package main import ( "encoding/json" "log" "net/http" "github.com/drjones/quantum-arcade/pkg/lnurl" ) // LNURL-withdraw endpoints. // // Two of these are called by the player's wallet, not by the arcade's own // client, so they follow the LNURL specification's shapes rather than this // project's conventions: a bare JSON object with a status field, and no // bearer token, because a wallet has none. The single-use k1 is the // authorisation. func (s *server) routesLNURL(mux *http.ServeMux) { // Called by the arcade's own client, authenticated as normal. mux.HandleFunc("POST /api/withdraw/code", s.handleWithdrawCode) // Called by the player's wallet after scanning. No session exists here. mux.HandleFunc("GET /lnurl/withdraw", s.handleLNURLTerms) mux.HandleFunc("GET /lnurl/withdraw/callback", s.handleLNURLCallback) } // handleWithdrawCode issues a scannable cash-out code. // // The funds are debited when the code is issued, not when it is redeemed. A // code is an authorisation to pull an exact amount, and leaving the balance // spendable while a code is outstanding would let a player cash out and then // bet the same sats before the wallet claims them. func (s *server) handleWithdrawCode(w http.ResponseWriter, r *http.Request) { if s.ln == nil || s.lnurl == nil { writeErr(w, http.StatusServiceUnavailable, "lightning not configured") return } accountID, _, ok := s.account(r) if !ok { writeErr(w, http.StatusUnauthorized, "sign in first") return } var req struct { AmountSat int64 `json:"amount_sats"` } body, err := readBody(r) if err != nil { writeErr(w, http.StatusBadRequest, "could not read request") return } if err := json.Unmarshal(body, &req); err != nil || req.AmountSat < 1 { writeErr(w, http.StatusBadRequest, "amount_sats required (>=1)") return } amountMsat := req.AmountSat * 1000 // Take the money now. If the code is never scanned, the sweep below // returns it. if _, err := s.ledger.Withdraw(r.Context(), accountID, amountMsat); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } code, k1, err := s.lnurl.Issue(accountID, amountMsat) if err != nil { // Could not issue: give the money straight back rather than holding it // against a code that does not exist. if _, rerr := s.ledger.Deposit(r.Context(), accountID, amountMsat); rerr != nil { log.Printf("lnurl: CRITICAL: debited %d msat from %d but could not "+ "issue a code or refund: %v / %v", amountMsat, accountID, err, rerr) } writeErr(w, http.StatusInternalServerError, err.Error()) return } bal, _ := s.ledger.Balance(r.Context(), accountID) writeJSON(w, http.StatusOK, map[string]any{ "lnurl": code, "k1": k1, "amount_sats": req.AmountSat, "balance_msat": bal, "expires_in": int(lnurl.TokenTTL.Seconds()), }) } // handleLNURLTerms is what the wallet fetches after scanning. func (s *server) handleLNURLTerms(w http.ResponseWriter, r *http.Request) { if s.lnurl == nil { writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured")) return } terms, err := s.lnurl.Describe(r.URL.Query().Get("k1")) if err != nil { // LNURL errors are 200s with a status field; a wallet shows the reason // to the player, whereas an HTTP error shows nothing useful. writeJSON(w, http.StatusOK, lnurl.Fail(err.Error())) return } writeJSON(w, http.StatusOK, terms) } // handleLNURLCallback is where the wallet delivers its invoice. func (s *server) handleLNURLCallback(w http.ResponseWriter, r *http.Request) { if s.ln == nil || s.lnurl == nil { writeJSON(w, http.StatusOK, lnurl.Fail("lightning not configured")) return } k1 := r.URL.Query().Get("k1") invoice := r.URL.Query().Get("pr") if k1 == "" || invoice == "" { writeJSON(w, http.StatusOK, lnurl.Fail("k1 and pr are required")) return } // Consume the token before paying. A token left valid after a successful // payment could be replayed for the same amount again. token, err := s.lnurl.Redeem(r.Context(), k1) if err != nil { writeJSON(w, http.StatusOK, lnurl.Fail(err.Error())) return } // The balance was already debited when the code was issued, so this queues // the payment against funds the arcade is holding rather than the player's // balance. Crediting first and withdrawing again would double-charge. if _, err := s.ln.PayHeld(r.Context(), token.AccountID, invoice, token.AmountMsat); err != nil { // Payment failed: hand the authorisation back so the player can retry // rather than losing the cash-out silently. s.lnurl.Restore(token) log.Printf("lnurl: payment for account %d failed: %v", token.AccountID, err) writeJSON(w, http.StatusOK, lnurl.Fail("payment failed; try scanning again")) return } writeJSON(w, http.StatusOK, lnurl.OK()) }