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/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 new file mode 100644 index 00000000..f8cb5476 --- /dev/null +++ b/internal/cashu/cashu_test.go @@ -0,0 +1,141 @@ +package cashu + +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. +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) + } + } +} + +// 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{ + 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/cashu/client.go b/internal/cashu/client.go new file mode 100644 index 00000000..99bb26d9 --- /dev/null +++ b/internal/cashu/client.go @@ -0,0 +1,388 @@ +package cashu + +import ( + "encoding/hex" + "fmt" + "strconv" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "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 + 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", + }, + } +} + +// 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 := c.http.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 := c.http.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 := 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) + } + 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 := 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) + } + 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 := 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) + } + 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 := 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) + } + 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 +} + +// CheckMeltQuote fetches the current state of a melt quote (NUT-05). +func (c *Client) CheckMeltQuote(quoteId string) (*MeltQuoteResponse, error) { + resp, err := c.http.Get(c.mintURL+"/v1/melt/quote/bolt11/"+quoteId, c.header) + if err != nil { + return nil, fmt.Errorf("failed to check melt quote: %w", err) + } + if resp.Response().StatusCode >= 300 { + return nil, fmt.Errorf("check melt quote failed with status %d", resp.Response().StatusCode) + } + var quote MeltQuoteResponse + err = resp.ToJSON("e) + return "e, err +} + +// WaitMeltPaid polls a melt quote until the mint reports the Lightning payment +// as completed. A melt is an outbound LN payment from the mint and can +// legitimately sit in PENDING for seconds — PENDING means wait, not failure. +func (c *Client) WaitMeltPaid(quoteId string, timeout time.Duration) (bool, error) { + deadline := time.Now().Add(timeout) + for { + q, err := c.CheckMeltQuote(quoteId) + if err == nil && q.IsPaid() { + return true, nil + } + if err == nil && q.State == "UNPAID" { + // Mint gave up on the payment; proofs stay unspent. + return false, nil + } + if time.Now().After(deadline) { + return false, err + } + time.Sleep(2 * time.Second) + } +} + +// 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 := 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) + } + 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 := 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) + } + 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 +} + +// 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 +// 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] + // /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 + } + } + 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 { + secret, err := GenerateSecret() + if err != nil { + return "", err + } + br, err := BlindMessage(secret) + 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)) + dleqMissing := 0 + 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) + } + + // 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 { + 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) + } + + 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) + 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..6b9d53aa --- /dev/null +++ b/internal/cashu/crypto.go @@ -0,0 +1,232 @@ +package cashu + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + + "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. 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*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. +// 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) + 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. +// 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()) +} + +// 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 new file mode 100644 index 00000000..ead4770b --- /dev/null +++ b/internal/cashu/token.go @@ -0,0 +1,129 @@ +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) { + 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 (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 or %s", TokenPrefixV3, TokenPrefixV4) + } + + 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 +} + +// 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 new file mode 100644 index 00000000..c61d866c --- /dev/null +++ b/internal/cashu/types.go @@ -0,0 +1,169 @@ +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) + 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). +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" (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"` + 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" + Paid bool `json:"paid"` // legacy field, pre-"state" mints + Expiry int64 `json:"expiry"` +} + +// IsPaid reports whether the melt completed, handling both API shapes. +func (q *MeltQuoteResponse) IsPaid() bool { + return q.State == "PAID" || (q.State == "" && q.Paid) +} + +// 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..5abb06c0 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 @@ -139,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.go b/internal/telegram/cashu.go new file mode 100644 index 00000000..a5ab359a --- /dev/null +++ b/internal/telegram/cashu.go @@ -0,0 +1,862 @@ +package telegram + +import ( + "bytes" + "context" + "fmt" + "strconv" + "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/storage" + "github.com/LightningTipBot/LightningTipBot/internal/str" + "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 + } + + // 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(), m.Chat, 0, "") + } + + // 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": + return bot.cashuMintHandler(ctx) + case "receive", "redeem", "claim": + 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 + } +} + +var ( + cashuMintConfirmationMenu = &tb.ReplyMarkup{ResizeKeyboard: true} + // 🌱 because you mint it fresh + btnConfirmCashuMint = cashuMintConfirmationMenu.Data("🌱 Mint", "confirm_cashu_mint") + btnCancelCashuMint = cashuMintConfirmationMenu.Data("🚫 Cancel", "cancel_cashu_mint") + btnCashuTipAmount = cashuMintConfirmationMenu.Data("", "cashu_tip_amount") +) + +// 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"` + Public bool `json:"cashu_mint_req_public"` // post token into the chat instead of DM + RecipientID int64 `json:"cashu_mint_req_recipient_id"` // /cashutip as reply: only this user may collect (0 = anyone) + RecipientName string `json:"cashu_mint_req_recipient_name"` // for display +} + +// cashuTipRecipient extracts the intended recipient when /cashutip is used as +// a reply to someone's message. Self-replies and replies to the bot yield no +// restriction (anyone may collect). +func (bot *TipBot) cashuTipRecipient(m *tb.Message) (int64, string) { + if m.ReplyTo == nil || m.ReplyTo.Sender == nil { + return 0, "" + } + r := m.ReplyTo.Sender + if r.ID == m.Sender.ID || r.ID == bot.Telegram.Me.ID { + return 0, "" + } + return r.ID, GetUserStr(r) +} + +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] in DMs. +func (bot *TipBot) cashuMintHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + + // 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:], " ") + } + + return bot.requestCashuMint(ctx, amount, memo, false, m.Chat, 0, "") +} + +// cashuTipHandler handles /cashutip [amount] [memo] in a group. Without an +// amount it asks for one in the requester's DM (ForceReply state flow, same as +// invoices) and then posts the confirmable share back into the group. +func (bot *TipBot) cashuTipHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if !internal.Configuration.Cashu.Enabled { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuDisabledMessage")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // Used as a reply to someone's message = tip locked to that user. + recipientID, recipientName := bot.cashuTipRecipient(m) + + // Flexible args: "/cashutip 21 @user memo", "/cashutip @user 21", ... + // First @mention = recipient (wins over reply target), first number = + // amount, the rest = memo. + var amount int64 + var memoParts []string + for _, arg := range strings.Fields(m.Text)[1:] { + if strings.HasPrefix(arg, "@") && len(arg) > 1 { + toUser, err := GetUserByTelegramUsername(arg[1:], *bot) + if err != nil { + bot.trySendMessage(m.Chat, fmt.Sprintf(Translate(ctx, "sendUserHasNoWalletMessage"), str.MarkdownEscape(arg))) + return ctx, err + } + recipientID, recipientName = toUser.Telegram.ID, GetUserStr(toUser.Telegram) + continue + } + if amount == 0 { + if a, err := GetAmount(arg); err == nil && a >= 1 { + amount = a + continue + } + } + memoParts = append(memoParts, arg) + } + + if amount > 0 { + return bot.requestCashuMint(ctx, amount, strings.Join(memoParts, " "), !m.Private(), m.Chat, recipientID, recipientName) + } + + // No amount given: show an amount picker right in the chat. One tap is + // both amount entry and confirmation; only the requester's taps count. + // (Telegram has no requester-only visible group messages, so buttons beat + // a public "enter amount" prompt or a DM detour.) + NewMessage(m, WithDuration(0, bot)) + req := &CashuMintRequest{ + Base: storage.New(storage.ID(fmt.Sprintf("cashu-mint-req:%d:%s", m.Sender.ID, RandStringRunes(8)))), + TelegramID: m.Sender.ID, + Public: !m.Private(), + RecipientID: recipientID, + RecipientName: recipientName, + } + if err := req.Set(req, bot.Bunt); err != nil { + return ctx, err + } + + menu := &tb.ReplyMarkup{ResizeKeyboard: true} + var row []tb.Btn + for _, amt := range []int64{21, 100, 210, 500, 1000} { + row = append(row, menu.Data(fmt.Sprintf("%d", amt), "cashu_tip_amount", req.ID, strconv.FormatInt(amt, 10))) + } + cancelRow := menu.Row(menu.Data("🚫 Cancel", "cancel_cashu_mint", req.ID)) + menu.Inline(menu.Row(row...), cancelRow) + + pickerMsg := bot.trySendMessage(m.Chat, fmt.Sprintf("🄜 %s, pick a tip amount (sat):", GetUserStrMd(m.Sender)), menu) + if pickerMsg != nil { + // Unanswered picker self-cancels; re-check state first, the message is + // edited into the live share once an amount is picked. + reqID := req.ID + time.AfterFunc(5*time.Minute, func() { + check := &CashuMintRequest{Base: storage.New(storage.ID(reqID))} + fn, err := check.Get(check, bot.Bunt) + if err != nil { + return + } + if r := fn.(*CashuMintRequest); r.Active { + _ = r.Inactivate(r, bot.Bunt) + bot.tryDeleteMessage(pickerMsg) + } + }) + } + return ctx, nil +} + +// cashuTipAmountHandler runs when the requester taps an amount on the picker. +// The tap is the confirmation: the picker message becomes the Collect share. +func (bot *TipBot) cashuTipAmountHandler(ctx intercept.Context) (intercept.Context, error) { + c := ctx.Callback() + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + + // c.Data = "|" + parts := strings.Split(c.Data, "|") + if len(parts) != 2 { + return ctx, errors.Create(errors.InvalidSyntaxError) + } + amount, err := strconv.ParseInt(parts[1], 10, 64) + if err != nil || amount < 1 { + return ctx, errors.Create(errors.InvalidAmountError) + } + + req := &CashuMintRequest{Base: storage.New(storage.ID(parts[0]))} + fn, err := req.Get(req, bot.Bunt) + if err != nil { + bot.tryEditMessage(c.Message, "🄜 This tip request expired. Send the command again.", &tb.ReplyMarkup{}) + return ctx, err + } + req = fn.(*CashuMintRequest) + if req.TelegramID != user.Telegram.ID { + ctx.Context = context.WithValue(ctx, "callback_response", "🄜 Only the requester can pick the amount.") + return ctx, errors.Create(errors.UnknownError) + } + if !req.Active { + ctx.Context = context.WithValue(ctx, "callback_response", "🄜 Already being processed.") + return ctx, errors.Create(errors.NotActiveError) + } + + // Balance and pending-cap checks, deferred to tap time. + if balance, err := bot.GetUserBalanceCached(user); err == nil && balance < amount { + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "balanceTooLowMessage")) + return ctx, errors.Create(errors.BalanceToLowError) + } + if pending, err := bot.countPendingCashuTokens(user.Telegram.ID); err == nil && pending >= maxPendingCashuTokens { + ctx.Context = context.WithValue(ctx, "callback_response", fmt.Sprintf("🄜 You have %d pending cashu tokens (max %d).", pending, maxPendingCashuTokens)) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + _ = req.Inactivate(req, bot.Bunt) + + return bot.postCashuShare(ctx, user, c.Message, amount, req.Memo, req.RecipientID, req.RecipientName) +} + +// 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 chat (group drop); chat is where the +// confirmation (and later the Collect message) is posted. +func (bot *TipBot) requestCashuMint(ctx intercept.Context, amount int64, memo string, public bool, chat *tb.Chat, recipientID int64, recipientName string) (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 { + 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")) + } + + // 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) + } + + // 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, + Public: public, + RecipientID: recipientID, + RecipientName: recipientName, + } + if err := req.Set(req, bot.Bunt); err != nil { + log.Errorf("[cashu mint] could not persist mint request: %s", err.Error()) + return ctx, err + } + + var confirmText string + if public { + 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())) + } + if len(memo) > 0 { + confirmText += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(memo)) + } + // The confirmation lives in the chat the command came from; only the + // requester can press its buttons. + confirmMsg := bot.trySendMessage(chat, confirmText, bot.makeCashuMintConfirmKeyboard(req.ID)) + if public { + // Keep group chats tidy: drop the command message right away, and + // self-cancel an unanswered confirmation after 5 minutes. The timer must + // re-check state: on Confirm this same message is edited into the live + // Collect share and must NOT be deleted. + NewMessage(m, WithDuration(0, bot)) + if confirmMsg != nil { + reqID := req.ID + time.AfterFunc(5*time.Minute, func() { + check := &CashuMintRequest{Base: storage.New(storage.ID(reqID))} + fn, err := check.Get(check, bot.Bunt) + if err != nil { + return + } + if r := fn.(*CashuMintRequest); r.Active { + _ = r.Inactivate(r, bot.Bunt) + bot.tryDeleteMessage(confirmMsg) + } + }) + } + } + 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. Others get a toast so the + // button doesn't appear dead. + if req.TelegramID != user.Telegram.ID { + ctx.Context = context.WithValue(ctx, "callback_response", "🄜 Only the requester can confirm this.") + return ctx, errors.Create(errors.UnknownError) + } + if !req.Active { + ctx.Context = context.WithValue(ctx, "callback_response", "🄜 Already being processed.") + 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) + } + + if req.Public { + return bot.postCashuShare(ctx, user, c.Message, req.Amount, req.Memo, req.RecipientID, req.RecipientName) + } + + return bot.executeCashuMint(ctx, user, c.Sender, c.Message, req.Amount, req.Memo) +} + +// postCashuShare turns msg into a collectable share message. No minting yet — +// the sender's wallet is only charged when someone actually collects +// (acceptInlineCashuHandler), backed by the same InlineCashu record the +// inline flow uses. +func (bot *TipBot) postCashuShare(ctx intercept.Context, user *lnbits.User, msg *tb.Message, amount int64, memo string, recipientID int64, recipientName string) (intercept.Context, error) { + id := fmt.Sprintf("cashu:%s:%d", RandStringRunes(10), amount) + shareMsg := fmt.Sprintf(Translate(ctx, "cashuSendMessage"), GetUserStrMd(user.Telegram), amount) + if recipientID != 0 { + shareMsg += fmt.Sprintf("\nFor %s only.", str.MarkdownEscape(recipientName)) + } + if len(memo) > 0 { + shareMsg += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(memo)) + } + ic := &InlineCashu{ + Base: storage.New(storage.ID(id)), + Message: shareMsg, + Amount: amount, + From: user, + Memo: memo, + RecipientID: recipientID, + RecipientName: recipientName, + 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(msg, shareMsg, bot.makeCashuKeyboard(ctx, id)) + log.Infof("[cashu share] %s shared %d sat collectable in chat %d", GetUserStr(user.Telegram), amount, msg.Chat.ID) + return ctx, nil +} + +// 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 { + ctx.Context = context.WithValue(ctx, "callback_response", "🄜 Only the requester can cancel this.") + return ctx, errors.Create(errors.UnknownError) + } + _ = req.Inactivate(req, bot.Bunt) + bot.tryEditMessage(c.Message, Translate(ctx, "cashuSendCancelledMessage"), &tb.ReplyMarkup{}) + NewMessage(c.Message, WithDuration(10*time.Minute, bot)) + return ctx, nil +} + +// executeCashuMint performs the actual quote -> pay -> mint flow after the +// 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)) + + // 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)) + + // 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 + } + + log.Infof("[cashu mint] Paid mint invoice for quote %s", quote.Quote) + + // 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) + if err != nil { + // 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) + + // 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: replace the status message with QR + token + bot.tryDeleteMessage(statusMsg) + bot.sendCashuQr(sender, tokenStr, caption) + bot.trySendMessage(sender, "`"+tokenStr+"`") + + log.Infof("[cashu mint] %s minted %d sat cashu token", GetUserStr(sender), amount) + return ctx, nil +} + +// cashuClaimAliasHandler handles top-level /claim and /redeem +// (observed users trying these naturally). Same as /cashu receive. +func (bot *TipBot) cashuClaimAliasHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if !internal.Configuration.Cashu.Enabled { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuDisabledMessage")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + user := LoadUser(ctx) + if user.Wallet.ID == "" { + return ctx, errors.Create(errors.UserNoWalletError) + } + args := strings.Fields(m.Text) + if len(args) < 2 { + bot.trySendMessage(m.Sender, Translate(ctx, "cashuHelpText")) + return ctx, errors.Create(errors.InvalidSyntaxError) + } + return bot.redeemCashuToken(ctx, args[1], user, m.Sender) +} + +// 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. + // Normalize: trailing slashes and scheme/host case must not cause a + // same-mint token to be rejected. + tokenMintURL := token.Token[0].Mint + 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") + } + + 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-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] 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 + } + + // 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 { + 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) + } + } + + resp, err := bot.CashuClient.Melt(mq.Quote, proofs) + if err != nil { + return 0, err + } + if resp.State == "PENDING" || resp.State == "" { + // The mint's outbound Lightning payment is in flight — poll until it + // settles instead of treating PENDING as failure. + paid, werr := bot.CashuClient.WaitMeltPaid(mq.Quote, 45*time.Second) + if !paid { + return 0, fmt.Errorf("melt not settled (state=%s, err=%v)", resp.State, werr) + } + } else if resp.State != "PAID" { + return 0, fmt.Errorf("melt state: %s", resp.State) + } + return net, nil +} + +// 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 list] %s", err.Error()) + return ctx, err + } + + // Summary first, then each unclaimed token as its OWN message: several + // full token strings in one message blow Telegram's 4096-char limit and + // the send fails silently, making /cashu list appear dead. + var sb strings.Builder + sb.WriteString("🄜 *Your cashu tokens*\n") + var unclaimed []*CashuToken + 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 (token below)", c.Amount)) + unclaimed = append(unclaimed, c) + 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++ + } + } + } + + if shown == 0 { + bot.trySendMessage(m.Sender, "🄜 You have no active cashu tokens.") + return ctx, nil + } + bot.trySendMessage(m.Sender, sb.String()) + for _, c := range unclaimed { + // QR first (scannable by any cashu wallet), token text after (copyable). + bot.sendCashuQr(m.Sender, c.Token, fmt.Sprintf("🄜 %d sat", c.Amount)) + bot.trySendMessage(m.Sender, fmt.Sprintf("🄜 *%d sat*:\n`%s`", c.Amount, c.Token)) + } + 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, 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" { + // 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.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 + c.State = cashuStateUnclaimed + _ = bot.setCashuToken(c) + bot.trySendMessage(m.Sender, fmt.Sprintf("🄜 Recovered *%d sat*:\n`%s`", c.Amount, tokenStr)) + recovered++ + } + + 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 +} + +// cashuQrSize returns a pixel size that keeps QR modules readable. Measured: +// gozxing needs >=1536px for a ~2.6KB token (~171 modules) even on a clean +// PNG; 512px is only safe for small tokens. +func cashuQrSize(token string) int { + switch l := len(token); { + case l < 800: + return 512 + case l < 1600: + return 1024 + default: + return 1792 + } +} + +// sendCashuQr sends a token QR. Small tokens go as a photo (inline preview). +// Big tokens go as a PNG document: Telegram recompresses photos on send +// (downscale + JPEG), which measurably destroys dense QRs, so only a document +// round-trips scannable. +func (bot *TipBot) sendCashuQr(dest tb.Recipient, tokenStr string, caption string) { + size := cashuQrSize(tokenStr) + qr, err := qrcode.Encode(tokenStr, qrcode.Low, size) + if err != nil { + log.Errorf("[cashu] QR code generation failed: %s", err.Error()) + return + } + if size <= 512 { + bot.trySendMessage(dest, &tb.Photo{ + File: tb.FromReader(bytes.NewReader(qr)), + Caption: caption, + }) + return + } + bot.trySendMessage(dest, &tb.Document{ + File: tb.FromReader(bytes.NewReader(qr)), + FileName: "cashu-token.png", + MIME: "image/png", + Caption: caption, + }) +} + +// 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 { + return s + } + return s[:maxLen] + "..." +} 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) + } +} diff --git a/internal/telegram/cashu_inline.go b/internal/telegram/cashu_inline.go new file mode 100644 index 00000000..a135a8c3 --- /dev/null +++ b/internal/telegram/cashu_inline.go @@ -0,0 +1,356 @@ +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/str" + "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"` + // RecipientID != 0 locks collection to that user (/cashutip as a reply). + RecipientID int64 `json:"inline_cashu_recipient_id"` + RecipientName string `json:"inline_cashu_recipient_name"` + 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 + } + + // 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). + + // 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 { + // User-controlled text rendered as Markdown in a public chat — escape it. + inlineMessage += fmt.Sprintf("\n_Memo: %s_", str.MarkdownEscape(memo)) + } + + inlineCashu := &InlineCashu{ + Base: storage.New(storage.ID(id)), + Message: inlineMessage, + Amount: amount, + From: fromUser, + Token: "", // minted on claim + 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.SetParseMode(tb.ModeMarkdown) + 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) + } + + // Targeted tip: only the intended recipient may collect. + if inlineCashu.RecipientID != 0 && to.Telegram.ID != inlineCashu.RecipientID { + ctx.Context = context.WithValue(ctx, "callback_response", fmt.Sprintf("🄜 This tip is for %s.", inlineCashu.RecipientName)) + return ctx, errors.Create(errors.UnknownError) + } + + // Immediate feedback: strip the buttons and show progress. The whole flow + // (mint quote -> pay -> settle poll -> melt poll) can take 10-45s, and a + // silent button gets pressed repeatedly. + bot.tryEditMessage(c.Message, fmt.Sprintf("ā³ %s is collecting *%d sat*...", GetUserStrMd(to.Telegram), inlineCashu.Amount), &tb.ReplyMarkup{}) + // restoreShare puts the collectable message back if anything below fails. + restoreShare := func() { + bot.tryEditMessage(c.Message, inlineCashu.Message, bot.makeCashuKeyboard(ctx, inlineCashu.ID)) + } + + // 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 + } + + // 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 + } + // 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. + // closeShare retires the collectable after the sender's money is already + // committed: leaving it collectable would let a second click mint again. + closeShare := func() { + inlineCashu.Active = false + _ = inlineCashu.Set(inlineCashu, bot.Bunt) + bot.tryEditMessage(c.Message, "🄜 This share was closed — the creator's sats are safe in their wallet.", &tb.ReplyMarkup{}) + NewMessage(c.Message, WithDuration(10*time.Minute, bot)) + } + + 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()) + restoreShare() + 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 + restoreShare() + ctx.Context = context.WithValue(ctx, "callback_response", Translate(ctx, "balanceTooLowMessage")) + return ctx, err + } + 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) + closeShare() + bot.trySendMessage(from.Telegram, "🄜 Your shared cashu payment hasn't settled at the mint yet. It will be recovered to your wallet automatically.") + 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 { + // 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()) + closeShare() + bot.trySendMessage(from.Telegram, "🄜 Your shared cashu couldn't be minted yet. It will be recovered to your wallet automatically.") + 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 { + log.Errorf("[cashu claim] deserialize freshly minted token failed: %s", err.Error()) + closeShare() + return ctx, err + } + + proofs := token.Token[0].Proofs + totalAmount := token.Amount() + + // Deliver to the claimer by melting the proofs onto their wallet. + netAmount, err := bot.meltProofsToWallet(to, proofs, totalAmount) + if err != nil { + // 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()) + closeShare() + bot.trySendMessage(from.Telegram, "🄜 Your shared 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 + 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; the resolved state cleans itself up in groups. + claimedMessage := fmt.Sprintf(Translate(ctx, "cashuSendClaimedMessage"), totalAmount, GetUserStrMd(to.Telegram)) + bot.tryEditMessage(c.Message, claimedMessage, &tb.ReplyMarkup{}) + NewMessage(c.Message, WithDuration(10*time.Minute, bot)) + + // 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) + } + + // 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 + inlineCashu.Canceled = true + inlineCashu.Set(inlineCashu, bot.Bunt) + + bot.tryEditMessage(c.Message, Translate(ctx, "cashuSendCancelledMessage"), &tb.ReplyMarkup{}) + NewMessage(c.Message, WithDuration(10*time.Minute, bot)) + log.Infof("[cashu cancel] %s cancelled cashu token %s", GetUserStr(user.Telegram), inlineCashu.ID) + return ctx, nil +} diff --git a/internal/telegram/cashu_storage.go b/internal/telegram/cashu_storage.go new file mode 100644 index 00000000..0c96be68 --- /dev/null +++ b/internal/telegram/cashu_storage.go @@ -0,0 +1,130 @@ +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 + + // 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. +// 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 +} + +// 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) { + 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/internal/telegram/files.go b/internal/telegram/files.go index bcbadd30..9facf3f8 100644 --- a/internal/telegram/files.go +++ b/internal/telegram/files.go @@ -31,5 +31,13 @@ func (bot *TipBot) fileHandler(ctx intercept.Context) (intercept.Context, error) return c(ctx) } + + // No state waiting for a file: an image document may be a QR code + // (large cashu token QRs only survive Telegram uncompressed, as files). + // NOTE: tb.OnDocument is registered exactly once, on this handler — + // telebot keeps one handler per endpoint, last registration wins. + if m.Document != nil { + return bot.documentHandler(ctx) + } return ctx, errors.Create(errors.NoFileFoundError) } diff --git a/internal/telegram/handler.go b/internal/telegram/handler.go index 8096f6cf..95832f83 100644 --- a/internal/telegram/handler.go +++ b/internal/telegram/handler.go @@ -404,6 +404,55 @@ func (bot TipBot) getHandler() []InterceptionWrapper { }, }, }, + // cashu ecash + { + Endpoints: []interface{}{"/cashutip"}, + Handler: bot.cashuTipHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/claim", "/redeem"}, + Handler: bot.cashuClaimAliasHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, + bot.logMessageInterceptor, + bot.requireUserInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + Endpoints: []interface{}{"/cashu"}, + Handler: bot.cashuHandler, + Interceptor: &Interceptor{ + Before: []intercept.Func{ + // group chats allowed: bare "/cashu " shares into the + // chat; the handler itself keeps subcommands DM-only + 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, @@ -634,6 +683,7 @@ func (bot TipBot) getHandler() []InterceptionWrapper { Before: []intercept.Func{ bot.requirePrivateChatInterceptor, + bot.localizerInterceptor, // documentHandler QR path uses Translate bot.logMessageInterceptor, bot.loadUserInterceptor}}, }, @@ -672,6 +722,54 @@ func (bot TipBot) getHandler() []InterceptionWrapper { Endpoints: []interface{}{tb.OnInlineResult}, Handler: bot.anyChosenInlineHandler, }, + { + Endpoints: []interface{}{&btnCashuTipAmount}, + Handler: bot.cashuTipAmountHandler, + Interceptor: &Interceptor{ + + Before: []intercept.Func{ + bot.localizerInterceptor, + bot.requireUserInterceptor, + bot.answerCallbackInterceptor, + bot.lockInterceptor, + }, + OnDefer: []intercept.Func{ + bot.unlockInterceptor, + }, + }, + }, + { + 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, @@ -834,6 +932,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/internal/telegram/nostr.go b/internal/telegram/nostr.go index 31649a62..2bd2a53c 100644 --- a/internal/telegram/nostr.go +++ b/internal/telegram/nostr.go @@ -64,8 +64,20 @@ func (bot *TipBot) publishNostrEvent(ev nostr.Event, relays []string) { ev.Sign(pk) log.Debugf("[NOSTR] 🟣 publishing nostr event %s", ev.ID) - // more relays - relays = append(relays, "wss://relay.nostr.ch", "wss://eden.nostr.land", "wss://nostr.btcmp.com", "wss://nostr.relayer.se", "wss://relay.current.fyi", "wss://nos.lol", "wss://nostr.mom", "wss://relay.nostr.info", "wss://nostr.zebedee.cloud", "wss://nostr-pub.wellorder.net", "wss://relay.snort.social/", "wss://relay.damus.io/", "wss://nostr.oxtr.dev/", "wss://nostr.fmt.wiz.biz/", "wss://brb.io") + // Default relays the zap receipt is published to, in addition to the ones + // from the zap request. Large, reliably-up, write-accepting relays only — + // profile-only or paid-write relays don't help kind-9735 receipts. + relays = append(relays, + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + "wss://relay.nostr.band", + "wss://relay.snort.social", + "wss://nostr.mom", + "wss://nostr.oxtr.dev", + "wss://offchain.pub", + "wss://nostr.bitcoiner.social", + ) // remove trailing / relays = cleanUrls(relays) diff --git a/internal/telegram/photo.go b/internal/telegram/photo.go index d10eb6c6..59df23a2 100644 --- a/internal/telegram/photo.go +++ b/internal/telegram/photo.go @@ -6,8 +6,10 @@ import ( "fmt" "image" "image/jpeg" + _ "image/png" // registers the PNG decoder for image.Decode (QR files) "strings" + "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" "github.com/LightningTipBot/LightningTipBot/internal/errors" @@ -25,9 +27,14 @@ import ( func TryRecognizeQrCode(img image.Image) (*gozxing.Result, error) { // check for qr code bmp, _ := gozxing.NewBinaryBitmapFromImage(img) - // decode image + // decode image; TRY_HARDER spends more CPU but survives the JPEG + // compression Telegram applies, which dense QRs (large cashu tokens) + // otherwise don't. qrReader := qrcode.NewQRCodeReader() - result, err := qrReader.Decode(bmp, nil) + hints := map[gozxing.DecodeHintType]interface{}{ + gozxing.DecodeHintType_TRY_HARDER: true, + } + result, err := qrReader.Decode(bmp, hints) if err != nil { return nil, err } @@ -37,9 +44,20 @@ func TryRecognizeQrCode(img image.Image) (*gozxing.Result, error) { // invoke payment confirmation ctx return result, nil } + if isCashuToken(result.String()) { + return result, nil + } return nil, fmt.Errorf("no codes found") } +// isCashuToken reports whether s looks like a cashu token. The prefix check is +// case-insensitive but the token itself is returned verbatim: base64 payloads +// are case-sensitive. +func isCashuToken(s string) bool { + s = strings.ToLower(strings.TrimSpace(s)) + return strings.HasPrefix(s, "cashua") || strings.HasPrefix(s, "cashub") +} + // photoHandler is the handler function for every photo from a private chat that the bot receives func (bot *TipBot) photoHandler(ctx intercept.Context) (intercept.Context, error) { m := ctx.Message() @@ -68,6 +86,46 @@ func (bot *TipBot) photoHandler(ctx intercept.Context) (intercept.Context, error log.Errorf("[photoHandler] image.Decode error: %v\n", err.Error()) return ctx, err } + return bot.routeQrImage(ctx, img) +} + +// documentHandler handles image files sent as documents. Documents skip +// Telegram's photo compression, so dense QR codes (large cashu tokens) that +// are unreadable as photos decode fine as files. +func (bot *TipBot) documentHandler(ctx intercept.Context) (intercept.Context, error) { + m := ctx.Message() + if m.Document != nil { + log.Infof("[documentHandler] received document from %s: mime=%q filename=%q size=%d", GetUserStr(m.Sender), m.Document.MIME, m.Document.FileName, m.Document.FileSize) + } else { + log.Infof("[documentHandler] fired without document from %s", GetUserStr(m.Sender)) + } + if m.Chat.Type != tb.ChatPrivate { + return ctx, errors.Create(errors.NoPrivateChatError) + } + if m.Document == nil || !strings.HasPrefix(m.Document.MIME, "image/") { + // not an image file — none of our business + return ctx, nil + } + + reader, err := bot.Telegram.File(m.Document.MediaFile()) + if err != nil { + log.Errorf("[documentHandler] getfile error: %v\n", err.Error()) + return ctx, err + } + // image.Decode picks the right decoder (png/jpeg) from the data + img, _, err := image.Decode(reader) + if err != nil { + log.Errorf("[documentHandler] image.Decode error: %v\n", err.Error()) + return ctx, err + } + return bot.routeQrImage(ctx, img) +} + +// routeQrImage decodes a QR from img and dispatches its payload (invoice, +// LNURL, or cashu token). +func (bot *TipBot) routeQrImage(ctx intercept.Context, img image.Image) (intercept.Context, error) { + m := ctx.Message() + user := LoadUser(ctx) data, err := TryRecognizeQrCode(img) if err != nil { log.Errorf("[photoHandler] tryRecognizeQrCodes error: %v\n", err.Error()) @@ -75,6 +133,13 @@ func (bot *TipBot) photoHandler(ctx intercept.Context) (intercept.Context, error return ctx, err } + // Cashu token QR: redeem straight to the user's wallet. Checked before the + // generic "recognized" echo so the (long) token isn't parroted back. + // Lightning invoice/lnurl QR handling below is untouched. + if internal.Configuration.Cashu.Enabled && isCashuToken(data.String()) { + return bot.redeemCashuToken(ctx, strings.TrimSpace(data.String()), user, m.Sender) + } + bot.trySendMessage(m.Sender, fmt.Sprintf(Translate(ctx, "photoQrRecognizedMessage"), data.String())) // invoke payment handler if lightning.IsInvoice(data.String()) { diff --git a/internal/telegram/text.go b/internal/telegram/text.go index 86f1e080..a5030fb8 100644 --- a/internal/telegram/text.go +++ b/internal/telegram/text.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/LightningTipBot/LightningTipBot/internal" "github.com/LightningTipBot/LightningTipBot/internal/errors" "github.com/LightningTipBot/LightningTipBot/internal/telegram/intercept" @@ -46,6 +47,11 @@ func (bot *TipBot) anyTextHandler(ctx intercept.Context) (intercept.Context, err m.Text = "/lnurl " + anyText return bot.lnurlHandler(ctx) } + // pasted cashu token: redeem straight to the user's wallet. Uses the + // original (case-sensitive) text, not the lowercased copy. + if internal.Configuration.Cashu.Enabled && isCashuToken(m.Text) { + return bot.redeemCashuToken(ctx, strings.TrimSpace(m.Text), user, m.Sender) + } if c := stateCallbackMessage[user.StateKey]; c != nil { return c(ctx) //ResetUserState(user, bot) diff --git a/translations/en.toml b/translations/en.toml index d1f2ab0c..675cf704 100644 --- a/translations/en.toml +++ b/translations/en.toml @@ -231,7 +231,7 @@ donateHelpText = """šŸ“– Oops, that didn't work. %s # PHOTO -photoQrNotRecognizedMessage = """🚫 Could not recognize a Lightning invoice or a LNURL. Try to center the QR code, crop the photo, or zoom in.""" +photoQrNotRecognizedMessage = """🚫 Could not recognize a Lightning invoice, LNURL, or Cashu token. Try to center the QR code, crop the photo, or zoom in. For big Cashu tokens, copy-paste the token text instead — large QR codes often don't survive photo compression.""" photoQrRecognizedMessage = """āœ… QR code: `%s`""" @@ -409,4 +409,36 @@ 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 [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 +`/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.""" + +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* — Collected āœ… by %s""" +cashuSendCancelledMessage = """🄜 Cashu token cancelled.""" +cashuAlreadyClaimedMessage = """This token has already been claimed.""" +cashuClaimButtonMessage = """🄜 Collect""" +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