From 878d4e67332b68405a024ea8fbcabaaa62956440 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Mon, 22 Jun 2026 01:49:14 +0200 Subject: [PATCH 01/15] Add Cashu ecash support Introduce optional Cashu ecash integration so users can mint, send, and redeem bearer ecash tokens against an external mint, alongside the existing LNbits Lightning wallet. - internal/cashu: minimal mint client (NUT-00/03/04/05 style) with blinded-message crypto, token (de)serialization, and types - /cashu command with mint/send/receive(redeem/claim) subcommands - Inline query support to share claimable Cashu tokens in group chats (claim/cancel buttons) - Config block (cashu.enabled/mint_url/unit) with validation; disabled by default and a no-op unless explicitly enabled - Cashu-specific error types and English translations Co-Authored-By: Claude Opus 4.8 --- config.yaml.example | 4 + internal/cashu/client.go | 288 ++++++++++++++++++++++++++ internal/cashu/crypto.go | 160 +++++++++++++++ internal/cashu/token.go | 57 ++++++ internal/cashu/types.go | 148 ++++++++++++++ internal/config.go | 20 ++ internal/errors/types.go | 8 + internal/telegram/bot.go | 32 +-- internal/telegram/cashu.go | 270 +++++++++++++++++++++++++ internal/telegram/cashu_inline.go | 326 ++++++++++++++++++++++++++++++ internal/telegram/handler.go | 53 +++++ internal/telegram/inline_query.go | 10 + translations/en.toml | 31 ++- 13 files changed, 1394 insertions(+), 13 deletions(-) create mode 100644 internal/cashu/client.go create mode 100644 internal/cashu/crypto.go create mode 100644 internal/cashu/token.go create mode 100644 internal/cashu/types.go create mode 100644 internal/telegram/cashu.go create mode 100644 internal/telegram/cashu_inline.go diff --git a/config.yaml.example b/config.yaml.example index 360137a3..7032c391 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -31,3 +31,7 @@ generate: worker: 2 nostr: private_key: "hex private key here" +cashu: + enabled: false + mint_url: "https://mint.minibits.cash" + unit: "sat" diff --git a/internal/cashu/client.go b/internal/cashu/client.go new file mode 100644 index 00000000..a15fceb4 --- /dev/null +++ b/internal/cashu/client.go @@ -0,0 +1,288 @@ +package cashu + +import ( + "encoding/hex" + "fmt" + "strconv" + + "github.com/imroc/req" + log "github.com/sirupsen/logrus" +) + +// Client is an HTTP client for communicating with a Cashu mint. +type Client struct { + mintURL string + header req.Header +} + +// NewClient creates a new Cashu mint client. +func NewClient(mintURL string) *Client { + return &Client{ + mintURL: mintURL, + header: req.Header{ + "Content-Type": "application/json", + "Accept": "application/json", + }, + } +} + +// MintURL returns the configured mint URL. +func (c *Client) MintURL() string { + return c.mintURL +} + +// GetInfo fetches mint information (NUT-06). +func (c *Client) GetInfo() (*MintInfo, error) { + resp, err := req.Get(c.mintURL+"/v1/info", c.header) + if err != nil { + return nil, fmt.Errorf("failed to get mint info: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("mint info request failed with status %d", resp.Response().StatusCode) + } + var info MintInfo + err = resp.ToJSON(&info) + return &info, err +} + +// GetKeysets fetches active keysets from the mint (NUT-01). +func (c *Client) GetKeysets() (*KeysResponse, error) { + resp, err := req.Get(c.mintURL+"/v1/keys", c.header) + if err != nil { + return nil, fmt.Errorf("failed to get keysets: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("keysets request failed with status %d", resp.Response().StatusCode) + } + var keys KeysResponse + err = resp.ToJSON(&keys) + return &keys, err +} + +// MintQuote requests a mint quote (NUT-04, step 1). +// The mint returns a Lightning invoice that must be paid before tokens can be minted. +func (c *Client) MintQuote(amount int64, unit string) (*MintQuoteResponse, error) { + body := MintQuoteRequest{ + Amount: amount, + Unit: unit, + } + resp, err := req.Post(c.mintURL+"/v1/mint/quote/bolt11", c.header, req.BodyJSON(&body)) + if err != nil { + return nil, fmt.Errorf("failed to request mint quote: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("mint quote failed with status %d: %s", resp.Response().StatusCode, resp.String()) + } + var quote MintQuoteResponse + err = resp.ToJSON("e) + return "e, err +} + +// CheckMintQuote checks the status of a mint quote (NUT-04). +func (c *Client) CheckMintQuote(quoteId string) (*MintQuoteResponse, error) { + resp, err := req.Get(c.mintURL+"/v1/mint/quote/bolt11/"+quoteId, c.header) + if err != nil { + return nil, fmt.Errorf("failed to check mint quote: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("check mint quote failed with status %d", resp.Response().StatusCode) + } + var quote MintQuoteResponse + err = resp.ToJSON("e) + return "e, err +} + +// Mint exchanges blinded messages for blind signatures after the quote invoice is paid (NUT-04, step 2). +func (c *Client) Mint(quoteId string, outputs []BlindedMessage) (*MintResponse, error) { + body := MintRequest{ + Quote: quoteId, + Outputs: outputs, + } + resp, err := req.Post(c.mintURL+"/v1/mint/bolt11", c.header, req.BodyJSON(&body)) + if err != nil { + return nil, fmt.Errorf("failed to mint tokens: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("mint failed with status %d: %s", resp.Response().StatusCode, resp.String()) + } + var mintResp MintResponse + err = resp.ToJSON(&mintResp) + return &mintResp, err +} + +// MeltQuote requests a melt quote to pay a Lightning invoice with ecash (NUT-05, step 1). +func (c *Client) MeltQuote(bolt11 string, unit string) (*MeltQuoteResponse, error) { + body := MeltQuoteRequest{ + Request: bolt11, + Unit: unit, + } + resp, err := req.Post(c.mintURL+"/v1/melt/quote/bolt11", c.header, req.BodyJSON(&body)) + if err != nil { + return nil, fmt.Errorf("failed to request melt quote: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("melt quote failed with status %d: %s", resp.Response().StatusCode, resp.String()) + } + var quote MeltQuoteResponse + err = resp.ToJSON("e) + return "e, err +} + +// Melt submits proofs to the mint which pays the Lightning invoice (NUT-05, step 2). +func (c *Client) Melt(quoteId string, inputs []Proof) (*MeltResponse, error) { + body := MeltRequest{ + Quote: quoteId, + Inputs: inputs, + } + resp, err := req.Post(c.mintURL+"/v1/melt/bolt11", c.header, req.BodyJSON(&body)) + if err != nil { + return nil, fmt.Errorf("failed to melt tokens: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("melt failed with status %d: %s", resp.Response().StatusCode, resp.String()) + } + var meltResp MeltResponse + err = resp.ToJSON(&meltResp) + return &meltResp, err +} + +// CheckState checks whether proofs have been spent (NUT-07). +func (c *Client) CheckState(proofs []Proof) (*CheckStateResponse, error) { + ys := make([]string, len(proofs)) + for i, p := range proofs { + y, err := CalculateY(p.Secret) + if err != nil { + return nil, fmt.Errorf("failed to calculate Y for proof: %w", err) + } + ys[i] = y + } + body := CheckStateRequest{Ys: ys} + resp, err := req.Post(c.mintURL+"/v1/checkstate", c.header, req.BodyJSON(&body)) + if err != nil { + return nil, fmt.Errorf("failed to check state: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("check state failed with status %d", resp.Response().StatusCode) + } + var stateResp CheckStateResponse + err = resp.ToJSON(&stateResp) + return &stateResp, err +} + +// MintTokens is a high-level function that orchestrates the full minting flow: +// 1. Get active keyset +// 2. Split amount into power-of-2 denominations +// 3. Generate secrets and blinding factors +// 4. Create blinded messages +// 5. Send to mint and receive blind signatures +// 6. Unblind signatures to get valid proofs +// 7. Serialize to cashuA token string +// +// The caller must have already paid the mint quote's Lightning invoice. +func (c *Client) MintTokens(quoteId string, amount int64, memo string) (string, error) { + // Get active keyset + keysResp, err := c.GetKeysets() + if err != nil { + return "", fmt.Errorf("failed to get keysets: %w", err) + } + + // Find the active sat keyset + var activeKeyset *Keyset + for i := range keysResp.Keysets { + ks := &keysResp.Keysets[i] + if ks.Active && ks.Unit == "sat" { + activeKeyset = ks + break + } + } + if activeKeyset == nil { + return "", fmt.Errorf("no active sat keyset found at mint") + } + + // Split amount into powers of 2 + amounts := SplitAmount(amount) + + // Generate blinded messages + outputs := make([]BlindedMessage, len(amounts)) + blindingResults := make([]*BlindingResult, len(amounts)) + for i, amt := range amounts { + br, err := BlindMessage(GenerateSecret()) + if err != nil { + return "", fmt.Errorf("failed to blind message: %w", err) + } + blindingResults[i] = br + outputs[i] = BlindedMessage{ + Amount: amt, + Id: activeKeyset.Id, + B_: hex.EncodeToString(br.B_.SerializeCompressed()), + } + } + + // Send to mint + mintResp, err := c.Mint(quoteId, outputs) + if err != nil { + return "", fmt.Errorf("mint request failed: %w", err) + } + + if len(mintResp.Signatures) != len(outputs) { + return "", fmt.Errorf("expected %d signatures, got %d", len(outputs), len(mintResp.Signatures)) + } + + // Unblind signatures to get valid proofs + proofs := make([]Proof, len(mintResp.Signatures)) + for i, sig := range mintResp.Signatures { + // Look up the mint's public key for this denomination + amtStr := strconv.FormatInt(sig.Amount, 10) + mintPubKey, ok := activeKeyset.Keys[amtStr] + if !ok { + return "", fmt.Errorf("no mint key found for amount %d", sig.Amount) + } + + // Unblind: C = C_ - r*K + C, err := UnblindSignature(sig.C_, blindingResults[i].R, mintPubKey) + if err != nil { + return "", fmt.Errorf("failed to unblind signature: %w", err) + } + + proofs[i] = Proof{ + Amount: sig.Amount, + Id: sig.Id, + Secret: blindingResults[i].Secret, + C: C, + } + } + + // Serialize to cashuA token + token := &TokenV3{ + Token: []TokenEntry{ + { + Mint: c.mintURL, + Proofs: proofs, + }, + }, + Memo: memo, + Unit: "sat", + } + + tokenStr, err := token.Serialize() + if err != nil { + return "", fmt.Errorf("failed to serialize token: %w", err) + } + + log.Infof("[cashu] Minted %d sat token with %d proofs", amount, len(proofs)) + return tokenStr, nil +} + +// AllProofsUnspent checks if all proofs in a token are unspent. +func (c *Client) AllProofsUnspent(proofs []Proof) (bool, error) { + stateResp, err := c.CheckState(proofs) + if err != nil { + return false, err + } + for _, s := range stateResp.States { + if s.State != "UNSPENT" { + return false, nil + } + } + return true, nil +} diff --git a/internal/cashu/crypto.go b/internal/cashu/crypto.go new file mode 100644 index 00000000..c2fea0f6 --- /dev/null +++ b/internal/cashu/crypto.go @@ -0,0 +1,160 @@ +package cashu + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +// Domain separator per NUT-00 specification for hash_to_curve. +var domainSeparator = []byte("Secp256k1_HashToCurve_Cashu_") + +// HashToCurve deterministically maps a message to a point on the secp256k1 curve. +// Follows the NUT-00 specification: SHA256(domain_separator || msg), then +// try interpreting as x-coordinate with even y (02 prefix), incrementing a +// counter byte until a valid point is found. +func HashToCurve(message []byte) (*btcec.PublicKey, error) { + msgHash := sha256.Sum256(append(domainSeparator, message...)) + for counter := uint32(0); counter < 65536; counter++ { + // hash = SHA256(msgHash || counter_le_bytes) + counterBytes := []byte{byte(counter), byte(counter >> 8), byte(counter >> 16), byte(counter >> 24)} + h := sha256.Sum256(append(msgHash[:], counterBytes...)) + // Try to parse as compressed point with 0x02 prefix (even y) + compressed := make([]byte, 33) + compressed[0] = 0x02 + copy(compressed[1:], h[:]) + pk, err := btcec.ParsePubKey(compressed) + if err == nil { + return pk, nil + } + } + return nil, fmt.Errorf("could not find valid point for hash_to_curve") +} + +// BlindingResult holds the output of the blinding step. +type BlindingResult struct { + B_ *btcec.PublicKey // blinded message point + R *btcec.PrivateKey // blinding factor (needed for unblinding) + Secret string // the original secret +} + +// BlindMessage blinds a secret for sending to the mint (Step 1 / Alice). +// B_ = Y + r*G, where Y = hash_to_curve(secret), r is a random blinding factor. +func BlindMessage(secret string) (*BlindingResult, error) { + Y, err := HashToCurve([]byte(secret)) + if err != nil { + return nil, fmt.Errorf("hash_to_curve failed: %w", err) + } + + // Generate random blinding factor r + rBytes := make([]byte, 32) + _, err = rand.Read(rBytes) + if err != nil { + return nil, fmt.Errorf("failed to generate random blinding factor: %w", err) + } + r, _ := btcec.PrivKeyFromBytes(rBytes) + + // r*G (the public key corresponding to r) + rG := r.PubKey() + + // B_ = Y + r*G + var yJ, rGJ, bJ secp256k1.JacobianPoint + Y.AsJacobian(&yJ) + rG.AsJacobian(&rGJ) + secp256k1.AddNonConst(&yJ, &rGJ, &bJ) + bJ.ToAffine() + B_ := btcec.NewPublicKey(&bJ.X, &bJ.Y) + + return &BlindingResult{ + B_: B_, + R: r, + Secret: secret, + }, nil +} + +// UnblindSignature removes the blinding factor from the mint's signature (Step 3 / Alice). +// C = C_ - r*K, where K is the mint's public key for this denomination. +func UnblindSignature(C_hex string, r *btcec.PrivateKey, mintPubKeyHex string) (string, error) { + C_bytes, err := hex.DecodeString(C_hex) + if err != nil { + return "", fmt.Errorf("invalid C_ hex: %w", err) + } + C_, err := btcec.ParsePubKey(C_bytes) + if err != nil { + return "", fmt.Errorf("invalid C_ point: %w", err) + } + + kBytes, err := hex.DecodeString(mintPubKeyHex) + if err != nil { + return "", fmt.Errorf("invalid mint pubkey hex: %w", err) + } + K, err := btcec.ParsePubKey(kBytes) + if err != nil { + return "", fmt.Errorf("invalid mint pubkey point: %w", err) + } + + // rK = r * K + var kJ, rkJ secp256k1.JacobianPoint + K.AsJacobian(&kJ) + // Scalar multiply: r * K + rScalar := new(secp256k1.ModNScalar) + rScalar.SetByteSlice(r.Serialize()) + secp256k1.ScalarMultNonConst(rScalar, &kJ, &rkJ) + + // Negate rK to get -rK + rkJ.ToAffine() + negY := new(secp256k1.FieldVal).NegateVal(&rkJ.Y, 1).Normalize() + negRK := btcec.NewPublicKey(&rkJ.X, negY) + + // C = C_ + (-rK) = C_ - rK + var c_J, negRKJ, cJ secp256k1.JacobianPoint + C_.AsJacobian(&c_J) + negRK.AsJacobian(&negRKJ) + secp256k1.AddNonConst(&c_J, &negRKJ, &cJ) + cJ.ToAffine() + C := btcec.NewPublicKey(&cJ.X, &cJ.Y) + + return hex.EncodeToString(C.SerializeCompressed()), nil +} + +// CalculateY computes Y = hash_to_curve(secret) for use in NUT-07 state checking. +func CalculateY(secret string) (string, error) { + Y, err := HashToCurve([]byte(secret)) + if err != nil { + return "", err + } + return hex.EncodeToString(Y.SerializeCompressed()), nil +} + +// GenerateSecret creates a random 32-byte hex-encoded secret for a proof. +func GenerateSecret() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// SplitAmount decomposes a satoshi amount into powers of 2. +// For example: 13 -> [1, 4, 8] +func SplitAmount(amount int64) []int64 { + var amounts []int64 + for i := 0; amount > 0; i++ { + if amount&1 == 1 { + amounts = append(amounts, int64(1)<>= 1 + } + return amounts +} + +// PointToHex serializes a public key point to compressed hex string. +func PointToHex(p *btcec.PublicKey) string { + return hex.EncodeToString(p.SerializeCompressed()) +} + +// dummy use to satisfy the big import for potential future use +var _ = new(big.Int) diff --git a/internal/cashu/token.go b/internal/cashu/token.go new file mode 100644 index 00000000..0994e1ac --- /dev/null +++ b/internal/cashu/token.go @@ -0,0 +1,57 @@ +package cashu + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +const ( + // TokenPrefixV3 is the prefix for Cashu V3 tokens. + TokenPrefixV3 = "cashuA" +) + +// Serialize encodes a TokenV3 to the cashuA... string format. +// Format: "cashuA" + base64url(json(TokenV3)) +func (t *TokenV3) Serialize() (string, error) { + jsonBytes, err := json.Marshal(t) + if err != nil { + return "", fmt.Errorf("failed to marshal token: %w", err) + } + encoded := base64.URLEncoding.EncodeToString(jsonBytes) + return TokenPrefixV3 + encoded, nil +} + +// Deserialize decodes a cashuA... string to a TokenV3. +func Deserialize(tokenStr string) (*TokenV3, error) { + tokenStr = strings.TrimSpace(tokenStr) + + if !strings.HasPrefix(tokenStr, TokenPrefixV3) { + return nil, fmt.Errorf("invalid token: must start with %s", TokenPrefixV3) + } + + encoded := strings.TrimPrefix(tokenStr, TokenPrefixV3) + + // Try standard base64url first, then try with padding + jsonBytes, err := base64.URLEncoding.DecodeString(encoded) + if err != nil { + // Try without padding + jsonBytes, err = base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("failed to decode token base64: %w", err) + } + } + + var token TokenV3 + err = json.Unmarshal(jsonBytes, &token) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal token JSON: %w", err) + } + + if len(token.Token) == 0 { + return nil, fmt.Errorf("token contains no entries") + } + + return &token, nil +} diff --git a/internal/cashu/types.go b/internal/cashu/types.go new file mode 100644 index 00000000..e1a3c437 --- /dev/null +++ b/internal/cashu/types.go @@ -0,0 +1,148 @@ +package cashu + +// Core Cashu ecash protocol types per NUT-00 through NUT-07. + +// Proof represents a single ecash proof (a signed token for a specific amount). +type Proof struct { + Amount int64 `json:"amount"` + Id string `json:"id"` // keyset ID + Secret string `json:"secret"` // spending secret + C string `json:"C"` // mint signature (compressed point hex) +} + +// BlindedMessage is sent to the mint during minting (Step 1 of BDHKE). +type BlindedMessage struct { + Amount int64 `json:"amount"` + Id string `json:"id"` // keyset ID + B_ string `json:"B_"` // blinded secret (compressed point hex) +} + +// BlindSignature is returned by the mint (Step 2 of BDHKE). +type BlindSignature struct { + Amount int64 `json:"amount"` + Id string `json:"id"` + C_ string `json:"C_"` // blinded signature (compressed point hex) +} + +// Keyset represents a mint keyset (NUT-01/NUT-02). +type Keyset struct { + Id string `json:"id"` + Unit string `json:"unit"` + Keys map[string]string `json:"keys"` // denomination string -> pubkey hex + Active bool `json:"active"` +} + +// KeysResponse is the response from GET /v1/keys. +type KeysResponse struct { + Keysets []Keyset `json:"keysets"` +} + +// TokenV3 is the top-level Cashu V3 token structure (cashuA prefix). +type TokenV3 struct { + Token []TokenEntry `json:"token"` + Memo string `json:"memo,omitempty"` + Unit string `json:"unit,omitempty"` +} + +// TokenEntry groups proofs by mint URL. +type TokenEntry struct { + Mint string `json:"mint"` + Proofs []Proof `json:"proofs"` +} + +// Amount returns the total satoshi value of all proofs in the token. +func (t *TokenV3) Amount() int64 { + var total int64 + for _, entry := range t.Token { + for _, p := range entry.Proofs { + total += p.Amount + } + } + return total +} + +// MintQuoteRequest is sent to POST /v1/mint/quote/bolt11 (NUT-04 step 1). +type MintQuoteRequest struct { + Amount int64 `json:"amount"` + Unit string `json:"unit"` +} + +// MintQuoteResponse is returned by the mint with a Lightning invoice to pay. +type MintQuoteResponse struct { + Quote string `json:"quote"` + Request string `json:"request"` // bolt11 invoice + State string `json:"state"` // "UNPAID", "PAID", "ISSUED" + Expiry int64 `json:"expiry"` +} + +// MintRequest is sent to POST /v1/mint/bolt11 after paying the invoice (NUT-04 step 2). +type MintRequest struct { + Quote string `json:"quote"` + Outputs []BlindedMessage `json:"outputs"` +} + +// MintResponse contains the blind signatures from the mint. +type MintResponse struct { + Signatures []BlindSignature `json:"signatures"` +} + +// MeltQuoteRequest is sent to POST /v1/melt/quote/bolt11 (NUT-05 step 1). +type MeltQuoteRequest struct { + Request string `json:"request"` // bolt11 invoice for the mint to pay + Unit string `json:"unit"` +} + +// MeltQuoteResponse tells us how much the melt will cost. +type MeltQuoteResponse struct { + Quote string `json:"quote"` + Amount int64 `json:"amount"` + FeeReserve int64 `json:"fee_reserve"` + State string `json:"state"` // "UNPAID", "PENDING", "PAID" + Expiry int64 `json:"expiry"` +} + +// MeltRequest is sent to POST /v1/melt/bolt11 with the proofs (NUT-05 step 2). +type MeltRequest struct { + Quote string `json:"quote"` + Inputs []Proof `json:"inputs"` +} + +// MeltResponse indicates whether the melt (Lightning payment) succeeded. +type MeltResponse struct { + State string `json:"state"` // "PAID", "UNPAID", "PENDING" + Preimage string `json:"payment_preimage,omitempty"` +} + +// CheckStateRequest is sent to POST /v1/checkstate (NUT-07). +type CheckStateRequest struct { + Ys []string `json:"Ys"` // Y = hash_to_curve(secret) for each proof +} + +// ProofState represents the state of a single proof. +type ProofState struct { + Y string `json:"Y"` + State string `json:"state"` // "UNSPENT", "SPENT", "PENDING" +} + +// CheckStateResponse returns the state of requested proofs. +type CheckStateResponse struct { + States []ProofState `json:"states"` +} + +// MintInfo is the response from GET /v1/info (NUT-06). +type MintInfo struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` +} + +// SwapRequest is sent to POST /v1/swap (NUT-03). +type SwapRequest struct { + Inputs []Proof `json:"inputs"` + Outputs []BlindedMessage `json:"outputs"` +} + +// SwapResponse contains the new blind signatures after a swap. +type SwapResponse struct { + Signatures []BlindSignature `json:"signatures"` +} diff --git a/internal/config.go b/internal/config.go index 1ae3b3a3..463eb80f 100644 --- a/internal/config.go +++ b/internal/config.go @@ -16,8 +16,15 @@ var Configuration = struct { Lnbits LnbitsConfiguration `yaml:"lnbits"` Generate GenerateConfiguration `yaml:"generate"` Nostr NostrConfiguration `yaml:"nostr"` + Cashu CashuConfiguration `yaml:"cashu"` }{} +type CashuConfiguration struct { + Enabled bool `yaml:"enabled"` + MintURL string `yaml:"mint_url"` + Unit string `yaml:"unit"` +} + type NostrConfiguration struct { PrivateKey string `yaml:"private_key"` } @@ -88,6 +95,19 @@ func init() { } Configuration.Bot.LNURLHostUrl = hostname checkLnbitsConfiguration() + checkCashuConfiguration() +} + +func checkCashuConfiguration() { + if Configuration.Cashu.Enabled && Configuration.Cashu.MintURL == "" { + log.Warnf("Cashu is enabled but no mint_url configured, disabling cashu support") + Configuration.Cashu.Enabled = false + } + if Configuration.Cashu.Unit == "" { + Configuration.Cashu.Unit = "sat" + } + // Remove trailing slash from mint URL + Configuration.Cashu.MintURL = strings.TrimSuffix(Configuration.Cashu.MintURL, "/") } func checkLnbitsConfiguration() { diff --git a/internal/errors/types.go b/internal/errors/types.go index 89014cc9..f23a7942 100644 --- a/internal/errors/types.go +++ b/internal/errors/types.go @@ -27,6 +27,14 @@ const ( InvalidAmountPerUserError ) +const ( + CashuTokenInvalidError TipBotErrorType = 4000 + iota + CashuTokenSpentError + CashuMintError + CashuMeltError + CashuDisabledError +) + const ( NoShopError TipBotErrorType = 3000 + iota NotShopOwnerError diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index c571f5a4..524bd9d4 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -15,6 +15,7 @@ import ( "github.com/eko/gocache/store" "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/cashu" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" "github.com/LightningTipBot/LightningTipBot/internal/storage" gocache "github.com/patrickmn/go-cache" @@ -23,12 +24,13 @@ import ( ) type TipBot struct { - DB *Databases - Bunt *storage.DB - ShopBunt *storage.DB - Telegram *tb.Bot - Client *lnbits.Client - limiter map[string]limiter.Limiter + DB *Databases + Bunt *storage.DB + ShopBunt *storage.DB + Telegram *tb.Bot + Client *lnbits.Client + CashuClient *cashu.Client + limiter map[string]limiter.Limiter Cache } type Cache struct { @@ -48,13 +50,19 @@ func NewBot() TipBot { dbs := AutoMigration() limiter.Start() poller := &ReactionPoller{Timeout: 60 * time.Second} + var cashuClient *cashu.Client + if internal.Configuration.Cashu.Enabled { + cashuClient = cashu.NewClient(internal.Configuration.Cashu.MintURL) + log.Infof("Cashu ecash support enabled, mint: %s", internal.Configuration.Cashu.MintURL) + } bot := TipBot{ - DB: dbs, - Client: lnbits.NewClient(internal.Configuration.Lnbits.AdminKey, internal.Configuration.Lnbits.Url), - Bunt: createBunt(internal.Configuration.Database.BuntDbPath), - ShopBunt: createBunt(internal.Configuration.Database.ShopBuntDbPath), - Telegram: newTelegramBot(poller), - Cache: Cache{GoCacheStore: gocacheStore}, + DB: dbs, + Client: lnbits.NewClient(internal.Configuration.Lnbits.AdminKey, internal.Configuration.Lnbits.Url), + CashuClient: cashuClient, + Bunt: createBunt(internal.Configuration.Database.BuntDbPath), + ShopBunt: createBunt(internal.Configuration.Database.ShopBuntDbPath), + Telegram: newTelegramBot(poller), + Cache: Cache{GoCacheStore: gocacheStore}, } poller.bot = &bot // register invoice callbacks early so the webhook server can dispatch diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go new file mode 100644 index 00000000..e8a33cda --- /dev/null +++ b/internal/telegram/cashu.go @@ -0,0 +1,270 @@ +package telegram + +import ( + "bytes" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/cashu" + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + "github.com/skip2/go-qrcode" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +// cashuHandler is the main /cashu command router. +func (bot *TipBot) cashuHandler(ctx intercept.Context) (intercept.Context, error) { + if !internal.Configuration.Cashu.Enabled { + bot.trySendMessage(ctx.Message().Sender, Translate(ctx, "cashuDisabledMessage")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + m := ctx.Message() + if m.Text == "" { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, nil + } + + args := strings.Fields(m.Text) + if len(args) < 2 { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, nil + } + + subcommand := strings.ToLower(args[1]) + switch subcommand { + case "mint": + return bot.cashuMintHandler(ctx) + case "receive", "redeem", "claim": + return bot.cashuReceiveHandler(ctx) + case "send": + return bot.cashuSendHandler(ctx) + default: + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, nil + } +} + +// cashuMintHandler handles /cashu mint [memo] +// Creates ecash tokens by paying the external mint's Lightning invoice. +func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // Parse: /cashu mint [memo] + args := strings.Fields(m.Text) + if len(args) < 3 { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + amount, err := GetAmount(args[2]) + if err != nil || amount < 1 { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, errors.New(errors.InvalidAmountError, fmt.Errorf("invalid cashu mint amount")) + } + + memo := "" + if len(args) > 3 { + memo = strings.Join(args[3:], " ") + } + + // Check user balance + balance, err := bot.GetUserBalanceCached(user) + if err != nil { + log.Errorf("[cashu mint] Error getting balance: %s", err.Error()) + return ctx, errors.New(errors.GetBalanceError, err) + } + if balance < amount { + bot.trySendMessage(m.Sender, Translate(ctx, "balanceTooLowMessage")) + return ctx, errors.New(errors.BalanceToLowError, fmt.Errorf("balance too low for cashu mint")) + } + + // Notify user we're working on it + statusMsg := bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "cashuMintMessage"), amount)) + + // Step 1: Request mint quote from the external Cashu mint + quote, err := bot.CashuClient.MintQuote(amount, "sat") + if err != nil { + log.Errorf("[cashu mint] MintQuote error: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + log.Infof("[cashu mint] Got quote %s for %d sat, invoice: %s...", quote.Quote, amount, truncate(quote.Request, 30)) + + // Step 2: Pay the mint's Lightning invoice from user's wallet + _, err = user.Wallet.Pay(lnbits.PaymentParams{ + Out: true, + Bolt11: quote.Request, + }, bot.Client) + if err != nil { + log.Errorf("[cashu mint] Payment to mint failed: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + log.Infof("[cashu mint] Paid mint invoice for quote %s", quote.Quote) + + // Step 3: Wait briefly for payment to settle, then check quote status + time.Sleep(2 * time.Second) + + // Step 4: Mint the tokens + tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, memo) + if err != nil { + log.Errorf("[cashu mint] MintTokens failed: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + // Step 5: Generate QR code + qr, err := qrcode.Encode(tokenStr, qrcode.Medium, 512) + if err != nil { + log.Errorf("[cashu mint] QR code generation failed: %s", err.Error()) + // Still send the token string even if QR fails + bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount)+"\n\n`"+tokenStr+"`") + return ctx, nil + } + + // Delete status message and send QR + token + bot.tryDeleteMessage(statusMsg) + + caption := fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount) + "\n\n`" + tokenStr + "`" + photo := &tb.Photo{ + File: tb.FromReader(bytes.NewReader(qr)), + Caption: caption, + } + bot.trySendMessage(m.Sender, photo) + + log.Infof("[cashu mint] %s minted %d sat cashu token", GetUserStr(m.Sender), amount) + return ctx, nil +} + +// cashuReceiveHandler handles /cashu receive +// Redeems a cashu token by melting it at the mint (mint pays a Lightning invoice to user's wallet). +func (bot *TipBot) cashuReceiveHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // Parse: /cashu receive + args := strings.Fields(m.Text) + if len(args) < 3 { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + tokenStr := args[2] + + return bot.redeemCashuToken(ctx, tokenStr, user, m.Sender) +} + +// cashuSendHandler handles /cashu send [memo] +// Creates a cashu token (same as mint) for the purpose of sharing. +func (bot *TipBot) cashuSendHandler(ctx intercept.Context) (intercept.Context, error) { + // Send is functionally the same as mint - it creates a token the user can share + return bot.cashuMintHandler(ctx) +} + +// redeemCashuToken is the shared logic for receiving/redeeming a cashu token. +func (bot *TipBot) redeemCashuToken(ctx intercept.Context, tokenStr string, user *lnbits.User, recipient *tb.User) (intercept.Context, error) { + // Step 1: Deserialize the token + token, err := cashu.Deserialize(tokenStr) + if err != nil { + log.Warnf("[cashu receive] Invalid token: %s", err.Error()) + bot.trySendMessage(recipient, Translate(ctx, "cashuTokenInvalidMessage")) + return ctx, err + } + + if len(token.Token) == 0 || len(token.Token[0].Proofs) == 0 { + bot.trySendMessage(recipient, Translate(ctx, "cashuTokenInvalidMessage")) + return ctx, fmt.Errorf("token has no proofs") + } + + // Step 2: Validate the token's mint URL matches our configured mint + tokenMintURL := token.Token[0].Mint + if tokenMintURL != bot.CashuClient.MintURL() { + log.Warnf("[cashu receive] Token from unknown mint: %s (expected: %s)", tokenMintURL, bot.CashuClient.MintURL()) + bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuMintMismatchMessage"), bot.CashuClient.MintURL())) + return ctx, fmt.Errorf("mint mismatch") + } + + proofs := token.Token[0].Proofs + totalAmount := token.Amount() + + // Step 3: Check if proofs are unspent (NUT-07) + unspent, err := bot.CashuClient.AllProofsUnspent(proofs) + if err != nil { + log.Warnf("[cashu receive] State check failed: %s", err.Error()) + // Don't abort - some mints may not support NUT-07. Proceed and let melt fail if spent. + } else if !unspent { + bot.trySendMessage(recipient, Translate(ctx, "cashuTokenSpentMessage")) + return ctx, fmt.Errorf("token already spent") + } + + // Step 4: Create a Lightning invoice on the user's LNbits wallet + invoice, err := user.Wallet.Invoice(lnbits.InvoiceParams{ + Out: false, + Amount: totalAmount, + Memo: fmt.Sprintf("Cashu ecash redeem (%d sat)", totalAmount), + }, bot.Client) + if err != nil { + log.Errorf("[cashu receive] Failed to create invoice: %s", err.Error()) + bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + // Step 5: Request melt quote from mint (ask mint to pay our invoice) + meltQuote, err := bot.CashuClient.MeltQuote(invoice.PaymentRequest, "sat") + if err != nil { + log.Errorf("[cashu receive] MeltQuote failed: %s", err.Error()) + bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + // Check if the melt will consume more than the token provides (fees) + meltCost := meltQuote.Amount + meltQuote.FeeReserve + if meltCost > totalAmount { + log.Warnf("[cashu receive] Melt cost (%d) exceeds token amount (%d)", meltCost, totalAmount) + bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuFeeTooHighMessage"), totalAmount, meltCost)) + return ctx, fmt.Errorf("melt fees too high") + } + + // Step 6: Melt the proofs (mint pays the invoice) + meltResp, err := bot.CashuClient.Melt(meltQuote.Quote, proofs) + if err != nil { + log.Errorf("[cashu receive] Melt failed: %s", err.Error()) + bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + if meltResp.State != "PAID" { + log.Warnf("[cashu receive] Melt not paid, state: %s", meltResp.State) + bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + return ctx, fmt.Errorf("melt state: %s", meltResp.State) + } + + // Success! + bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuReceiveSuccessMessage"), totalAmount)) + log.Infof("[cashu receive] %s redeemed %d sat cashu token", GetUserStr(recipient), totalAmount) + + return ctx, nil +} + +// truncate shortens a string for logging. +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go new file mode 100644 index 00000000..c228f0b4 --- /dev/null +++ b/internal/telegram/cashu_inline.go @@ -0,0 +1,326 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + "github.com/LightningTipBot/LightningTipBot/internal/cashu" + "github.com/LightningTipBot/LightningTipBot/internal/errors" + "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" + + "github.com/eko/gocache/store" + + log "github.com/sirupsen/logrus" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +var ( + inlineCashuMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnClaimInlineCashu = inlineCashuMenu.Data("🥜 Claim", "claim_cashu_inline") + btnCancelInlineCashu = inlineCashuMenu.Data("🚫 Cancel", "cancel_cashu_inline") +) + +// InlineCashu represents a cashu token shared in a group chat via inline query. +type InlineCashu struct { + *storage.Base + Message string `json:"inline_cashu_message"` + Amount int64 `json:"inline_cashu_amount"` + From *lnbits.User `json:"inline_cashu_from"` + Token string `json:"inline_cashu_token"` + Memo string `json:"inline_cashu_memo"` + Claimed bool `json:"inline_cashu_claimed"` + ClaimedBy *lnbits.User `json:"inline_cashu_claimed_by,omitempty"` + LanguageCode string `json:"languagecode"` +} + +func (bot *TipBot) makeCashuKeyboard(ctx context.Context, id string) *tb.ReplyMarkup { + menu := &tb.ReplyMarkup{ResizeKeyboard: true} + claimBtn := menu.Data(Translate(ctx, "cashuClaimButtonMessage"), "claim_cashu_inline", id) + cancelBtn := menu.Data(Translate(ctx, "cancelButtonMessage"), "cancel_cashu_inline", id) + menu.Inline( + menu.Row(claimBtn, cancelBtn), + ) + return menu +} + +// handleInlineCashuQuery handles inline query: @bot cashu [memo] +func (bot *TipBot) handleInlineCashuQuery(ctx intercept.Context) (intercept.Context, error) { + if !internal.Configuration.Cashu.Enabled { + bot.inlineQueryReplyWithError(ctx, "Cashu disabled", "Cashu ecash support is not enabled on this bot.") + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + query := ctx.Query() + text := query.Text + args := strings.Fields(text) + + // cashu [memo] + if len(args) < 2 { + bot.inlineQueryReplyWithError(ctx, + TranslateUser(ctx, "inlineQueryCashuTitle"), + fmt.Sprintf(TranslateUser(ctx, "inlineQueryCashuDescription"), bot.Telegram.Me.Username)) + return ctx, nil + } + + amount, err := GetAmount(args[1]) + if err != nil || amount < 1 { + bot.inlineQueryReplyWithError(ctx, "Invalid amount", fmt.Sprintf(TranslateUser(ctx, "inlineQueryCashuDescription"), bot.Telegram.Me.Username)) + return ctx, nil + } + + memo := "" + if len(args) > 2 { + memo = strings.Join(args[2:], " ") + } + + fromUser := LoadUser(ctx) + fromUserStr := GetUserStr(query.Sender) + + // Check balance + balance, err := bot.GetUserBalanceCached(fromUser) + if err != nil { + bot.inlineQueryReplyWithError(ctx, "Error", "Could not check balance.") + return ctx, err + } + if balance < amount { + bot.inlineQueryReplyWithError(ctx, + TranslateUser(ctx, "balanceTooLowMessage"), + fmt.Sprintf(TranslateUser(ctx, "inlineQueryCashuDescription"), bot.Telegram.Me.Username)) + return ctx, nil + } + + // Mint the token + quote, err := bot.CashuClient.MintQuote(amount, "sat") + if err != nil { + log.Errorf("[cashu inline] MintQuote error: %s", err.Error()) + bot.inlineQueryReplyWithError(ctx, "Mint error", "Could not communicate with the cashu mint.") + return ctx, err + } + + // Pay the mint's invoice + _, err = fromUser.Wallet.Pay(lnbits.PaymentParams{ + Out: true, + Bolt11: quote.Request, + }, bot.Client) + if err != nil { + log.Errorf("[cashu inline] Payment to mint failed: %s", err.Error()) + bot.inlineQueryReplyWithError(ctx, "Payment error", "Could not pay the mint's invoice.") + return ctx, err + } + + time.Sleep(2 * time.Second) + + tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, memo) + if err != nil { + log.Errorf("[cashu inline] MintTokens failed: %s", err.Error()) + bot.inlineQueryReplyWithError(ctx, "Mint error", "Could not create ecash token.") + return ctx, err + } + + // Create the inline cashu object + id := fmt.Sprintf("cashu:%s:%d", RandStringRunes(10), amount) + inlineMessage := fmt.Sprintf(Translate(ctx, "cashuSendMessage"), GetUserStrMd(query.Sender), amount) + if len(memo) > 0 { + inlineMessage += fmt.Sprintf("\n_Memo: %s_", memo) + } + + inlineCashu := &InlineCashu{ + Base: storage.New(storage.ID(id)), + Message: inlineMessage, + Amount: amount, + From: fromUser, + Token: tokenStr, + Memo: memo, + Claimed: false, + LanguageCode: "en", + } + + // Build inline result + results := make(tb.Results, 1) + result := &tb.ArticleResult{ + Text: inlineMessage, + Title: fmt.Sprintf(TranslateUser(ctx, "inlineResultCashuTitle"), amount), + Description: TranslateUser(ctx, "inlineResultCashuDescription"), + ThumbURL: queryImage, + } + result.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: bot.makeCashuKeyboard(ctx, inlineCashu.ID).InlineKeyboard} + results[0] = result + results[0].SetResultID(inlineCashu.ID) + + // Cache for later retrieval + bot.Cache.Set(inlineCashu.ID, inlineCashu, &store.Options{Expiration: 5 * time.Minute}) + + log.Infof("[cashu inline] %s created inline cashu %s: %d sat", fromUserStr, id, amount) + + err = bot.Telegram.Answer(ctx.Query(), &tb.QueryResponse{ + Results: results, + CacheTime: 1, + }) + if err != nil { + log.Errorln(err.Error()) + } + return ctx, nil +} + +// acceptInlineCashuHandler handles the "Claim" button click on an inline cashu token. +func (bot *TipBot) acceptInlineCashuHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + to := LoadUser(ctx) + + tx := &InlineCashu{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[acceptInlineCashuHandler] c.Data: %s, Error: %s", c.Data, err.Error()) + return ctx, err + } + + inlineCashu := fn.(*InlineCashu) + from := inlineCashu.From + + // Check if already claimed + if !inlineCashu.Active || inlineCashu.Claimed { + log.Tracef("[cashu] token %s already claimed", inlineCashu.ID) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cashuAlreadyClaimedMessage")) + return ctx, errors.Create(errors.NotActiveError) + } + + // Can't claim your own token + if from.Telegram.ID == to.Telegram.ID { + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "sendYourselfMessage")) + return ctx, errors.Create(errors.SelfPaymentError) + } + + // Create wallet for recipient if needed + if !to.Initialized { + _, err = bot.CreateWalletForTelegramUser(to.Telegram) + if err != nil { + log.Errorf("[cashu claim] Failed to create wallet: %s", err.Error()) + return ctx, err + } + to = LoadUser(ctx) // reload + } + + // Redeem the token + token, err := cashu.Deserialize(inlineCashu.Token) + if err != nil { + log.Errorf("[cashu claim] Invalid stored token: %s", err.Error()) + return ctx, err + } + + proofs := token.Token[0].Proofs + totalAmount := token.Amount() + + // Create invoice on recipient's wallet + invoice, err := to.Wallet.Invoice(lnbits.InvoiceParams{ + Out: false, + Amount: totalAmount, + Memo: fmt.Sprintf("Cashu ecash claim (%d sat)", totalAmount), + }, bot.Client) + if err != nil { + log.Errorf("[cashu claim] Failed to create invoice: %s", err.Error()) + return ctx, err + } + + // Melt at the mint + meltQuote, err := bot.CashuClient.MeltQuote(invoice.PaymentRequest, "sat") + if err != nil { + log.Errorf("[cashu claim] MeltQuote failed: %s", err.Error()) + return ctx, err + } + + meltResp, err := bot.CashuClient.Melt(meltQuote.Quote, proofs) + if err != nil { + log.Errorf("[cashu claim] Melt failed: %s", err.Error()) + return ctx, err + } + + if meltResp.State != "PAID" { + log.Warnf("[cashu claim] Melt not paid, state: %s", meltResp.State) + return ctx, fmt.Errorf("melt state: %s", meltResp.State) + } + + // Mark as claimed + inlineCashu.Claimed = true + inlineCashu.ClaimedBy = to + inlineCashu.Active = false + err = inlineCashu.Set(inlineCashu, bot.Bunt) + if err != nil { + log.Errorf("[cashu claim] Failed to update bunt: %s", err.Error()) + } + + // Update inline message + claimedMessage := fmt.Sprintf(Translate(ctx, "cashuSendClaimedMessage"), totalAmount, GetUserStrMd(to.Telegram)) + bot.tryEditMessage(c.Message, claimedMessage, &tb.ReplyMarkup{}) + + // Notify the sender + bot.trySendMessage(from.Telegram, fmt.Sprintf("🥜 Your %d sat cashu token was claimed by %s.", totalAmount, GetUserStr(to.Telegram))) + + log.Infof("[cashu claim] %s claimed %d sat cashu token from %s", GetUserStr(to.Telegram), totalAmount, GetUserStr(from.Telegram)) + return ctx, nil +} + +// cancelInlineCashuHandler handles the "Cancel" button click (only the creator can cancel). +func (bot *TipBot) cancelInlineCashuHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + + tx := &InlineCashu{Base: storage.New(storage.ID(c.Data))} + mutex.LockWithContext(ctx, tx.ID) + defer mutex.UnlockWithContext(ctx, tx.ID) + + fn, err := tx.Get(tx, bot.Bunt) + if err != nil { + log.Errorf("[cancelInlineCashuHandler] Error: %s", err.Error()) + return ctx, err + } + + inlineCashu := fn.(*InlineCashu) + + // Only the creator can cancel + if inlineCashu.From.Telegram.ID != user.Telegram.ID { + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cantDoThatMessage")) + return ctx, errors.Create(errors.InvalidTypeError) + } + + if !inlineCashu.Active || inlineCashu.Claimed { + return ctx, errors.Create(errors.NotActiveError) + } + + // Redeem the token back to the creator's wallet + token, err := cashu.Deserialize(inlineCashu.Token) + if err == nil && len(token.Token) > 0 && len(token.Token[0].Proofs) > 0 { + // Try to redeem back to creator + proofs := token.Token[0].Proofs + totalAmount := token.Amount() + + invoice, err := user.Wallet.Invoice(lnbits.InvoiceParams{ + Out: false, + Amount: totalAmount, + Memo: "Cashu token cancel refund", + }, bot.Client) + if err == nil { + meltQuote, err := bot.CashuClient.MeltQuote(invoice.PaymentRequest, "sat") + if err == nil { + bot.CashuClient.Melt(meltQuote.Quote, proofs) + } + } + } + + // Mark as inactive + inlineCashu.Active = false + inlineCashu.Canceled = true + inlineCashu.Set(inlineCashu, bot.Bunt) + + bot.tryEditMessage(c.Message, Translate(ctx, "cashuSendCancelledMessage"), &tb.ReplyMarkup{}) + log.Infof("[cashu cancel] %s cancelled cashu token %s", GetUserStr(user.Telegram), inlineCashu.ID) + return ctx, nil +} diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index 8096f6cf..ccd8ab41 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -404,6 +404,23 @@ func (bot TipBot) getHandler() []InterceptionWrapper { }, }, }, + // cashu ecash + { + Endpoints: []interface{}{"/cashu"}, + Handler: bot.cashuHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, { Endpoints: []interface{}{"/faucet", "/crane", "/spigot", "/tap", "/hydrant", "/funding", "/zapfhahn", "/kraan", "/kran", "/grifo", "/fonds", "/hana", "/keran", "/distribuzione", "/torneira", "/fici", "/kohoutek"}, Handler: bot.faucetHandler, @@ -834,6 +851,42 @@ func (bot TipBot) getHandler() []InterceptionWrapper { }, }, }, + // cashu inline claim + { + Endpoints: []interface{}{&btnClaimInlineCashu}, + Handler: bot.acceptInlineCashuHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.singletonCallbackInterceptor, + bot.localizerInterceptor, + bot.loadUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + bot.answerCallbackInterceptor, + }, + }, + }, + // cashu inline cancel + { + Endpoints: []interface{}{&btnCancelInlineCashu}, + Handler: bot.cancelInlineCashuHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, { Endpoints: []interface{}{&btnAcceptInlineTipjar}, Handler: bot.acceptInlineTipjarHandler, diff --git a/internal/telegram/inline_query.go b/internal/telegram/inline_query.go index 8aeddb6f..0ba69833 100644 --- a/internal/telegram/inline_query.go +++ b/internal/telegram/inline_query.go @@ -46,6 +46,11 @@ func (bot TipBot) inlineQueryInstructions(ctx intercept.Context) (intercept.Cont title: TranslateUser(ctx, "inlineQueryTipjarTitle"), description: fmt.Sprintf(TranslateUser(ctx, "inlineQueryTipjarDescription"), bot.Telegram.Me.Username), }, + { + url: queryImage, + title: TranslateUser(ctx, "inlineQueryCashuTitle"), + description: fmt.Sprintf(TranslateUser(ctx, "inlineQueryCashuDescription"), bot.Telegram.Me.Username), + }, } results := make(tb.Results, len(instructions)) // []tb.Result for i, instruction := range instructions { @@ -216,5 +221,10 @@ func (bot TipBot) anyQueryHandler(ctx intercept.Context) (intercept.Context, err if strings.HasPrefix(text, "receive") || strings.HasPrefix(text, "get") || strings.HasPrefix(text, "payme") || strings.HasPrefix(text, "request") { return bot.handleInlineReceiveQuery(ctx) } + + if strings.HasPrefix(text, "cashu") { + return bot.handleInlineCashuQuery(ctx) + } + return ctx, nil } diff --git a/translations/en.toml b/translations/en.toml index d1f2ab0c..fdafb1c9 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -409,4 +409,33 @@ For users (in private chat): `/join `\nExample: `/join TheBestBitcoi # DALLE GENERATE generateDalleHelpMessage = """Generate images using OpenAI DALLE 2.\nUsage: `/generate `\nPrice: 1000 sat""" generateDallePayInvoiceMessage = """Pay this invoice to generate four images 👇""" -generateDalleGeneratingMessage = """Your images are being generated. Please wait...""" \ No newline at end of file +generateDalleGeneratingMessage = """Your images are being generated. Please wait...""" + +# CASHU ECASH +cashuCommandStr = """cashu""" +cashuHelpText = """🥜 *Cashu ecash tokens* + +`/cashu mint [memo]` — Create an ecash token +`/cashu receive ` — Redeem a cashu token to your wallet +`/cashu send [memo]` — Create a cashu token to share + +Cashu tokens are digital bearer tokens backed by Bitcoin Lightning. You can copy-paste them, share them offline, or drop them in group chats.""" + +cashuMintMessage = """🥜 Creating a %d sat cashu token...""" +cashuMintSuccessMessage = """🥜 *Cashu Token Created*\n\nAmount: *%d sat*\n\nCopy this token to share it:""" +cashuReceiveSuccessMessage = """🥜 *Token Redeemed*\n\n*%d sat* have been added to your wallet.""" +cashuTokenInvalidMessage = """🥜 Invalid cashu token. Make sure you copied the full token string starting with `cashuA`.""" +cashuTokenSpentMessage = """🥜 This token has already been redeemed.""" +cashuMintErrorMessage = """🥜 Error communicating with the cashu mint. Please try again later.""" +cashuDisabledMessage = """🥜 Cashu ecash support is not enabled on this bot.""" +cashuMintMismatchMessage = """🥜 This token is from a different mint. This bot only accepts tokens from `%s`.""" +cashuFeeTooHighMessage = """🥜 Cannot redeem: the token has %d sat but melt fees would cost %d sat.""" +cashuSendMessage = """🥜 *%s* is sharing *%d sat* as ecash.""" +cashuSendClaimedMessage = """🥜 *%d sat* claimed by %s.""" +cashuSendCancelledMessage = """🥜 Cashu token cancelled.""" +cashuAlreadyClaimedMessage = """This token has already been claimed.""" +cashuClaimButtonMessage = """🥜 Claim""" +inlineQueryCashuTitle = """🥜 Share ecash token""" +inlineQueryCashuDescription = """Usage: @%s cashu [memo]""" +inlineResultCashuTitle = """🥜 Share %d sat ecash token""" +inlineResultCashuDescription = """Tap to share this ecash token in the chat.""" \ No newline at end of file From d5186940ce9e7fa0365c04d3cca9d93bbad63345 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 18:33:22 +0200 Subject: [PATCH 02/15] Fix cashu keyset selection for /v1/keys response MintTokens fetched keysets from /v1/keys (NUT-01), which returns only active keysets and has no "active" field. The Active bool therefore decoded to false and the "ks.Active && ks.Unit == sat" check never matched, failing every mint with "no active sat keyset found at mint" even after the mint invoice was paid. Match on unit alone, since all keysets from /v1/keys are active by definition. Co-Authored-By: Claude Opus 4.8 --- internal/cashu/client.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cashu/client.go b/internal/cashu/client.go index a15fceb4..15df3966 100644 --- a/internal/cashu/client.go +++ b/internal/cashu/client.go @@ -190,7 +190,9 @@ func (c *Client) MintTokens(quoteId string, amount int64, memo string) (string, var activeKeyset *Keyset for i := range keysResp.Keysets { ks := &keysResp.Keysets[i] - if ks.Active && ks.Unit == "sat" { + // /v1/keys (NUT-01) returns only active keysets and has no "active" field, + // so match on unit alone. ponytail: first sat keyset wins, fine for one mint. + if ks.Unit == "sat" { activeKeyset = ks break } From 3797e834a29eac706971e55a1e0f2d8b465a52cb Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 18:42:14 +0200 Subject: [PATCH 03/15] Fix inline cashu minting on every keystroke handleInlineCashuQuery minted a token and paid the mint invoice inside the inline query handler, which Telegram fires on every keystroke. Each character typed spent real sats (observed: 6 mints in ~35s). Mirror the inline-send flow: the query handler now only builds the result and stores intent; the token is minted from the sender's wallet on claim (acceptInlineCashuHandler). Cancel no longer needs a refund since an unclaimed token was never minted. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/cashu_inline.go | 82 ++++++++++++------------------- 1 file changed, 32 insertions(+), 50 deletions(-) diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go index c228f0b4..6b982b84 100644 --- a/internal/telegram/cashu_inline.go +++ b/internal/telegram/cashu_inline.go @@ -95,33 +95,9 @@ func (bot *TipBot) handleInlineCashuQuery(ctx intercept.Context) (intercept.Cont return ctx, nil } - // Mint the token - quote, err := bot.CashuClient.MintQuote(amount, "sat") - if err != nil { - log.Errorf("[cashu inline] MintQuote error: %s", err.Error()) - bot.inlineQueryReplyWithError(ctx, "Mint error", "Could not communicate with the cashu mint.") - return ctx, err - } - - // Pay the mint's invoice - _, err = fromUser.Wallet.Pay(lnbits.PaymentParams{ - Out: true, - Bolt11: quote.Request, - }, bot.Client) - if err != nil { - log.Errorf("[cashu inline] Payment to mint failed: %s", err.Error()) - bot.inlineQueryReplyWithError(ctx, "Payment error", "Could not pay the mint's invoice.") - return ctx, err - } - - time.Sleep(2 * time.Second) - - tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, memo) - if err != nil { - log.Errorf("[cashu inline] MintTokens failed: %s", err.Error()) - bot.inlineQueryReplyWithError(ctx, "Mint error", "Could not create ecash token.") - return ctx, err - } + // ponytail: do NOT mint here — inline queries fire on every keystroke, so + // minting here spends money per character typed. The token is minted from + // the sender's wallet on claim instead (acceptInlineCashuHandler). // Create the inline cashu object id := fmt.Sprintf("cashu:%s:%d", RandStringRunes(10), amount) @@ -135,7 +111,7 @@ func (bot *TipBot) handleInlineCashuQuery(ctx intercept.Context) (intercept.Cont Message: inlineMessage, Amount: amount, From: fromUser, - Token: tokenStr, + Token: "", // minted on claim Memo: memo, Claimed: false, LanguageCode: "en", @@ -209,10 +185,33 @@ func (bot *TipBot) acceptInlineCashuHandler(ctx intercept.Context) (intercept.Co to = LoadUser(ctx) // reload } - // Redeem the token - token, err := cashu.Deserialize(inlineCashu.Token) + // Mint the token now, from the sender's wallet. Deferred to claim so inline + // query keystrokes never spend money — only an actual claim does. + amount := inlineCashu.Amount + quote, err := bot.CashuClient.MintQuote(amount, "sat") + if err != nil { + log.Errorf("[cashu claim] MintQuote failed: %s", err.Error()) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + if _, err = from.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: quote.Request}, bot.Client); err != nil { + log.Errorf("[cashu claim] sender %s could not pay mint invoice: %s", GetUserStr(from.Telegram), err.Error()) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "balanceTooLowMessage")) + return ctx, err + } + time.Sleep(2 * time.Second) + tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, inlineCashu.Memo) + if err != nil { + // ponytail: sender already paid; the paid quote is a bearer credit. + // Log the id loudly so the sats are recoverable rather than silently lost. + log.Errorf("[cashu claim] MintTokens failed AFTER sender paid, recoverable quote=%s: %s", quote.Quote, err.Error()) + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + + token, err := cashu.Deserialize(tokenStr) if err != nil { - log.Errorf("[cashu claim] Invalid stored token: %s", err.Error()) + log.Errorf("[cashu claim] deserialize freshly minted token failed: %s", err.Error()) return ctx, err } @@ -295,25 +294,8 @@ func (bot *TipBot) cancelInlineCashuHandler(ctx intercept.Context) (intercept.Co return ctx, errors.Create(errors.NotActiveError) } - // Redeem the token back to the creator's wallet - token, err := cashu.Deserialize(inlineCashu.Token) - if err == nil && len(token.Token) > 0 && len(token.Token[0].Proofs) > 0 { - // Try to redeem back to creator - proofs := token.Token[0].Proofs - totalAmount := token.Amount() - - invoice, err := user.Wallet.Invoice(lnbits.InvoiceParams{ - Out: false, - Amount: totalAmount, - Memo: "Cashu token cancel refund", - }, bot.Client) - if err == nil { - meltQuote, err := bot.CashuClient.MeltQuote(invoice.PaymentRequest, "sat") - if err == nil { - bot.CashuClient.Melt(meltQuote.Quote, proofs) - } - } - } + // ponytail: nothing to refund — the token is only minted on claim, so an + // unclaimed cancel never spent the creator's sats. // Mark as inactive inlineCashu.Active = false From f4b99e2efab5893c81cb4ceb24621f60bf17a8f7 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 18:54:25 +0200 Subject: [PATCH 04/15] Add durable cashu token store, list, recover, and melt-fee fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 money-safety for the cashu feature — closes the paths where a user could lose sats and adds the requested wallet view. - Persist a CashuToken record per user (buntdb) BEFORE paying the mint invoice, so a paid-but-not-minted quote is never lost. State machine: minting -> unclaimed -> spent. - /cashu list: shows unclaimed tokens (copyable), greys out claimed ones and hides them after 24h; refreshes state via NUT-07 CheckState. - /cashu recover: re-drives paid-but-unminted quotes to completion. - Melt-fee fix: redeem/claim now invoice for (amount - mint fee) via a shared meltProofsToWallet helper, so redeeming a token no longer fails whenever the mint charges a non-zero melt fee. - Inline claim: if delivery to the claimer fails after the sender was debited, the minted token is saved to the sender's wallet instead of being lost. - Tests for amount-splitting and token serialize round-trip. Co-Authored-By: Claude Opus 4.8 --- internal/cashu/cashu_test.go | 49 +++++++ internal/telegram/cashu.go | 200 ++++++++++++++++++++++++----- internal/telegram/cashu_inline.go | 42 +++--- internal/telegram/cashu_storage.go | 110 ++++++++++++++++ translations/en.toml | 2 + 5 files changed, 346 insertions(+), 57 deletions(-) create mode 100644 internal/cashu/cashu_test.go create mode 100644 internal/telegram/cashu_storage.go diff --git a/internal/cashu/cashu_test.go b/internal/cashu/cashu_test.go new file mode 100644 index 00000000..6469e704 --- /dev/null +++ b/internal/cashu/cashu_test.go @@ -0,0 +1,49 @@ +package cashu + +import "testing" + +// SplitAmount must always sum back to the input and only emit powers of two, +// otherwise a mint would sign the wrong total and value is created or lost. +func TestSplitAmountSumsToInput(t *testing.T) { + for _, amt := range []int64{1, 2, 3, 13, 21, 100, 2100, 1_000_000} { + var sum int64 + for _, p := range SplitAmount(amt) { + if p&(p-1) != 0 { + t.Fatalf("SplitAmount(%d) produced non-power-of-two %d", amt, p) + } + sum += p + } + if sum != amt { + t.Fatalf("SplitAmount(%d) sums to %d", amt, sum) + } + } +} + +// A token must survive serialize -> deserialize unchanged, including its total. +func TestTokenRoundTrip(t *testing.T) { + orig := &TokenV3{ + Token: []TokenEntry{{ + Mint: "https://mint.example", + Proofs: []Proof{ + {Amount: 8, Id: "00abc", Secret: "deadbeef", C: "02aa"}, + {Amount: 2, Id: "00abc", Secret: "cafe", C: "02bb"}, + }, + }}, + Memo: "hi", + Unit: "sat", + } + s, err := orig.Serialize() + if err != nil { + t.Fatal(err) + } + got, err := Deserialize(s) + if err != nil { + t.Fatal(err) + } + if got.Amount() != 10 { + t.Fatalf("amount = %d, want 10", got.Amount()) + } + if got.Token[0].Mint != orig.Token[0].Mint || len(got.Token[0].Proofs) != 2 { + t.Fatalf("round trip mismatch: %+v", got) + } +} diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index e8a33cda..162662ea 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -44,6 +44,10 @@ func (bot *TipBot) cashuHandler(ctx intercept.Context) (intercept.Context, error return bot.cashuReceiveHandler(ctx) case "send": return bot.cashuSendHandler(ctx) + case "list", "pending", "tokens": + return bot.cashuListHandler(ctx) + case "recover": + return bot.cashuRecoverHandler(ctx) default: bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) return ctx, nil @@ -101,13 +105,24 @@ func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, e log.Infof("[cashu mint] Got quote %s for %d sat, invoice: %s...", quote.Quote, amount, truncate(quote.Request, 30)) + // Persist a durable record BEFORE paying so a paid-but-not-minted quote is + // never silently lost — /cashu recover can finish it. Starts as "minting". + record := newCashuToken(m.Sender.ID, m.Sender.Username, amount, memo, quote.Quote) + if err := bot.setCashuToken(record); err != nil { + log.Errorf("[cashu mint] could not persist token record: %s", err.Error()) + bot.tryEditMessage(statusMsg, Translate(ctx, "cashuMintErrorMessage")) + return ctx, err + } + // Step 2: Pay the mint's Lightning invoice from user's wallet _, err = user.Wallet.Pay(lnbits.PaymentParams{ Out: true, Bolt11: quote.Request, }, bot.Client) if err != nil { + // Nothing was paid — drop the record so it doesn't show as recoverable. log.Errorf("[cashu mint] Payment to mint failed: %s", err.Error()) + _ = record.Delete(record, bot.Bunt) bot.tryEditMessage(statusMsg, Translate(ctx, "cashuMintErrorMessage")) return ctx, err } @@ -120,11 +135,19 @@ func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, e // Step 4: Mint the tokens tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, memo) if err != nil { - log.Errorf("[cashu mint] MintTokens failed: %s", err.Error()) - bot.tryEditMessage(statusMsg, Translate(ctx, "cashuMintErrorMessage")) + // PAID but not minted. Keep the "minting" record so /cashu recover can + // finish it. ponytail: recovery re-mints from the paid quote; only a lost + // mint response (quote already ISSUED) is unrecoverable, which is rare. + log.Errorf("[cashu mint] MintTokens failed after pay, recoverable quote=%s: %s", quote.Quote, err.Error()) + bot.tryEditMessage(statusMsg, "🥜 Your payment went through, but the mint hasn't returned the token yet. It's saved — run /cashu recover to finish it.") return ctx, err } + // Token minted: mark the record unclaimed and store the token string. + record.Token = tokenStr + record.State = cashuStateUnclaimed + _ = bot.setCashuToken(record) + // Step 5: Generate QR code qr, err := qrcode.Encode(tokenStr, qrcode.Medium, 512) if err != nil { @@ -212,52 +235,165 @@ func (bot *TipBot) redeemCashuToken(ctx intercept.Context, tokenStr string, user return ctx, fmt.Errorf("token already spent") } - // Step 4: Create a Lightning invoice on the user's LNbits wallet - invoice, err := user.Wallet.Invoice(lnbits.InvoiceParams{ - Out: false, - Amount: totalAmount, - Memo: fmt.Sprintf("Cashu ecash redeem (%d sat)", totalAmount), - }, bot.Client) + // Step 4-6: melt the proofs so the mint pays an invoice on the user's wallet. + netAmount, err := bot.meltProofsToWallet(user, proofs, totalAmount) if err != nil { - log.Errorf("[cashu receive] Failed to create invoice: %s", err.Error()) - bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + log.Errorf("[cashu receive] melt failed: %s", err.Error()) + if strings.Contains(err.Error(), "exceed") { + bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuFeeTooHighMessage"), totalAmount, totalAmount)) + } else { + bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + } return ctx, err } - // Step 5: Request melt quote from mint (ask mint to pay our invoice) - meltQuote, err := bot.CashuClient.MeltQuote(invoice.PaymentRequest, "sat") + // If this was one of the user's own stored tokens, mark it spent. + bot.markCashuTokenSpent(user.Telegram.ID, tokenStr) + + bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuReceiveSuccessMessage"), netAmount)) + log.Infof("[cashu receive] %s redeemed %d sat cashu token (net %d)", GetUserStr(recipient), totalAmount, netAmount) + + return ctx, nil +} + +// meltProofsToWallet melts proofs at the mint so the mint pays a Lightning +// invoice on the given wallet, covering the mint's melt fee by invoicing for +// (totalAmount - fee). Returns the net sats credited. Shared by /cashu receive +// and the inline claim so the fee math lives in exactly one place. +func (bot *TipBot) meltProofsToWallet(user *lnbits.User, proofs []cashu.Proof, totalAmount int64) (int64, error) { + // quoteFor creates an invoice for amt on the user's wallet and asks the mint + // how much melting to it would cost. + quoteFor := func(amt int64) (*cashu.MeltQuoteResponse, error) { + inv, err := user.Wallet.Invoice(lnbits.InvoiceParams{ + Out: false, + Amount: amt, + Memo: fmt.Sprintf("Cashu redeem (%d sat)", amt), + }, bot.Client) + if err != nil { + return nil, fmt.Errorf("invoice: %w", err) + } + return bot.CashuClient.MeltQuote(inv.PaymentRequest, "sat") + } + + mq, err := quoteFor(totalAmount) if err != nil { - log.Errorf("[cashu receive] MeltQuote failed: %s", err.Error()) - bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) - return ctx, err + return 0, err + } + net := totalAmount + if mq.FeeReserve > 0 { + // Proofs only cover totalAmount, so the mint must pay less than that to + // leave room for its fee. ponytail: the first (full-amount) invoice is + // left unpaid and simply expires — cheaper than a separate fee-estimate. + net = totalAmount - mq.FeeReserve + if net <= 0 { + return 0, fmt.Errorf("melt fees (%d) exceed token amount (%d)", mq.FeeReserve, totalAmount) + } + mq, err = quoteFor(net) + if err != nil { + return 0, err + } + if mq.Amount+mq.FeeReserve > totalAmount { + return 0, fmt.Errorf("melt cost (%d) exceeds token amount (%d)", mq.Amount+mq.FeeReserve, totalAmount) + } } - // Check if the melt will consume more than the token provides (fees) - meltCost := meltQuote.Amount + meltQuote.FeeReserve - if meltCost > totalAmount { - log.Warnf("[cashu receive] Melt cost (%d) exceeds token amount (%d)", meltCost, totalAmount) - bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuFeeTooHighMessage"), totalAmount, meltCost)) - return ctx, fmt.Errorf("melt fees too high") + resp, err := bot.CashuClient.Melt(mq.Quote, proofs) + if err != nil { + return 0, err } + if resp.State != "PAID" { + return 0, fmt.Errorf("melt state: %s", resp.State) + } + return net, nil +} - // Step 6: Melt the proofs (mint pays the invoice) - meltResp, err := bot.CashuClient.Melt(meltQuote.Quote, proofs) +// cashuListHandler handles /cashu list — shows the user's cashu tokens, +// greying out / hiding claimed ones. +func (bot *TipBot) cashuListHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + tokens, err := bot.listCashuTokens(user.Telegram.ID) if err != nil { - log.Errorf("[cashu receive] Melt failed: %s", err.Error()) - bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) + log.Errorf("[cashu list] %s", err.Error()) return ctx, err } - if meltResp.State != "PAID" { - log.Warnf("[cashu receive] Melt not paid, state: %s", meltResp.State) - bot.trySendMessage(recipient, Translate(ctx, "cashuMintErrorMessage")) - return ctx, fmt.Errorf("melt state: %s", meltResp.State) + var sb strings.Builder + sb.WriteString("🥜 *Your cashu tokens*\n") + shown := 0 + for _, c := range tokens { + bot.refreshCashuTokenState(c) // may flip unclaimed -> spent + switch c.State { + case cashuStateMinting: + sb.WriteString(fmt.Sprintf("\n⏳ *%d sat* — paid but not minted. Run /cashu recover.", c.Amount)) + shown++ + case cashuStateUnclaimed: + sb.WriteString(fmt.Sprintf("\n🥜 *%d sat* unclaimed:\n`%s`\n", c.Amount, c.Token)) + shown++ + case cashuStateSpent: + // Greyed out, and hidden entirely once it's been claimed a while. + if time.Since(c.CreatedAt) < 24*time.Hour { + sb.WriteString(fmt.Sprintf("\n~%d sat — claimed~", c.Amount)) + shown++ + } + } } - // Success! - bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuReceiveSuccessMessage"), totalAmount)) - log.Infof("[cashu receive] %s redeemed %d sat cashu token", GetUserStr(recipient), totalAmount) + if shown == 0 { + bot.trySendMessage(m.Sender, "🥜 You have no active cashu tokens.") + return ctx, nil + } + bot.trySendMessage(m.Sender, sb.String()) + return ctx, nil +} +// cashuRecoverHandler handles /cashu recover — finishes any paid-but-not-minted +// quotes so the user's sats are never left stranded. +func (bot *TipBot) cashuRecoverHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + tokens, err := bot.listCashuTokens(user.Telegram.ID) + if err != nil { + return ctx, err + } + + recovered := 0 + for _, c := range tokens { + if c.State != cashuStateMinting { + continue + } + q, err := bot.CashuClient.CheckMintQuote(c.QuoteId) + if err != nil { + log.Errorf("[cashu recover] check quote %s: %s", c.QuoteId, err.Error()) + continue + } + if q.State == "ISSUED" { + // Tokens were already issued for this quote but never stored (lost mint + // response). They can't be re-derived; stop showing it as pending. + c.State = cashuStateSpent + _ = bot.setCashuToken(c) + continue + } + if q.State != "PAID" { + continue + } + tokenStr, err := bot.CashuClient.MintTokens(c.QuoteId, c.Amount, c.Memo) + if err != nil { + log.Errorf("[cashu recover] mint from quote %s failed: %s", c.QuoteId, err.Error()) + continue + } + c.Token = tokenStr + c.State = cashuStateUnclaimed + _ = bot.setCashuToken(c) + bot.trySendMessage(m.Sender, fmt.Sprintf("🥜 Recovered *%d sat*:\n`%s`", c.Amount, tokenStr)) + recovered++ + } + + if recovered == 0 { + bot.trySendMessage(m.Sender, "🥜 Nothing to recover.") + } else { + bot.trySendMessage(m.Sender, fmt.Sprintf("🥜 Recovered %d token(s) to your wallet.", recovered)) + } return ctx, nil } diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go index 6b982b84..4bdb2fcd 100644 --- a/internal/telegram/cashu_inline.go +++ b/internal/telegram/cashu_inline.go @@ -218,34 +218,26 @@ func (bot *TipBot) acceptInlineCashuHandler(ctx intercept.Context) (intercept.Co proofs := token.Token[0].Proofs totalAmount := token.Amount() - // Create invoice on recipient's wallet - invoice, err := to.Wallet.Invoice(lnbits.InvoiceParams{ - Out: false, - Amount: totalAmount, - Memo: fmt.Sprintf("Cashu ecash claim (%d sat)", totalAmount), - }, bot.Client) + // Deliver to the claimer by melting the proofs onto their wallet. + netAmount, err := bot.meltProofsToWallet(to, proofs, totalAmount) if err != nil { - log.Errorf("[cashu claim] Failed to create invoice: %s", err.Error()) - return ctx, err - } - - // Melt at the mint - meltQuote, err := bot.CashuClient.MeltQuote(invoice.PaymentRequest, "sat") - if err != nil { - log.Errorf("[cashu claim] MeltQuote failed: %s", err.Error()) - return ctx, err - } - - meltResp, err := bot.CashuClient.Melt(meltQuote.Quote, proofs) - if err != nil { - log.Errorf("[cashu claim] Melt failed: %s", err.Error()) + // Sender was already debited and a token minted, but delivery failed. + // Save the token to the sender's wallet so the sats are never lost. + rec := &CashuToken{ + Base: storage.New(storage.ID(cashuTokenKey(from.Telegram.ID, RandStringRunes(10)))), + TelegramID: from.Telegram.ID, + Username: from.Telegram.Username, + Amount: totalAmount, + Memo: inlineCashu.Memo, + Token: tokenStr, + State: cashuStateUnclaimed, + } + _ = bot.setCashuToken(rec) + log.Errorf("[cashu claim] melt to claimer failed, saved token to sender %s: %s", GetUserStr(from.Telegram), err.Error()) + bot.trySendMessage(from.Telegram, "🥜 Your inline cashu couldn't be delivered, so the token was saved to your wallet — see /cashu list.") return ctx, err } - - if meltResp.State != "PAID" { - log.Warnf("[cashu claim] Melt not paid, state: %s", meltResp.State) - return ctx, fmt.Errorf("melt state: %s", meltResp.State) - } + totalAmount = netAmount // Mark as claimed inlineCashu.Claimed = true diff --git a/internal/telegram/cashu_storage.go b/internal/telegram/cashu_storage.go new file mode 100644 index 00000000..cdb5f360 --- /dev/null +++ b/internal/telegram/cashu_storage.go @@ -0,0 +1,110 @@ +package telegram + +import ( + "encoding/json" + "fmt" + + "github.com/LightningTipBot/LightningTipBot/internal/cashu" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + + log "github.com/sirupsen/logrus" + "github.com/tidwall/buntdb" +) + +// Cashu token lifecycle states. +const ( + cashuStateMinting = "minting" // user paid the mint invoice; token not yet minted (recoverable via /cashu recover) + cashuStateUnclaimed = "unclaimed" // token minted and held in the wallet, not yet redeemed + cashuStateSpent = "spent" // token has been redeemed/claimed +) + +// CashuToken is a durable per-user record of a cashu token the user minted. +// It exists so sats are never stranded in a chat message the user might lose, +// and so a paid-but-not-minted quote can be recovered instead of lost. +type CashuToken struct { + *storage.Base + TelegramID int64 `json:"cashu_telegram_id"` + Username string `json:"cashu_username"` + Amount int64 `json:"cashu_amount"` + Memo string `json:"cashu_memo"` + Token string `json:"cashu_token"` // empty while state == minting + QuoteId string `json:"cashu_quote_id"` // paid mint quote, used for recovery + State string `json:"cashu_state"` +} + +func cashuTokenKey(telegramID int64, id string) string { + return fmt.Sprintf("cashutoken:%d:%s", telegramID, id) +} + +// newCashuToken creates a minting-state record. Persist it BEFORE paying the +// mint invoice so a crash/failure between pay and mint is always recoverable. +func newCashuToken(telegramID int64, username string, amount int64, memo, quoteId string) *CashuToken { + return &CashuToken{ + Base: storage.New(storage.ID(cashuTokenKey(telegramID, RandStringRunes(10)))), + TelegramID: telegramID, + Username: username, + Amount: amount, + Memo: memo, + QuoteId: quoteId, + State: cashuStateMinting, + } +} + +func (bot *TipBot) setCashuToken(c *CashuToken) error { + return c.Set(c, bot.Bunt) +} + +// listCashuTokens returns all cashu token records for a user. +func (bot *TipBot) listCashuTokens(telegramID int64) ([]*CashuToken, error) { + var tokens []*CashuToken + prefix := fmt.Sprintf("cashutoken:%d:", telegramID) + err := bot.Bunt.View(func(tx *buntdb.Tx) error { + return tx.AscendKeys(prefix+"*", func(key, value string) bool { + var c CashuToken + if err := json.Unmarshal([]byte(value), &c); err != nil { + log.Errorf("[cashu list] corrupt record %s: %s", key, err.Error()) + return true // skip, keep iterating + } + tokens = append(tokens, &c) + return true + }) + }) + return tokens, err +} + +// markCashuTokenSpent flips the caller's stored token matching tokenStr to spent. +// Only matches the user's own minted tokens; external tokens are simply ignored. +func (bot *TipBot) markCashuTokenSpent(telegramID int64, tokenStr string) { + tokens, err := bot.listCashuTokens(telegramID) + if err != nil { + return + } + for _, c := range tokens { + if c.Token != "" && c.Token == tokenStr && c.State != cashuStateSpent { + c.State = cashuStateSpent + _ = bot.setCashuToken(c) + return + } + } +} + +// refreshCashuTokenState checks an unclaimed token against the mint (NUT-07) and +// marks it spent if its proofs were redeemed elsewhere. Best-effort: if the mint +// doesn't support state checks, the record is left unchanged. +func (bot *TipBot) refreshCashuTokenState(c *CashuToken) { + if c.State != cashuStateUnclaimed || c.Token == "" { + return + } + token, err := cashu.Deserialize(c.Token) + if err != nil || len(token.Token) == 0 { + return + } + unspent, err := bot.CashuClient.AllProofsUnspent(token.Token[0].Proofs) + if err != nil { + return + } + if !unspent { + c.State = cashuStateSpent + _ = bot.setCashuToken(c) + } +} diff --git a/translations/en.toml b/translations/en.toml index fdafb1c9..35d4dd9e 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -418,6 +418,8 @@ cashuHelpText = """🥜 *Cashu ecash tokens* `/cashu mint [memo]` — Create an ecash token `/cashu receive ` — Redeem a cashu token to your wallet `/cashu send [memo]` — Create a cashu token to share +`/cashu list` — Show your unclaimed cashu tokens +`/cashu recover` — Finish any paid-but-unminted tokens Cashu tokens are digital bearer tokens backed by Bitcoin Lightning. You can copy-paste them, share them offline, or drop them in group chats.""" From 522fa9c949634ae7fd8fbfc4e1e5ba9281709aca Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 18:57:06 +0200 Subject: [PATCH 05/15] Cap pending cashu tokens per user A user could spam /cashu mint (or inline offers) to grow buntdb records and hammer the mint and LNbits without bound. Cap minting+unclaimed records at 10 per user, checked before any mint traffic in both the /cashu mint path and the inline query path. Capped users are pointed at /cashu list and /cashu recover to clear their backlog. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/cashu.go | 6 ++++++ internal/telegram/cashu_inline.go | 7 +++++++ internal/telegram/cashu_storage.go | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index 162662ea..2b7d45c1 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -92,6 +92,12 @@ func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, e return ctx, errors.New(errors.BalanceToLowError, fmt.Errorf("balance too low for cashu mint")) } + // DoS guard: cap pending (minting/unclaimed) tokens per user. + if pending, err := bot.countPendingCashuTokens(m.Sender.ID); err == nil && pending >= maxPendingCashuTokens { + bot.trySendMessage(m.Sender, fmt.Sprintf("🥜 You have %d pending cashu tokens (max %d). Redeem some with /cashu list or finish them with /cashu recover first.", pending, maxPendingCashuTokens)) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + // Notify user we're working on it statusMsg := bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "cashuMintMessage"), amount)) diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go index 4bdb2fcd..094effc4 100644 --- a/internal/telegram/cashu_inline.go +++ b/internal/telegram/cashu_inline.go @@ -95,6 +95,13 @@ func (bot *TipBot) handleInlineCashuQuery(ctx intercept.Context) (intercept.Cont return ctx, nil } + // DoS guard: same pending cap as /cashu mint, so a capped user can't keep + // creating claimable offers (each failed delivery would bank another token). + if pending, err := bot.countPendingCashuTokens(query.Sender.ID); err == nil && pending >= maxPendingCashuTokens { + bot.inlineQueryReplyWithError(ctx, "Too many pending cashu tokens", fmt.Sprintf("You have %d pending tokens (max %d). Redeem or recover them first.", pending, maxPendingCashuTokens)) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + // ponytail: do NOT mint here — inline queries fire on every keystroke, so // minting here spends money per character typed. The token is minted from // the sender's wallet on claim instead (acceptInlineCashuHandler). diff --git a/internal/telegram/cashu_storage.go b/internal/telegram/cashu_storage.go index cdb5f360..0c96be68 100644 --- a/internal/telegram/cashu_storage.go +++ b/internal/telegram/cashu_storage.go @@ -16,6 +16,11 @@ const ( cashuStateMinting = "minting" // user paid the mint invoice; token not yet minted (recoverable via /cashu recover) cashuStateUnclaimed = "unclaimed" // token minted and held in the wallet, not yet redeemed cashuStateSpent = "spent" // token has been redeemed/claimed + + // maxPendingCashuTokens caps minting+unclaimed records per user so a user + // can't grow the DB and hammer the mint without bound. + // ponytail: hard-coded; make configurable if a real user ever hits it. + maxPendingCashuTokens = 10 ) // CashuToken is a durable per-user record of a cashu token the user minted. @@ -72,6 +77,21 @@ func (bot *TipBot) listCashuTokens(telegramID int64) ([]*CashuToken, error) { return tokens, err } +// countPendingCashuTokens returns how many minting/unclaimed records a user has. +func (bot *TipBot) countPendingCashuTokens(telegramID int64) (int, error) { + tokens, err := bot.listCashuTokens(telegramID) + if err != nil { + return 0, err + } + n := 0 + for _, c := range tokens { + if c.State == cashuStateMinting || c.State == cashuStateUnclaimed { + n++ + } + } + return n, nil +} + // markCashuTokenSpent flips the caller's stored token matching tokenStr to spent. // Only matches the user's own minted tokens; external tokens are simply ignored. func (bot *TipBot) markCashuTokenSpent(telegramID int64, tokenStr string) { From 65f20c0410c4602b16056bb843b24a25855f4edb Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 19:01:38 +0200 Subject: [PATCH 06/15] Phase 2 cashu hardening: timeouts, RNG checks, memo escape, URL match - Mint HTTP calls get their own req instance with a 30s timeout; a hung mint previously held the user's lock and goroutine forever. - GenerateSecret no longer ignores the RNG error (a predictable secret is spendable by anyone), and BlindMessage rejects a zero blinding factor, which would have sent the secret's point unblinded. - Inline memo is user-controlled text rendered as Markdown in public chats; escape it with str.MarkdownEscape. - Mint URL comparison ignores trailing slashes and case so same-mint tokens are not rejected over formatting. Co-Authored-By: Claude Opus 4.8 --- internal/cashu/client.go | 29 ++++++++++++++++++++--------- internal/cashu/crypto.go | 28 +++++++++++++++++++--------- internal/telegram/cashu.go | 13 +++++++++++-- internal/telegram/cashu_inline.go | 4 +++- 4 files changed, 53 insertions(+), 21 deletions(-) diff --git a/internal/cashu/client.go b/internal/cashu/client.go index 15df3966..719d9f8b 100644 --- a/internal/cashu/client.go +++ b/internal/cashu/client.go @@ -4,6 +4,7 @@ import ( "encoding/hex" "fmt" "strconv" + "time" "github.com/imroc/req" log "github.com/sirupsen/logrus" @@ -13,12 +14,18 @@ import ( type Client struct { mintURL string header req.Header + http *req.Req } // NewClient creates a new Cashu mint client. func NewClient(mintURL string) *Client { + // Own req instance with a timeout so a hung mint can't wedge a user's + // lock and goroutine forever (handlers hold a per-user lock while calling). + r := req.New() + r.SetTimeout(30 * time.Second) return &Client{ mintURL: mintURL, + http: r, header: req.Header{ "Content-Type": "application/json", "Accept": "application/json", @@ -33,7 +40,7 @@ func (c *Client) MintURL() string { // GetInfo fetches mint information (NUT-06). func (c *Client) GetInfo() (*MintInfo, error) { - resp, err := req.Get(c.mintURL+"/v1/info", c.header) + resp, err := c.http.Get(c.mintURL+"/v1/info", c.header) if err != nil { return nil, fmt.Errorf("failed to get mint info: %w", err) } @@ -47,7 +54,7 @@ func (c *Client) GetInfo() (*MintInfo, error) { // GetKeysets fetches active keysets from the mint (NUT-01). func (c *Client) GetKeysets() (*KeysResponse, error) { - resp, err := req.Get(c.mintURL+"/v1/keys", c.header) + resp, err := c.http.Get(c.mintURL+"/v1/keys", c.header) if err != nil { return nil, fmt.Errorf("failed to get keysets: %w", err) } @@ -66,7 +73,7 @@ func (c *Client) MintQuote(amount int64, unit string) (*MintQuoteResponse, error Amount: amount, Unit: unit, } - resp, err := req.Post(c.mintURL+"/v1/mint/quote/bolt11", c.header, req.BodyJSON(&body)) + resp, err := c.http.Post(c.mintURL+"/v1/mint/quote/bolt11", c.header, req.BodyJSON(&body)) if err != nil { return nil, fmt.Errorf("failed to request mint quote: %w", err) } @@ -80,7 +87,7 @@ func (c *Client) MintQuote(amount int64, unit string) (*MintQuoteResponse, error // CheckMintQuote checks the status of a mint quote (NUT-04). func (c *Client) CheckMintQuote(quoteId string) (*MintQuoteResponse, error) { - resp, err := req.Get(c.mintURL+"/v1/mint/quote/bolt11/"+quoteId, c.header) + resp, err := c.http.Get(c.mintURL+"/v1/mint/quote/bolt11/"+quoteId, c.header) if err != nil { return nil, fmt.Errorf("failed to check mint quote: %w", err) } @@ -98,7 +105,7 @@ func (c *Client) Mint(quoteId string, outputs []BlindedMessage) (*MintResponse, Quote: quoteId, Outputs: outputs, } - resp, err := req.Post(c.mintURL+"/v1/mint/bolt11", c.header, req.BodyJSON(&body)) + resp, err := c.http.Post(c.mintURL+"/v1/mint/bolt11", c.header, req.BodyJSON(&body)) if err != nil { return nil, fmt.Errorf("failed to mint tokens: %w", err) } @@ -116,7 +123,7 @@ func (c *Client) MeltQuote(bolt11 string, unit string) (*MeltQuoteResponse, erro Request: bolt11, Unit: unit, } - resp, err := req.Post(c.mintURL+"/v1/melt/quote/bolt11", c.header, req.BodyJSON(&body)) + resp, err := c.http.Post(c.mintURL+"/v1/melt/quote/bolt11", c.header, req.BodyJSON(&body)) if err != nil { return nil, fmt.Errorf("failed to request melt quote: %w", err) } @@ -134,7 +141,7 @@ func (c *Client) Melt(quoteId string, inputs []Proof) (*MeltResponse, error) { Quote: quoteId, Inputs: inputs, } - resp, err := req.Post(c.mintURL+"/v1/melt/bolt11", c.header, req.BodyJSON(&body)) + resp, err := c.http.Post(c.mintURL+"/v1/melt/bolt11", c.header, req.BodyJSON(&body)) if err != nil { return nil, fmt.Errorf("failed to melt tokens: %w", err) } @@ -157,7 +164,7 @@ func (c *Client) CheckState(proofs []Proof) (*CheckStateResponse, error) { ys[i] = y } body := CheckStateRequest{Ys: ys} - resp, err := req.Post(c.mintURL+"/v1/checkstate", c.header, req.BodyJSON(&body)) + resp, err := c.http.Post(c.mintURL+"/v1/checkstate", c.header, req.BodyJSON(&body)) if err != nil { return nil, fmt.Errorf("failed to check state: %w", err) } @@ -208,7 +215,11 @@ func (c *Client) MintTokens(quoteId string, amount int64, memo string) (string, outputs := make([]BlindedMessage, len(amounts)) blindingResults := make([]*BlindingResult, len(amounts)) for i, amt := range amounts { - br, err := BlindMessage(GenerateSecret()) + secret, err := GenerateSecret() + if err != nil { + return "", err + } + br, err := BlindMessage(secret) if err != nil { return "", fmt.Errorf("failed to blind message: %w", err) } diff --git a/internal/cashu/crypto.go b/internal/cashu/crypto.go index c2fea0f6..70692019 100644 --- a/internal/cashu/crypto.go +++ b/internal/cashu/crypto.go @@ -51,13 +51,19 @@ func BlindMessage(secret string) (*BlindingResult, error) { return nil, fmt.Errorf("hash_to_curve failed: %w", err) } - // Generate random blinding factor r - rBytes := make([]byte, 32) - _, err = rand.Read(rBytes) - if err != nil { - return nil, fmt.Errorf("failed to generate random blinding factor: %w", err) + // Generate random blinding factor r. A zero r would send Y (and thus the + // secret's point) to the mint unblinded, so reject it outright. + var r *btcec.PrivateKey + for { + rBytes := make([]byte, 32) + if _, err := rand.Read(rBytes); err != nil { + return nil, fmt.Errorf("failed to generate random blinding factor: %w", err) + } + r, _ = btcec.PrivKeyFromBytes(rBytes) + if !r.Key.IsZero() { + break + } } - r, _ := btcec.PrivKeyFromBytes(rBytes) // r*G (the public key corresponding to r) rG := r.PubKey() @@ -132,10 +138,14 @@ func CalculateY(secret string) (string, error) { } // GenerateSecret creates a random 32-byte hex-encoded secret for a proof. -func GenerateSecret() string { +// The RNG error must not be ignored: a predictable secret is spendable by +// anyone who can guess it. +func GenerateSecret() (string, error) { b := make([]byte, 32) - rand.Read(b) - return hex.EncodeToString(b) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate proof secret: %w", err) + } + return hex.EncodeToString(b), nil } // SplitAmount decomposes a satoshi amount into powers of 2. diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index 2b7d45c1..17dfa710 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -220,9 +220,11 @@ func (bot *TipBot) redeemCashuToken(ctx intercept.Context, tokenStr string, user return ctx, fmt.Errorf("token has no proofs") } - // Step 2: Validate the token's mint URL matches our configured mint + // Step 2: Validate the token's mint URL matches our configured mint. + // Normalize: trailing slashes and scheme/host case must not cause a + // same-mint token to be rejected. tokenMintURL := token.Token[0].Mint - if tokenMintURL != bot.CashuClient.MintURL() { + if !sameMintURL(tokenMintURL, bot.CashuClient.MintURL()) { log.Warnf("[cashu receive] Token from unknown mint: %s (expected: %s)", tokenMintURL, bot.CashuClient.MintURL()) bot.trySendMessage(recipient, fmt.Sprintf(Translate(ctx, "cashuMintMismatchMessage"), bot.CashuClient.MintURL())) return ctx, fmt.Errorf("mint mismatch") @@ -403,6 +405,13 @@ func (bot *TipBot) cashuRecoverHandler(ctx intercept.Context) (intercept.Context return ctx, nil } +// sameMintURL compares two mint URLs ignoring trailing slashes and case. +// ponytail: string-level compare is enough for one configured mint; full URL +// parsing only if multi-mint support ever lands. +func sameMintURL(a, b string) bool { + return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/")) +} + // truncate shortens a string for logging. func truncate(s string, maxLen int) string { if len(s) <= maxLen { diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go index 094effc4..587b6916 100644 --- a/internal/telegram/cashu_inline.go +++ b/internal/telegram/cashu_inline.go @@ -12,6 +12,7 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/lnbits" "github.com/LightningTipBot/LightningTipBot/internal/runtime/mutex" "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/str" "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" "github.com/eko/gocache/store" @@ -110,7 +111,8 @@ func (bot *TipBot) handleInlineCashuQuery(ctx intercept.Context) (intercept.Cont id := fmt.Sprintf("cashu:%s:%d", RandStringRunes(10), amount) inlineMessage := fmt.Sprintf(Translate(ctx, "cashuSendMessage"), GetUserStrMd(query.Sender), amount) if len(memo) > 0 { - inlineMessage += fmt.Sprintf("\n_Memo: %s_", memo) + // User-controlled text rendered as Markdown in a public chat — escape it. + inlineMessage += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(memo)) } inlineCashu := &InlineCashu{ From d9c0706089f88b2a7444602cf68530aa213c2b3d Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 19:11:00 +0200 Subject: [PATCH 07/15] Require Confirm/Cancel before cashu minting /cashu mint (and send) no longer moves money immediately. The handler now validates the request, stores a pending CashuMintRequest, and shows the interpreted request ("Mint a N sat cashu token? This pays N sat from your wallet to the mint ") with Confirm/Cancel buttons, mirroring the /pay confirmation flow. The quote/pay/mint execution runs only on Confirm, guarded by owner check, single-use inactivation, and a re-check of the pending-token cap. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/cashu.go | 112 +++++++++++++++++++++++++++++++++-- internal/telegram/handler.go | 32 ++++++++++ 2 files changed, 140 insertions(+), 4 deletions(-) diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index 17dfa710..df35cf99 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -10,6 +10,8 @@ import ( "github.com/LightningTipBot/LightningTipBot/internal/cashu" "github.com/LightningTipBot/LightningTipBot/internal/errors" "github.com/LightningTipBot/LightningTipBot/internal/lnbits" + "github.com/LightningTipBot/LightningTipBot/internal/storage" + "github.com/LightningTipBot/LightningTipBot/internal/str" "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" "github.com/skip2/go-qrcode" @@ -54,8 +56,31 @@ func (bot *TipBot) cashuHandler(ctx intercept.Context) (intercept.Context, error } } -// cashuMintHandler handles /cashu mint [memo] -// Creates ecash tokens by paying the external mint's Lightning invoice. +var ( + cashuMintConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + btnConfirmCashuMint = cashuMintConfirmationMenu.Data("✅ Mint", "confirm_cashu_mint") + btnCancelCashuMint = cashuMintConfirmationMenu.Data("🚫 Cancel", "cancel_cashu_mint") +) + +// CashuMintRequest is a pending /cashu mint awaiting user confirmation. +type CashuMintRequest struct { + *storage.Base + TelegramID int64 `json:"cashu_mint_req_telegram_id"` + Amount int64 `json:"cashu_mint_req_amount"` + Memo string `json:"cashu_mint_req_memo"` +} + +func (bot *TipBot) makeCashuMintConfirmKeyboard(id string) *tb.ReplyMarkup { + menu := &tb.ReplyMarkup{ResizeKeyboard: true} + confirmBtn := menu.Data("✅ Mint", "confirm_cashu_mint", id) + cancelBtn := menu.Data("🚫 Cancel", "cancel_cashu_mint", id) + menu.Inline(menu.Row(confirmBtn, cancelBtn)) + return menu +} + +// cashuMintHandler handles /cashu mint [memo]. +// It validates the request and asks for confirmation; money only moves after +// the user taps Confirm (confirmCashuMintHandler). func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, error) { m := ctx.Message() user := LoadUser(ctx) @@ -98,8 +123,87 @@ func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, e return ctx, errors.Create(errors.InvalidSyntaxError) } - // Notify user we're working on it - statusMsg := bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "cashuMintMessage"), amount)) + // Store the pending request and ask for confirmation. + req := &CashuMintRequest{ + Base: storage.New(storage.ID(fmt.Sprintf("cashu-mint-req:%d:%s", m.Sender.ID, RandStringRunes(8)))), + TelegramID: m.Sender.ID, + Amount: amount, + Memo: memo, + } + if err := req.Set(req, bot.Bunt); err != nil { + log.Errorf("[cashu mint] could not persist mint request: %s", err.Error()) + return ctx, err + } + + confirmText := fmt.Sprintf("🥜 Mint a *%d sat* cashu token?\nThis pays %d sat from your wallet to the mint `%s`.", amount, amount, str.MarkdownEscape(bot.CashuClient.MintURL())) + if len(memo) > 0 { + confirmText += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(memo)) + } + bot.trySendMessage(m.Sender, confirmText, bot.makeCashuMintConfirmKeyboard(req.ID)) + return ctx, nil +} + +// confirmCashuMintHandler runs when the user taps Confirm on a pending mint. +func (bot *TipBot) confirmCashuMintHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + + req := &CashuMintRequest{Base: storage.New(storage.ID(c.Data))} + fn, err := req.Get(req, bot.Bunt) + if err != nil { + log.Errorf("[cashu confirm] request %s not found: %s", c.Data, err.Error()) + bot.tryEditMessage(c.Message, "🥜 This mint request expired. Send the command again.", &tb.ReplyMarkup{}) + return ctx, err + } + req = fn.(*CashuMintRequest) + + // Only the requester can confirm, and only once. + if req.TelegramID != user.Telegram.ID { + return ctx, errors.Create(errors.UnknownError) + } + if !req.Active { + bot.tryEditMessage(c.Message, "🥜 This mint request was already handled.", &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.NotActiveError) + } + _ = req.Inactivate(req, bot.Bunt) + + // Re-check the cap: requests could be confirmed out of order. + if pending, err := bot.countPendingCashuTokens(user.Telegram.ID); err == nil && pending >= maxPendingCashuTokens { + bot.tryEditMessage(c.Message, fmt.Sprintf("🥜 You have %d pending cashu tokens (max %d).", pending, maxPendingCashuTokens), &tb.ReplyMarkup{}) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + + return bot.executeCashuMint(ctx, user, c.Sender, c.Message, req.Amount, req.Memo) +} + +// cancelCashuMintHandler runs when the user taps Cancel on a pending mint. +func (bot *TipBot) cancelCashuMintHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + + req := &CashuMintRequest{Base: storage.New(storage.ID(c.Data))} + fn, err := req.Get(req, bot.Bunt) + if err != nil { + bot.tryEditMessage(c.Message, Translate(ctx, "cashuSendCancelledMessage"), &tb.ReplyMarkup{}) + return ctx, err + } + req = fn.(*CashuMintRequest) + if req.TelegramID != user.Telegram.ID { + return ctx, errors.Create(errors.UnknownError) + } + _ = req.Inactivate(req, bot.Bunt) + bot.tryEditMessage(c.Message, Translate(ctx, "cashuSendCancelledMessage"), &tb.ReplyMarkup{}) + return ctx, nil +} + +// executeCashuMint performs the actual quote -> pay -> mint flow after the +// user confirmed. statusMsg (the confirmation message) is edited in place. +func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, sender *tb.User, statusMsg *tb.Message, amount int64, memo string) (intercept.Context, error) { + m := &tb.Message{Sender: sender} // for the shared success-send path below + bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "cashuMintMessage"), amount)) // Step 1: Request mint quote from the external Cashu mint quote, err := bot.CashuClient.MintQuote(amount, "sat") diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index ccd8ab41..c2ca1277 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -689,6 +689,38 @@ func (bot TipBot) getHandler() []InterceptionWrapper { Endpoints: []interface{}{tb.OnInlineResult}, Handler: bot.anyChosenInlineHandler, }, + { + Endpoints: []interface{}{&btnConfirmCashuMint}, + Handler: bot.confirmCashuMintHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{&btnCancelCashuMint}, + Handler: bot.cancelCashuMintHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, { Endpoints: []interface{}{&btnPay}, Handler: bot.confirmPayHandler, From 9acb3315ab97d1109bd63a2ddee4b6871cfcda48 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 19:19:09 +0200 Subject: [PATCH 08/15] Render inline cashu message as Markdown The inline result was sent without a parse mode, so *bold* markers showed as literal asterisks in chat. Set ModeMarkdown on the result, matching how the message text is built. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/cashu_inline.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go index 587b6916..a51b5457 100644 --- a/internal/telegram/cashu_inline.go +++ b/internal/telegram/cashu_inline.go @@ -134,6 +134,7 @@ func (bot *TipBot) handleInlineCashuQuery(ctx intercept.Context) (intercept.Cont Description: TranslateUser(ctx, "inlineResultCashuDescription"), ThumbURL: queryImage, } + result.SetParseMode(tb.ModeMarkdown) result.ReplyMarkup = &tb.ReplyMarkup{InlineKeyboard: bot.makeCashuKeyboard(ctx, inlineCashu.ID).InlineKeyboard} results[0] = result results[0].SetResultID(inlineCashu.ID) From 55d2815b7849676b0cb21277c6315b4963564062 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 19:30:33 +0200 Subject: [PATCH 09/15] Phase 3: DLEQ verification (NUT-12) and Cashu V4 token support - Verify the mint's DLEQ proof on every blind signature during minting: R1 = s*G - e*A, R2 = s*B_ - e*C_, e == hash_e(R1,R2,A,C_). An invalid proof aborts the mint (a mint signing with an unpublished key could later disown the tokens); a missing proof only warns, since not all mints ship NUT-12. - Accept cashuB (V4, CBOR) tokens on receive, mapped onto the internal V3 representation. Emitting stays V3 for wallet compatibility. Adds fxamacker/cbor. - Tests: mint-side DLEQ simulation (valid accepted, tampered rejected), V4 CBOR decode. Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 + go.sum | 4 ++ internal/cashu/cashu_test.go | 94 +++++++++++++++++++++++++++++++++++- internal/cashu/client.go | 34 +++++++++++++ internal/cashu/crypto.go | 68 ++++++++++++++++++++++++-- internal/cashu/token.go | 76 ++++++++++++++++++++++++++++- internal/cashu/types.go | 14 ++++-- 7 files changed, 283 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index e49c1b42..84ef2907 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/decred/dcrd/lru v1.0.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-errors/errors v1.0.1 // indirect github.com/go-redis/redis/v8 v8.8.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect @@ -89,6 +90,7 @@ require ( github.com/tidwall/rtred v0.1.2 // indirect github.com/tidwall/tinyqueue v0.1.1 // indirect github.com/valyala/fastjson v1.6.3 // indirect + github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v0.20.0 // indirect go.opentelemetry.io/otel/metric v0.20.0 // indirect go.opentelemetry.io/otel/trace v0.20.0 // indirect diff --git a/go.sum b/go.sum index 01a11b45..4d39212b 100644 --- a/go.sum +++ b/go.sum @@ -268,6 +268,8 @@ github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -908,6 +910,8 @@ github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtX github.com/valyala/fastjson v1.6.3 h1:tAKFnnwmeMGPbwJ7IwxcTPCNr3uIzoIj3/Fh90ra4xc= github.com/valyala/fastjson v1.6.3/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= diff --git a/internal/cashu/cashu_test.go b/internal/cashu/cashu_test.go index 6469e704..f8cb5476 100644 --- a/internal/cashu/cashu_test.go +++ b/internal/cashu/cashu_test.go @@ -1,6 +1,15 @@ package cashu -import "testing" +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/fxamacker/cbor/v2" +) // SplitAmount must always sum back to the input and only emit powers of two, // otherwise a mint would sign the wrong total and value is created or lost. @@ -19,6 +28,89 @@ func TestSplitAmountSumsToInput(t *testing.T) { } } +// Simulate the mint side of NUT-12 and check our verifier accepts a valid +// proof and rejects a tampered one. +func TestVerifyDLEQ(t *testing.T) { + newKey := func() *btcec.PrivateKey { + b := make([]byte, 32) + rand.Read(b) + k, _ := btcec.PrivKeyFromBytes(b) + return k + } + k := newKey() // mint private key + A := k.PubKey() // published mint key + br, err := BlindMessage("test-secret") + if err != nil { + t.Fatal(err) + } + + // Mint: C_ = k*B_ + mulPub := func(s *secp256k1.ModNScalar, P *btcec.PublicKey) *btcec.PublicKey { + var pJ, outJ secp256k1.JacobianPoint + P.AsJacobian(&pJ) + secp256k1.ScalarMultNonConst(s, &pJ, &outJ) + outJ.ToAffine() + return btcec.NewPublicKey(&outJ.X, &outJ.Y) + } + C_ := mulPub(&k.Key, br.B_) + + // Mint DLEQ: nonce w; R1 = w*G, R2 = w*B_; e = hash_e(R1,R2,A,C_); s = w + e*k + w := newKey() + var gJ secp256k1.JacobianPoint + secp256k1.ScalarBaseMultNonConst(&w.Key, &gJ) + gJ.ToAffine() + R1 := btcec.NewPublicKey(&gJ.X, &gJ.Y) + R2 := mulPub(&w.Key, br.B_) + eBytes := hashE(R1, R2, A, C_) + var e secp256k1.ModNScalar + e.SetByteSlice(eBytes) + s := new(secp256k1.ModNScalar).Mul2(&e, &k.Key).Add(&w.Key) + sBytes := s.Bytes() + + ok, err := VerifyDLEQ(hex.EncodeToString(eBytes), hex.EncodeToString(sBytes[:]), A, br.B_, C_) + if err != nil || !ok { + t.Fatalf("valid DLEQ rejected: ok=%v err=%v", ok, err) + } + + // Tamper: wrong mint key must fail. + ok, _ = VerifyDLEQ(hex.EncodeToString(eBytes), hex.EncodeToString(sBytes[:]), newKey().PubKey(), br.B_, C_) + if ok { + t.Fatal("DLEQ accepted with wrong mint key") + } +} + +// A V4 (cashuB, CBOR) token must decode into the internal representation. +func TestDeserializeV4(t *testing.T) { + v4 := tokenV4{ + Mint: "https://mint.example", + Unit: "sat", + Memo: "hi", + Token: []tokenV4Entry{{ + Id: []byte{0x00, 0xab}, + Proofs: []tokenV4Proof{ + {Amount: 8, Secret: "s1", C: []byte{0x02, 0x01}}, + {Amount: 2, Secret: "s2", C: []byte{0x02, 0x02}}, + }, + }}, + } + raw, err := cbor.Marshal(v4) + if err != nil { + t.Fatal(err) + } + tokenStr := TokenPrefixV4 + base64.RawURLEncoding.EncodeToString(raw) + + got, err := Deserialize(tokenStr) + if err != nil { + t.Fatal(err) + } + if got.Amount() != 10 || got.Token[0].Mint != "https://mint.example" { + t.Fatalf("V4 decode mismatch: %+v", got) + } + if got.Token[0].Proofs[0].Id != "00ab" { + t.Fatalf("keyset id not hex-mapped: %s", got.Token[0].Proofs[0].Id) + } +} + // A token must survive serialize -> deserialize unchanged, including its total. func TestTokenRoundTrip(t *testing.T) { orig := &TokenV3{ diff --git a/internal/cashu/client.go b/internal/cashu/client.go index 719d9f8b..c679c9e8 100644 --- a/internal/cashu/client.go +++ b/internal/cashu/client.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/imroc/req" log "github.com/sirupsen/logrus" ) @@ -243,6 +244,7 @@ func (c *Client) MintTokens(quoteId string, amount int64, memo string) (string, // Unblind signatures to get valid proofs proofs := make([]Proof, len(mintResp.Signatures)) + dleqMissing := 0 for i, sig := range mintResp.Signatures { // Look up the mint's public key for this denomination amtStr := strconv.FormatInt(sig.Amount, 10) @@ -251,6 +253,26 @@ func (c *Client) MintTokens(quoteId string, amount int64, memo string) (string, return "", fmt.Errorf("no mint key found for amount %d", sig.Amount) } + // NUT-12: verify the mint signed with its published key. Invalid proof = + // hard fail (the mint could later disown these tokens). Missing proof is + // tolerated with a warning since not every mint ships NUT-12. + if sig.DLEQ != nil { + A, err := parsePubKeyHex(mintPubKey) + if err != nil { + return "", fmt.Errorf("invalid mint key for amount %d: %w", sig.Amount, err) + } + C_, err := parsePubKeyHex(sig.C_) + if err != nil { + return "", fmt.Errorf("invalid C_ from mint: %w", err) + } + valid, err := VerifyDLEQ(sig.DLEQ.E, sig.DLEQ.S, A, blindingResults[i].B_, C_) + if err != nil || !valid { + return "", fmt.Errorf("mint returned invalid DLEQ proof for amount %d (err=%v)", sig.Amount, err) + } + } else { + dleqMissing++ + } + // Unblind: C = C_ - r*K C, err := UnblindSignature(sig.C_, blindingResults[i].R, mintPubKey) if err != nil { @@ -282,10 +304,22 @@ func (c *Client) MintTokens(quoteId string, amount int64, memo string) (string, return "", fmt.Errorf("failed to serialize token: %w", err) } + if dleqMissing > 0 { + log.Warnf("[cashu] mint did not include DLEQ proofs for %d/%d signatures (NUT-12 unsupported?)", dleqMissing, len(mintResp.Signatures)) + } log.Infof("[cashu] Minted %d sat token with %d proofs", amount, len(proofs)) return tokenStr, nil } +// parsePubKeyHex parses a compressed secp256k1 point from hex. +func parsePubKeyHex(h string) (*btcec.PublicKey, error) { + b, err := hex.DecodeString(h) + if err != nil { + return nil, err + } + return btcec.ParsePubKey(b) +} + // AllProofsUnspent checks if all proofs in a token are unspent. func (c *Client) AllProofsUnspent(proofs []Proof) (bool, error) { stateResp, err := c.CheckState(proofs) diff --git a/internal/cashu/crypto.go b/internal/cashu/crypto.go index 70692019..6b9d53aa 100644 --- a/internal/cashu/crypto.go +++ b/internal/cashu/crypto.go @@ -5,7 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "fmt" - "math/big" + "strings" "github.com/btcsuite/btcd/btcec/v2" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -166,5 +166,67 @@ func PointToHex(p *btcec.PublicKey) string { return hex.EncodeToString(p.SerializeCompressed()) } -// dummy use to satisfy the big import for potential future use -var _ = new(big.Int) +// hashE computes the NUT-12 challenge hash: SHA256 over the concatenated +// lowercase-hex UNCOMPRESSED serializations of the given points, as UTF-8. +func hashE(points ...*btcec.PublicKey) []byte { + var sb strings.Builder + for _, p := range points { + sb.WriteString(hex.EncodeToString(p.SerializeUncompressed())) + } + sum := sha256.Sum256([]byte(sb.String())) + return sum[:] +} + +// VerifyDLEQ verifies a NUT-12 DLEQ proof for a blind signature, proving the +// mint used the same private key k (with A = k*G published in the keyset) to +// produce C_ = k*B_. Without this check a malicious mint could sign with a +// throwaway key and later refuse the proofs as invalid. +// +// R1 = s*G - e*A +// R2 = s*B_ - e*C_ +// valid iff e == hash_e(R1, R2, A, C_) +func VerifyDLEQ(eHex, sHex string, A *btcec.PublicKey, B_ *btcec.PublicKey, C_ *btcec.PublicKey) (bool, error) { + eBytes, err := hex.DecodeString(eHex) + if err != nil { + return false, fmt.Errorf("invalid dleq e: %w", err) + } + sBytes, err := hex.DecodeString(sHex) + if err != nil { + return false, fmt.Errorf("invalid dleq s: %w", err) + } + var eScalar, sScalar secp256k1.ModNScalar + if overflow := eScalar.SetByteSlice(eBytes); overflow { + return false, fmt.Errorf("dleq e overflows curve order") + } + if overflow := sScalar.SetByteSlice(sBytes); overflow { + return false, fmt.Errorf("dleq s overflows curve order") + } + + // point = a*P + b*Q helper in Jacobian space + combine := func(a *secp256k1.ModNScalar, P *btcec.PublicKey, b *secp256k1.ModNScalar, Q *btcec.PublicKey) *btcec.PublicKey { + var pJ, qJ, aPJ, bQJ, sumJ secp256k1.JacobianPoint + P.AsJacobian(&pJ) + Q.AsJacobian(&qJ) + secp256k1.ScalarMultNonConst(a, &pJ, &aPJ) + secp256k1.ScalarMultNonConst(b, &qJ, &bQJ) + secp256k1.AddNonConst(&aPJ, &bQJ, &sumJ) + sumJ.ToAffine() + return btcec.NewPublicKey(&sumJ.X, &sumJ.Y) + } + + negE := new(secp256k1.ModNScalar).NegateVal(&eScalar) + + // R1 = s*G - e*A + var gJ secp256k1.JacobianPoint + secp256k1.ScalarBaseMultNonConst(&sScalar, &gJ) + gJ.ToAffine() + sG := btcec.NewPublicKey(&gJ.X, &gJ.Y) + one := new(secp256k1.ModNScalar).SetInt(1) + R1 := combine(one, sG, negE, A) + + // R2 = s*B_ - e*C_ + R2 := combine(&sScalar, B_, negE, C_) + + expected := hashE(R1, R2, A, C_) + return hex.EncodeToString(expected) == strings.ToLower(eHex), nil +} diff --git a/internal/cashu/token.go b/internal/cashu/token.go index 0994e1ac..ead4770b 100644 --- a/internal/cashu/token.go +++ b/internal/cashu/token.go @@ -2,16 +2,41 @@ package cashu import ( "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "strings" + + "github.com/fxamacker/cbor/v2" ) const ( // TokenPrefixV3 is the prefix for Cashu V3 tokens. TokenPrefixV3 = "cashuA" + // TokenPrefixV4 is the prefix for Cashu V4 (CBOR) tokens. + TokenPrefixV4 = "cashuB" ) +// tokenV4 is the NUT-00 V4 CBOR wire format (single-letter keys). +// We only deserialize V4; emitting stays V3 for maximum wallet compatibility. +type tokenV4 struct { + Mint string `cbor:"m"` + Unit string `cbor:"u"` + Memo string `cbor:"d,omitempty"` + Token []tokenV4Entry `cbor:"t"` +} + +type tokenV4Entry struct { + Id []byte `cbor:"i"` // keyset id, raw bytes + Proofs []tokenV4Proof `cbor:"p"` +} + +type tokenV4Proof struct { + Amount int64 `cbor:"a"` + Secret string `cbor:"s"` + C []byte `cbor:"c"` // signature point, raw bytes +} + // Serialize encodes a TokenV3 to the cashuA... string format. // Format: "cashuA" + base64url(json(TokenV3)) func (t *TokenV3) Serialize() (string, error) { @@ -23,12 +48,16 @@ func (t *TokenV3) Serialize() (string, error) { return TokenPrefixV3 + encoded, nil } -// Deserialize decodes a cashuA... string to a TokenV3. +// Deserialize decodes a cashuA (V3, JSON) or cashuB (V4, CBOR) token string +// into the internal TokenV3 representation. func Deserialize(tokenStr string) (*TokenV3, error) { tokenStr = strings.TrimSpace(tokenStr) + if strings.HasPrefix(tokenStr, TokenPrefixV4) { + return deserializeV4(strings.TrimPrefix(tokenStr, TokenPrefixV4)) + } if !strings.HasPrefix(tokenStr, TokenPrefixV3) { - return nil, fmt.Errorf("invalid token: must start with %s", TokenPrefixV3) + return nil, fmt.Errorf("invalid token: must start with %s or %s", TokenPrefixV3, TokenPrefixV4) } encoded := strings.TrimPrefix(tokenStr, TokenPrefixV3) @@ -55,3 +84,46 @@ func Deserialize(tokenStr string) (*TokenV3, error) { return &token, nil } + +// deserializeV4 decodes the base64url CBOR payload of a cashuB token and maps +// it onto the internal TokenV3 structure. +func deserializeV4(encoded string) (*TokenV3, error) { + raw, err := base64.URLEncoding.DecodeString(encoded) + if err != nil { + raw, err = base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("failed to decode V4 token base64: %w", err) + } + } + + var v4 tokenV4 + if err := cbor.Unmarshal(raw, &v4); err != nil { + return nil, fmt.Errorf("failed to decode V4 token CBOR: %w", err) + } + if len(v4.Token) == 0 { + return nil, fmt.Errorf("token contains no entries") + } + + // V4 groups proofs per keyset under one mint; flatten to V3 shape. + var proofs []Proof + for _, entry := range v4.Token { + id := hex.EncodeToString(entry.Id) + for _, p := range entry.Proofs { + proofs = append(proofs, Proof{ + Amount: p.Amount, + Id: id, + Secret: p.Secret, + C: hex.EncodeToString(p.C), + }) + } + } + if len(proofs) == 0 { + return nil, fmt.Errorf("token contains no proofs") + } + + return &TokenV3{ + Token: []TokenEntry{{Mint: v4.Mint, Proofs: proofs}}, + Memo: v4.Memo, + Unit: v4.Unit, + }, nil +} diff --git a/internal/cashu/types.go b/internal/cashu/types.go index e1a3c437..7278596e 100644 --- a/internal/cashu/types.go +++ b/internal/cashu/types.go @@ -19,9 +19,17 @@ type BlindedMessage struct { // BlindSignature is returned by the mint (Step 2 of BDHKE). type BlindSignature struct { - Amount int64 `json:"amount"` - Id string `json:"id"` - C_ string `json:"C_"` // blinded signature (compressed point hex) + Amount int64 `json:"amount"` + Id string `json:"id"` + C_ string `json:"C_"` // blinded signature (compressed point hex) + DLEQ *DLEQProof `json:"dleq,omitempty"` +} + +// DLEQProof proves the mint signed with its published key (NUT-12). +type DLEQProof struct { + E string `json:"e"` + S string `json:"s"` + R string `json:"r,omitempty"` // only present on proofs forwarded in tokens } // Keyset represents a mint keyset (NUT-01/NUT-02). From dfac7c11765882a6c04710a579d8919a10443e13 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 19:32:38 +0200 Subject: [PATCH 10/15] Fix /cashu recover reporting paid quotes as nothing to recover Recovery required quote state == "PAID", but mints on the legacy NUT-04 API return a "paid" bool and no "state" field, so their paid quotes were silently skipped and the user was told "Nothing to recover" while holding a minting-state record. Add IsPaid() handling both API shapes, and stop lying on the other skip paths too: quote-check errors, not-yet-settled quotes, and failed re-mints now count as "still pending" and are reported to the user ("your sats are not lost") instead of falling through to "Nothing to recover". Co-Authored-By: Claude Opus 4.8 --- internal/cashu/types.go | 9 ++++++++- internal/telegram/cashu.go | 20 +++++++++++++++----- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/cashu/types.go b/internal/cashu/types.go index 7278596e..5dac88c4 100644 --- a/internal/cashu/types.go +++ b/internal/cashu/types.go @@ -79,10 +79,17 @@ type MintQuoteRequest struct { type MintQuoteResponse struct { Quote string `json:"quote"` Request string `json:"request"` // bolt11 invoice - State string `json:"state"` // "UNPAID", "PAID", "ISSUED" + State string `json:"state"` // "UNPAID", "PAID", "ISSUED" (newer NUT-04) + Paid bool `json:"paid"` // legacy NUT-04 field, pre-"state" mints Expiry int64 `json:"expiry"` } +// IsPaid reports whether the quote's invoice was paid, handling both the +// current "state" field and the legacy "paid" bool. +func (q *MintQuoteResponse) IsPaid() bool { + return q.State == "PAID" || (q.State == "" && q.Paid) +} + // MintRequest is sent to POST /v1/mint/bolt11 after paying the invoice (NUT-04 step 2). type MintRequest struct { Quote string `json:"quote"` diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index df35cf99..dab07e0b 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -469,14 +469,16 @@ func (bot *TipBot) cashuRecoverHandler(ctx intercept.Context) (intercept.Context return ctx, err } - recovered := 0 + recovered, stillPending := 0, 0 for _, c := range tokens { if c.State != cashuStateMinting { continue } q, err := bot.CashuClient.CheckMintQuote(c.QuoteId) if err != nil { + // Never report a pending record as "nothing": tell the user it exists. log.Errorf("[cashu recover] check quote %s: %s", c.QuoteId, err.Error()) + stillPending++ continue } if q.State == "ISSUED" { @@ -486,12 +488,17 @@ func (bot *TipBot) cashuRecoverHandler(ctx intercept.Context) (intercept.Context _ = bot.setCashuToken(c) continue } - if q.State != "PAID" { + if !q.IsPaid() { + // Legacy mints answer with paid=false while settling; newer ones with + // state=UNPAID. Either way: not claimable yet, but still the user's. + log.Warnf("[cashu recover] quote %s not paid yet (state=%q paid=%v)", c.QuoteId, q.State, q.Paid) + stillPending++ continue } tokenStr, err := bot.CashuClient.MintTokens(c.QuoteId, c.Amount, c.Memo) if err != nil { log.Errorf("[cashu recover] mint from quote %s failed: %s", c.QuoteId, err.Error()) + stillPending++ continue } c.Token = tokenStr @@ -501,10 +508,13 @@ func (bot *TipBot) cashuRecoverHandler(ctx intercept.Context) (intercept.Context recovered++ } - if recovered == 0 { - bot.trySendMessage(m.Sender, "🥜 Nothing to recover.") - } else { + switch { + case recovered > 0 && stillPending == 0: bot.trySendMessage(m.Sender, fmt.Sprintf("🥜 Recovered %d token(s) to your wallet.", recovered)) + case stillPending > 0: + bot.trySendMessage(m.Sender, fmt.Sprintf("🥜 Recovered %d token(s). %d still pending — the mint hasn't settled or answered yet, try again in a bit. Your sats are not lost.", recovered, stillPending)) + default: + bot.trySendMessage(m.Sender, "🥜 Nothing to recover.") } return ctx, nil } From 199c7d6bd4e8a575f94f2a28bc98aa71b943c473 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 19:44:01 +0200 Subject: [PATCH 11/15] Poll mint until quote settles instead of sleeping 2s Observed on 21mint.me: LNbits Pay returns before the mint sees the Lightning payment as settled, so minting 2s later failed with "quote not paid" (code 20001) and the token landed in recovery. Replace the fixed sleep with WaitQuotePaid, which polls CheckMintQuote every 2s for up to 30s in both the /cashu mint and inline claim paths. On timeout the durable record plus /cashu recover takes over, and the user is told their sats are safe. Also close the remaining inline-claim gap: persist the minting-state record BEFORE the sender pays (like /cashu mint does), delete it if the payment itself fails, mark it spent after successful delivery. A wait timeout or mint failure after payment now always leaves a recoverable record instead of only a log line. Co-Authored-By: Claude Opus 4.8 --- internal/cashu/client.go | 18 +++++++++++++ internal/telegram/cashu.go | 9 +++++-- internal/telegram/cashu_inline.go | 43 +++++++++++++++++++------------ 3 files changed, 52 insertions(+), 18 deletions(-) diff --git a/internal/cashu/client.go b/internal/cashu/client.go index c679c9e8..3f89214a 100644 --- a/internal/cashu/client.go +++ b/internal/cashu/client.go @@ -177,6 +177,24 @@ func (c *Client) CheckState(proofs []Proof) (*CheckStateResponse, error) { return &stateResp, err } +// WaitQuotePaid polls the mint until it sees the quote's invoice as paid. +// LNbits returning from Pay only means our side sent the payment; the mint's +// view can lag by seconds (observed with 21mint.me), and minting before it +// settles fails with "quote not paid". +func (c *Client) WaitQuotePaid(quoteId string, timeout time.Duration) (bool, error) { + deadline := time.Now().Add(timeout) + for { + q, err := c.CheckMintQuote(quoteId) + if err == nil && q.IsPaid() { + return true, nil + } + if time.Now().After(deadline) { + return false, err + } + time.Sleep(2 * time.Second) + } +} + // MintTokens is a high-level function that orchestrates the full minting flow: // 1. Get active keyset // 2. Split amount into power-of-2 denominations diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index dab07e0b..cf7b1f04 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -239,8 +239,13 @@ func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, se log.Infof("[cashu mint] Paid mint invoice for quote %s", quote.Quote) - // Step 3: Wait briefly for payment to settle, then check quote status - time.Sleep(2 * time.Second) + // Step 3: Wait until the MINT sees the payment as settled. Our Pay + // returning is not enough — minting too early fails with "quote not paid". + if paid, err := bot.CashuClient.WaitQuotePaid(quote.Quote, 30*time.Second); !paid { + log.Warnf("[cashu mint] quote %s not settled after 30s (err=%v)", quote.Quote, err) + bot.tryEditMessage(statusMsg, "🥜 Payment sent, but the mint hasn't confirmed it yet. Your token is saved — run /cashu recover in a moment to finish it.") + return ctx, fmt.Errorf("mint quote not settled in time") + } // Step 4: Mint the tokens tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, memo) diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go index a51b5457..0bef6a29 100644 --- a/internal/telegram/cashu_inline.go +++ b/internal/telegram/cashu_inline.go @@ -204,20 +204,37 @@ func (bot *TipBot) acceptInlineCashuHandler(ctx intercept.Context) (intercept.Co ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cashuMintErrorMessage")) return ctx, err } + // Durable record BEFORE paying, exactly like /cashu mint: if anything after + // the payment fails, the sender recovers via /cashu recover instead of + // losing the sats. + record := newCashuToken(from.Telegram.ID, from.Telegram.Username, amount, inlineCashu.Memo, quote.Quote) + if err := bot.setCashuToken(record); err != nil { + log.Errorf("[cashu claim] could not persist token record: %s", err.Error()) + return ctx, err + } if _, err = from.Wallet.Pay(lnbits.PaymentParams{Out: true, Bolt11: quote.Request}, bot.Client); err != nil { log.Errorf("[cashu claim] sender %s could not pay mint invoice: %s", GetUserStr(from.Telegram), err.Error()) + _ = record.Delete(record, bot.Bunt) // nothing was paid ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "balanceTooLowMessage")) return ctx, err } - time.Sleep(2 * time.Second) + if paid, werr := bot.CashuClient.WaitQuotePaid(quote.Quote, 30*time.Second); !paid { + log.Errorf("[cashu claim] quote %s not settled after 30s (err=%v), sender can /cashu recover", quote.Quote, werr) + bot.trySendMessage(from.Telegram, "🥜 Your inline cashu payment hasn't settled at the mint yet. Run /cashu recover in a moment to reclaim it.") + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cashuMintErrorMessage")) + return ctx, fmt.Errorf("mint quote not settled in time") + } tokenStr, err := bot.CashuClient.MintTokens(quote.Quote, amount, inlineCashu.Memo) if err != nil { - // ponytail: sender already paid; the paid quote is a bearer credit. - // Log the id loudly so the sats are recoverable rather than silently lost. + // Sender paid; the minting-state record makes this recoverable. log.Errorf("[cashu claim] MintTokens failed AFTER sender paid, recoverable quote=%s: %s", quote.Quote, err.Error()) + bot.trySendMessage(from.Telegram, "🥜 Your inline cashu couldn't be minted yet. Run /cashu recover to reclaim it.") ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "cashuMintErrorMessage")) return ctx, err } + record.Token = tokenStr + record.State = cashuStateUnclaimed + _ = bot.setCashuToken(record) token, err := cashu.Deserialize(tokenStr) if err != nil { @@ -231,24 +248,18 @@ func (bot *TipBot) acceptInlineCashuHandler(ctx intercept.Context) (intercept.Co // Deliver to the claimer by melting the proofs onto their wallet. netAmount, err := bot.meltProofsToWallet(to, proofs, totalAmount) if err != nil { - // Sender was already debited and a token minted, but delivery failed. - // Save the token to the sender's wallet so the sats are never lost. - rec := &CashuToken{ - Base: storage.New(storage.ID(cashuTokenKey(from.Telegram.ID, RandStringRunes(10)))), - TelegramID: from.Telegram.ID, - Username: from.Telegram.Username, - Amount: totalAmount, - Memo: inlineCashu.Memo, - Token: tokenStr, - State: cashuStateUnclaimed, - } - _ = bot.setCashuToken(rec) - log.Errorf("[cashu claim] melt to claimer failed, saved token to sender %s: %s", GetUserStr(from.Telegram), err.Error()) + // Sender was debited and a token minted, but delivery failed. The + // unclaimed record (stored above) keeps the token in the sender's wallet. + log.Errorf("[cashu claim] melt to claimer failed, token stays with sender %s: %s", GetUserStr(from.Telegram), err.Error()) bot.trySendMessage(from.Telegram, "🥜 Your inline cashu couldn't be delivered, so the token was saved to your wallet — see /cashu list.") return ctx, err } totalAmount = netAmount + // Token consumed by the claimer: mark the sender's record spent. + record.State = cashuStateSpent + _ = bot.setCashuToken(record) + // Mark as claimed inlineCashu.Claimed = true inlineCashu.ClaimedBy = to From 867ce28c4d0c1a7ce494a2535b12482fe976f74e Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 20:04:12 +0200 Subject: [PATCH 12/15] Auto-recover stuck cashu mints in the background Users shouldn't need to know /cashu recover exists. A background loop scans every 2 minutes for minting-state records older than 5 minutes (the age gate keeps it from racing a mint handler still inside WaitQuotePaid), re-drives paid quotes, and DMs the user their recovered token. Expired-unpaid quotes are closed (nothing was spent); already- issued quotes are marked spent since their blinding data is gone. Manual /cashu recover stays for impatient users. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/bot.go | 3 + internal/telegram/cashu_autorecover.go | 89 ++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 internal/telegram/cashu_autorecover.go diff --git a/internal/telegram/bot.go b/internal/telegram/bot.go index 524bd9d4..5abb06c0 100644 --- a/internal/telegram/bot.go +++ b/internal/telegram/bot.go @@ -147,6 +147,9 @@ func (bot *TipBot) Start() { go bot.restartPersistedTickets() + // re-drive stuck cashu mints in the background (no-op if cashu disabled) + go bot.cashuAutoRecoverLoop() + // gracefully shutdown on interrupt or termination signal. // syscall.SIGSTOP is not catchable on any platform and is not defined on // Windows, so it must not be passed to signal.Notify. diff --git a/internal/telegram/cashu_autorecover.go b/internal/telegram/cashu_autorecover.go new file mode 100644 index 00000000..f8fed245 --- /dev/null +++ b/internal/telegram/cashu_autorecover.go @@ -0,0 +1,89 @@ +package telegram + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/LightningTipBot/LightningTipBot/internal" + + log "github.com/sirupsen/logrus" + "github.com/tidwall/buntdb" + tb "gopkg.in/lightningtipbot/telebot.v3" +) + +const ( + // How often the background loop scans for stuck mints. + cashuAutoRecoverInterval = 2 * time.Minute + // Records younger than this are skipped: their original mint handler may + // still be inside WaitQuotePaid, and re-driving the same quote from two + // goroutines would race. + cashuAutoRecoverMinAge = 5 * time.Minute +) + +// cashuAutoRecoverLoop periodically re-drives paid-but-unminted quotes so +// users get their token without knowing about /cashu recover. +func (bot *TipBot) cashuAutoRecoverLoop() { + if !internal.Configuration.Cashu.Enabled { + return + } + for { + time.Sleep(cashuAutoRecoverInterval) + bot.cashuAutoRecoverOnce() + } +} + +// cashuAutoRecoverOnce scans ALL users' cashu records for stuck mints. +func (bot *TipBot) cashuAutoRecoverOnce() { + var stuck []*CashuToken + err := bot.Bunt.View(func(tx *buntdb.Tx) error { + return tx.AscendKeys("cashutoken:*", func(key, value string) bool { + var c CashuToken + if err := json.Unmarshal([]byte(value), &c); err != nil { + return true // skip corrupt record, keep going + } + if c.State == cashuStateMinting && time.Since(c.CreatedAt) > cashuAutoRecoverMinAge { + stuck = append(stuck, &c) + } + return true + }) + }) + if err != nil { + log.Errorf("[cashu autorecover] scan failed: %s", err.Error()) + return + } + + for _, c := range stuck { + q, err := bot.CashuClient.CheckMintQuote(c.QuoteId) + if err != nil { + log.Warnf("[cashu autorecover] check quote %s: %s", c.QuoteId, err.Error()) + continue + } + recipient := &tb.User{ID: c.TelegramID} + if q.State == "ISSUED" { + // Signed elsewhere, blinding data gone — not re-derivable. Stop retrying. + c.State = cashuStateSpent + _ = bot.setCashuToken(c) + log.Warnf("[cashu autorecover] quote %s already issued, marking spent", c.QuoteId) + continue + } + if !q.IsPaid() { + if q.Expiry > 0 && time.Now().Unix() > q.Expiry { + // Invoice never paid and quote expired: nothing was spent. Close it. + _ = c.Inactivate(c, bot.Bunt) + log.Infof("[cashu autorecover] quote %s expired unpaid, closing record", c.QuoteId) + } + continue + } + tokenStr, err := bot.CashuClient.MintTokens(c.QuoteId, c.Amount, c.Memo) + if err != nil { + log.Errorf("[cashu autorecover] mint from quote %s failed: %s", c.QuoteId, err.Error()) + continue + } + c.Token = tokenStr + c.State = cashuStateUnclaimed + _ = bot.setCashuToken(c) + bot.trySendMessage(recipient, fmt.Sprintf("🥜 Your pending *%d sat* cashu token was recovered automatically:\n`%s`", c.Amount, tokenStr)) + log.Infof("[cashu autorecover] recovered %d sat for user %d (quote %s)", c.Amount, c.TelegramID, c.QuoteId) + } +} From 7d6790f1c5372e22eaa11f26714e4056e16359af Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 20:39:58 +0200 Subject: [PATCH 13/15] Support /cashu group shares with confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most users expect "/cashu 210" in a group to just work. The command is now allowed in group chats: a bare amount asks for confirmation in the chat ("wants to share N sat as ecash... Confirm?"), and on Confirm ( requester-only) the bot mints from the requester's wallet and posts the QR plus token into the chat — a first-come-first-served bearer drop redeemable by any cashu wallet, not just bot users. In DMs the bare amount is a mint shortcut. Named subcommands stay DM-only: receive/list in a group would leak tokens or private state. The existing inline claim-button flow is unchanged as the controlled-handover alternative. The token string is now always sent as its own message instead of in the QR photo caption: bigger tokens exceed Telegram's 1024-char caption limit (latent bug in the DM path too), and a standalone message is easier to copy. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/cashu.go | 87 ++++++++++++++++++++++++++++-------- internal/telegram/handler.go | 3 +- translations/en.toml | 1 + 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index cf7b1f04..fb36408d 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -38,6 +38,23 @@ func (bot *TipBot) cashuHandler(ctx intercept.Context) (intercept.Context, error return ctx, nil } + // Bare amount: "/cashu 210 [memo]" — mint-and-share into the current chat + // (group drop) or plain mint in a DM. This is what most users type first. + if amount, err := GetAmount(args[1]); err == nil && amount >= 1 { + memo := "" + if len(args) > 2 { + memo = strings.Join(args[2:], " ") + } + return bot.requestCashuMint(ctx, amount, memo, !m.Private()) + } + + // Named subcommands are DM-only: receive/list would leak tokens or private + // state into the group, and mint/send have the bare-amount form there. + if !m.Private() { + bot.trySendMessage(m.Chat, fmt.Sprintf("🥜 Use `/cashu ` here to share ecash, or DM @%s for all other cashu commands.", bot.Telegram.Me.Username)) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + subcommand := strings.ToLower(args[1]) switch subcommand { case "mint": @@ -68,6 +85,7 @@ type CashuMintRequest struct { TelegramID int64 `json:"cashu_mint_req_telegram_id"` Amount int64 `json:"cashu_mint_req_amount"` Memo string `json:"cashu_mint_req_memo"` + Public bool `json:"cashu_mint_req_public"` // post token into the chat instead of DM } func (bot *TipBot) makeCashuMintConfirmKeyboard(id string) *tb.ReplyMarkup { @@ -78,15 +96,9 @@ func (bot *TipBot) makeCashuMintConfirmKeyboard(id string) *tb.ReplyMarkup { return menu } -// cashuMintHandler handles /cashu mint [memo]. -// It validates the request and asks for confirmation; money only moves after -// the user taps Confirm (confirmCashuMintHandler). +// cashuMintHandler handles /cashu mint [memo] in DMs. func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, error) { m := ctx.Message() - user := LoadUser(ctx) - if user.Wallet.ID == "" { - return ctx, errors.Create(errors.UserNoWalletError) - } // Parse: /cashu mint [memo] args := strings.Fields(m.Text) @@ -106,6 +118,19 @@ func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, e memo = strings.Join(args[3:], " ") } + return bot.requestCashuMint(ctx, amount, memo, false) +} + +// requestCashuMint validates a mint/share request and asks for confirmation. +// Money only moves after the user taps Confirm (confirmCashuMintHandler). +// public = post the resulting token into the originating chat (group drop). +func (bot *TipBot) requestCashuMint(ctx intercept.Context, amount int64, memo string, public bool) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + // Check user balance balance, err := bot.GetUserBalanceCached(user) if err != nil { @@ -129,17 +154,25 @@ func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, e TelegramID: m.Sender.ID, Amount: amount, Memo: memo, + Public: public, } if err := req.Set(req, bot.Bunt); err != nil { log.Errorf("[cashu mint] could not persist mint request: %s", err.Error()) return ctx, err } - confirmText := fmt.Sprintf("🥜 Mint a *%d sat* cashu token?\nThis pays %d sat from your wallet to the mint `%s`.", amount, amount, str.MarkdownEscape(bot.CashuClient.MintURL())) + var confirmText string + if public { + confirmText = fmt.Sprintf("🥜 %s wants to share a *%d sat* ecash token in this chat.\nFirst to scan or redeem it gets the sats. Confirm?", GetUserStrMd(m.Sender), amount) + } else { + confirmText = fmt.Sprintf("🥜 Mint a *%d sat* cashu token?\nThis pays %d sat from your wallet to the mint `%s`.", amount, amount, str.MarkdownEscape(bot.CashuClient.MintURL())) + } if len(memo) > 0 { confirmText += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(memo)) } - bot.trySendMessage(m.Sender, confirmText, bot.makeCashuMintConfirmKeyboard(req.ID)) + // The confirmation lives in the chat the command came from; only the + // requester can press its buttons. + bot.trySendMessage(m.Chat, confirmText, bot.makeCashuMintConfirmKeyboard(req.ID)) return ctx, nil } @@ -176,7 +209,13 @@ func (bot *TipBot) confirmCashuMintHandler(ctx intercept.Context) (intercept.Con return ctx, errors.Create(errors.InvalidSyntaxError) } - return bot.executeCashuMint(ctx, user, c.Sender, c.Message, req.Amount, req.Memo) + // Public share: token goes into the chat the confirmation lives in. + // Private mint: token goes to the requester's DM. + var dest tb.Recipient = c.Sender + if req.Public { + dest = c.Message.Chat + } + return bot.executeCashuMint(ctx, user, c.Sender, c.Message, dest, req.Amount, req.Memo, req.Public) } // cancelCashuMintHandler runs when the user taps Cancel on a pending mint. @@ -201,8 +240,9 @@ func (bot *TipBot) cancelCashuMintHandler(ctx intercept.Context) (intercept.Cont // executeCashuMint performs the actual quote -> pay -> mint flow after the // user confirmed. statusMsg (the confirmation message) is edited in place. -func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, sender *tb.User, statusMsg *tb.Message, amount int64, memo string) (intercept.Context, error) { - m := &tb.Message{Sender: sender} // for the shared success-send path below +// dest is where the token lands: the requester (DM mint) or a chat (group share). +func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, sender *tb.User, statusMsg *tb.Message, dest tb.Recipient, amount int64, memo string, public bool) (intercept.Context, error) { + m := &tb.Message{Sender: sender} // ownership of the durable record bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "cashuMintMessage"), amount)) // Step 1: Request mint quote from the external Cashu mint @@ -263,26 +303,35 @@ func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, se record.State = cashuStateUnclaimed _ = bot.setCashuToken(record) + // Build the announcement. The token string goes as its OWN message: it can + // exceed Telegram's 1024-char photo caption limit, and a standalone + // monospace message is easier to copy anyway. + var caption string + if public { + caption = fmt.Sprintf("🥜 %s is sharing *%d sat* as ecash — scan the QR with any cashu wallet or redeem the token below. First come, first served!", GetUserStrMd(sender), amount) + } else { + caption = fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount) + } + // Step 5: Generate QR code qr, err := qrcode.Encode(tokenStr, qrcode.Medium, 512) if err != nil { log.Errorf("[cashu mint] QR code generation failed: %s", err.Error()) // Still send the token string even if QR fails - bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount)+"\n\n`"+tokenStr+"`") + bot.tryEditMessage(statusMsg, caption) + bot.trySendMessage(dest, "`"+tokenStr+"`") return ctx, nil } // Delete status message and send QR + token bot.tryDeleteMessage(statusMsg) - - caption := fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount) + "\n\n`" + tokenStr + "`" - photo := &tb.Photo{ + bot.trySendMessage(dest, &tb.Photo{ File: tb.FromReader(bytes.NewReader(qr)), Caption: caption, - } - bot.trySendMessage(m.Sender, photo) + }) + bot.trySendMessage(dest, "`"+tokenStr+"`") - log.Infof("[cashu mint] %s minted %d sat cashu token", GetUserStr(m.Sender), amount) + log.Infof("[cashu mint] %s minted %d sat cashu token (public=%v)", GetUserStr(sender), amount, public) return ctx, nil } diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index c2ca1277..9ba7edba 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -410,7 +410,8 @@ func (bot TipBot) getHandler() []InterceptionWrapper { Handler: bot.cashuHandler, Interceptor: &Interceptor{ Before: []intercept.Func{ - bot.requirePrivateChatInterceptor, + // group chats allowed: bare "/cashu " shares into the + // chat; the handler itself keeps subcommands DM-only bot.localizerInterceptor, bot.logMessageInterceptor, bot.requireUserInterceptor, diff --git a/translations/en.toml b/translations/en.toml index 35d4dd9e..83d67c47 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -415,6 +415,7 @@ generateDalleGeneratingMessage = """Your images are being generated. Please wai cashuCommandStr = """cashu""" cashuHelpText = """🥜 *Cashu ecash tokens* +`/cashu [memo]` — Mint an ecash token (in a group: share it in the chat, first come first served) `/cashu mint [memo]` — Create an ecash token `/cashu receive ` — Redeem a cashu token to your wallet `/cashu send [memo]` — Create a cashu token to share From c70dc1efcdd792573aa3d108d9f87881f73efeec Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 20:41:54 +0200 Subject: [PATCH 14/15] Group cashu shares use a Collect button instead of posting the token Posting the QR + raw token string into the chat spammed 1000+ chars of bearer token and let any scanner race the group. A group /cashu confirm now posts a compact message with a Collect button, backed by the same InlineCashu record and claim handler as the inline flow: the sender's wallet is only charged when someone actually collects, first collector wins, creator can still Cancel. Button label renamed Claim -> Collect to match the faucet wording. DM mints keep the QR + token output. Co-Authored-By: Claude Opus 4.8 --- internal/telegram/cashu.go | 57 +++++++++++++++++++++++--------------- translations/en.toml | 4 +-- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/internal/telegram/cashu.go b/internal/telegram/cashu.go index fb36408d..6679299b 100644 --- a/internal/telegram/cashu.go +++ b/internal/telegram/cashu.go @@ -163,7 +163,7 @@ func (bot *TipBot) requestCashuMint(ctx intercept.Context, amount int64, memo st var confirmText string if public { - confirmText = fmt.Sprintf("🥜 %s wants to share a *%d sat* ecash token in this chat.\nFirst to scan or redeem it gets the sats. Confirm?", GetUserStrMd(m.Sender), amount) + confirmText = fmt.Sprintf("🥜 %s wants to share a *%d sat* ecash token in this chat.\nThe first user to hit Collect gets the sats. Confirm?", GetUserStrMd(m.Sender), amount) } else { confirmText = fmt.Sprintf("🥜 Mint a *%d sat* cashu token?\nThis pays %d sat from your wallet to the mint `%s`.", amount, amount, str.MarkdownEscape(bot.CashuClient.MintURL())) } @@ -209,13 +209,33 @@ func (bot *TipBot) confirmCashuMintHandler(ctx intercept.Context) (intercept.Con return ctx, errors.Create(errors.InvalidSyntaxError) } - // Public share: token goes into the chat the confirmation lives in. - // Private mint: token goes to the requester's DM. - var dest tb.Recipient = c.Sender if req.Public { - dest = c.Message.Chat + // Group share: no minting yet. Post a Collect message backed by the same + // InlineCashu record the inline flow uses — the sender's wallet is only + // charged when someone actually collects (acceptInlineCashuHandler). + id := fmt.Sprintf("cashu:%s:%d", RandStringRunes(10), req.Amount) + shareMsg := fmt.Sprintf(Translate(ctx, "cashuSendMessage"), GetUserStrMd(user.Telegram), req.Amount) + if len(req.Memo) > 0 { + shareMsg += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(req.Memo)) + } + ic := &InlineCashu{ + Base: storage.New(storage.ID(id)), + Message: shareMsg, + Amount: req.Amount, + From: user, + Memo: req.Memo, + LanguageCode: "en", + } + if err := ic.Set(ic, bot.Bunt); err != nil { + log.Errorf("[cashu share] could not persist share: %s", err.Error()) + return ctx, err + } + bot.tryEditMessage(c.Message, shareMsg, bot.makeCashuKeyboard(ctx, id)) + log.Infof("[cashu share] %s shared %d sat collectable in chat %d", GetUserStr(c.Sender), req.Amount, c.Message.Chat.ID) + return ctx, nil } - return bot.executeCashuMint(ctx, user, c.Sender, c.Message, dest, req.Amount, req.Memo, req.Public) + + return bot.executeCashuMint(ctx, user, c.Sender, c.Message, req.Amount, req.Memo) } // cancelCashuMintHandler runs when the user taps Cancel on a pending mint. @@ -239,9 +259,8 @@ func (bot *TipBot) cancelCashuMintHandler(ctx intercept.Context) (intercept.Cont } // executeCashuMint performs the actual quote -> pay -> mint flow after the -// user confirmed. statusMsg (the confirmation message) is edited in place. -// dest is where the token lands: the requester (DM mint) or a chat (group share). -func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, sender *tb.User, statusMsg *tb.Message, dest tb.Recipient, amount int64, memo string, public bool) (intercept.Context, error) { +// user confirmed a private mint. statusMsg is edited in place. +func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, sender *tb.User, statusMsg *tb.Message, amount int64, memo string) (intercept.Context, error) { m := &tb.Message{Sender: sender} // ownership of the durable record bot.tryEditMessage(statusMsg, fmt.Sprintf(Translate(ctx, "cashuMintMessage"), amount)) @@ -303,15 +322,9 @@ func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, se record.State = cashuStateUnclaimed _ = bot.setCashuToken(record) - // Build the announcement. The token string goes as its OWN message: it can - // exceed Telegram's 1024-char photo caption limit, and a standalone - // monospace message is easier to copy anyway. - var caption string - if public { - caption = fmt.Sprintf("🥜 %s is sharing *%d sat* as ecash — scan the QR with any cashu wallet or redeem the token below. First come, first served!", GetUserStrMd(sender), amount) - } else { - caption = fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount) - } + // The token string goes as its OWN message: it can exceed Telegram's + // 1024-char photo caption limit, and a standalone message is easier to copy. + caption := fmt.Sprintf(Translate(ctx, "cashuMintSuccessMessage"), amount) // Step 5: Generate QR code qr, err := qrcode.Encode(tokenStr, qrcode.Medium, 512) @@ -319,19 +332,19 @@ func (bot *TipBot) executeCashuMint(ctx intercept.Context, user *lnbits.User, se log.Errorf("[cashu mint] QR code generation failed: %s", err.Error()) // Still send the token string even if QR fails bot.tryEditMessage(statusMsg, caption) - bot.trySendMessage(dest, "`"+tokenStr+"`") + bot.trySendMessage(sender, "`"+tokenStr+"`") return ctx, nil } // Delete status message and send QR + token bot.tryDeleteMessage(statusMsg) - bot.trySendMessage(dest, &tb.Photo{ + bot.trySendMessage(sender, &tb.Photo{ File: tb.FromReader(bytes.NewReader(qr)), Caption: caption, }) - bot.trySendMessage(dest, "`"+tokenStr+"`") + bot.trySendMessage(sender, "`"+tokenStr+"`") - log.Infof("[cashu mint] %s minted %d sat cashu token (public=%v)", GetUserStr(sender), amount, public) + log.Infof("[cashu mint] %s minted %d sat cashu token", GetUserStr(sender), amount) return ctx, nil } diff --git a/translations/en.toml b/translations/en.toml index 83d67c47..7a04b0e4 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -415,7 +415,7 @@ generateDalleGeneratingMessage = """Your images are being generated. Please wai cashuCommandStr = """cashu""" cashuHelpText = """🥜 *Cashu ecash tokens* -`/cashu [memo]` — Mint an ecash token (in a group: share it in the chat, first come first served) +`/cashu [memo]` — Mint an ecash token (in a group: share it with a Collect button, first come first served) `/cashu mint [memo]` — Create an ecash token `/cashu receive ` — Redeem a cashu token to your wallet `/cashu send [memo]` — Create a cashu token to share @@ -437,7 +437,7 @@ cashuSendMessage = """🥜 *%s* is sharing *%d sat* as ecash.""" cashuSendClaimedMessage = """🥜 *%d sat* claimed by %s.""" cashuSendCancelledMessage = """🥜 Cashu token cancelled.""" cashuAlreadyClaimedMessage = """This token has already been claimed.""" -cashuClaimButtonMessage = """🥜 Claim""" +cashuClaimButtonMessage = """🥜 Collect""" inlineQueryCashuTitle = """🥜 Share ecash token""" inlineQueryCashuDescription = """Usage: @%s cashu [memo]""" inlineResultCashuTitle = """🥜 Share %d sat ecash token""" From 4d09c8347d570c5ce685b0f8403bc1153f3347b6 Mon Sep 17 00:00:00 2001 From: Kevin Ravensberg Date: Sat, 11 Jul 2026 20:42:42 +0200 Subject: [PATCH 15/15] =?UTF-8?q?Show=20'Collected=20=E2=9C=85=20by=20@use?= =?UTF-8?q?r'=20when=20a=20shared=20cashu=20token=20is=20collected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- translations/en.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/translations/en.toml b/translations/en.toml index 7a04b0e4..213c2ac3 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -434,7 +434,7 @@ cashuDisabledMessage = """🥜 Cashu ecash support is not enabled on this bot."" cashuMintMismatchMessage = """🥜 This token is from a different mint. This bot only accepts tokens from `%s`.""" cashuFeeTooHighMessage = """🥜 Cannot redeem: the token has %d sat but melt fees would cost %d sat.""" cashuSendMessage = """🥜 *%s* is sharing *%d sat* as ecash.""" -cashuSendClaimedMessage = """🥜 *%d sat* claimed by %s.""" +cashuSendClaimedMessage = """🥜 *%d sat* — Collected ✅ by %s""" cashuSendCancelledMessage = """🥜 Cashu token cancelled.""" cashuAlreadyClaimedMessage = """This token has already been claimed.""" cashuClaimButtonMessage = """🥜 Collect"""