package lnurl import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "sync" "time" ) // Withdraw implements the LNURL-withdraw exchange. // // The flow, from the player's side, is: tap Cash out, scan the code, done. // Underneath: // // 1. The arcade mints a single-use token (k1) bound to one account and one // amount, and shows it as an LNURL. // 2. The player's wallet fetches the URL and reads the terms. // 3. The wallet generates an invoice for the amount and calls back with it. // 4. The arcade pays that invoice. // // Step 4 is why the token must be strictly single-use. It is a bearer // instrument: anyone holding it can direct a payment to an invoice of their // choosing. So it is random, short-lived, consumed on first use, and bound to // the amount it was issued for. var ( ErrUnknownToken = errors.New("lnurl: token not recognised or already used") ErrExpired = errors.New("lnurl: token has expired") ErrAmountRange = errors.New("lnurl: invoice amount outside the permitted range") ) // TokenTTL is how long a withdraw code stays valid. Short, because it is a // bearer instrument: a code left on screen at a party should stop working // well before anyone wanders off with a photograph of it. const TokenTTL = 5 * time.Minute // Token is one outstanding withdraw authorisation. type Token struct { K1 string AccountID int64 AmountMsat int64 Expires time.Time } // WithdrawRequest is the JSON a wallet reads after scanning. // Field names are fixed by the LNURL specification. type WithdrawRequest struct { Tag string `json:"tag"` Callback string `json:"callback"` K1 string `json:"k1"` DefaultDescription string `json:"defaultDescription"` MinWithdrawable int64 `json:"minWithdrawable"` MaxWithdrawable int64 `json:"maxWithdrawable"` } // Response is the LNURL result envelope. type Response struct { Status string `json:"status"` Reason string `json:"reason,omitempty"` } func OK() Response { return Response{Status: "OK"} } func Fail(why string) Response { return Response{Status: "ERROR", Reason: why} } // Service issues and redeems withdraw tokens. // // Tokens live in memory rather than the database. They are short-lived and // worthless once used, and keeping them out of durable storage means a lost // instance cannot leave a valid bearer token lying around to be replayed // later. type Service struct { // BaseURL is how a wallet reaches this instance, e.g. http://10.0.0.5:8080. BaseURL string mu sync.Mutex tokens map[string]Token } func NewService(baseURL string) *Service { return &Service{BaseURL: baseURL, tokens: make(map[string]Token)} } // Issue mints a withdraw code for an exact amount. func (s *Service) Issue(accountID, amountMsat int64) (lnurl string, k1 string, err error) { if amountMsat <= 0 { return "", "", fmt.Errorf("%w: amount must be positive", ErrAmountRange) } var raw [32]byte if _, err := rand.Read(raw[:]); err != nil { panic("lnurl: system randomness unavailable: " + err.Error()) } k1 = hex.EncodeToString(raw[:]) s.mu.Lock() s.sweepLocked() s.tokens[k1] = Token{ K1: k1, AccountID: accountID, AmountMsat: amountMsat, Expires: time.Now().Add(TokenTTL), } s.mu.Unlock() url := fmt.Sprintf("%s/lnurl/withdraw?k1=%s", s.BaseURL, k1) encoded, err := EncodeURL(url) if err != nil { return "", "", err } return encoded, k1, nil } // Describe returns the terms a wallet reads after scanning. // // The minimum and maximum are set to the same value, which is what tells the // wallet to withdraw exactly this amount rather than prompting the player to // choose one. Choosing an amount is the step this whole mechanism exists to // remove. func (s *Service) Describe(k1 string) (*WithdrawRequest, error) { t, err := s.lookup(k1) if err != nil { return nil, err } return &WithdrawRequest{ Tag: "withdrawRequest", Callback: s.BaseURL + "/lnurl/withdraw/callback", K1: t.K1, DefaultDescription: "Quantum Arcade cash out", MinWithdrawable: t.AmountMsat, MaxWithdrawable: t.AmountMsat, }, nil } // Redeem consumes a token and returns what it authorises. // // The token is deleted before the payment is attempted. A token that is // consumed and then fails to pay costs the player a retry; a token that is // left valid after a payment succeeds costs the house the whole balance // again. The asymmetry decides the ordering. func (s *Service) Redeem(ctx context.Context, k1 string) (Token, error) { s.mu.Lock() t, ok := s.tokens[k1] if ok { delete(s.tokens, k1) } s.mu.Unlock() if !ok { return Token{}, ErrUnknownToken } if time.Now().After(t.Expires) { return Token{}, ErrExpired } return t, nil } // Restore puts a token back after a failed payment, so a routing failure does // not silently swallow the player's cash-out. func (s *Service) Restore(t Token) { if time.Now().After(t.Expires) { return // no point restoring something already expired } s.mu.Lock() s.tokens[t.K1] = t s.mu.Unlock() } // ForceStore inserts a token regardless of its expiry. It exists so tests can // construct an aged token; production code uses Restore, which refuses to // resurrect something already expired. func (s *Service) ForceStore(t Token) { s.mu.Lock() s.tokens[t.K1] = t s.mu.Unlock() } func (s *Service) lookup(k1 string) (Token, error) { s.mu.Lock() defer s.mu.Unlock() t, ok := s.tokens[k1] if !ok { return Token{}, ErrUnknownToken } if time.Now().After(t.Expires) { delete(s.tokens, k1) return Token{}, ErrExpired } return t, nil } // sweepLocked drops expired tokens. Called under the mutex. // // It discards them rather than reporting them, because Issue does not know how // to refund. Callers that must refund use Expired instead. func (s *Service) sweepLocked() { now := time.Now() for k, t := range s.tokens { if now.After(t.Expires) { delete(s.tokens, k) } } } // Expired removes and returns every token past its lifetime. // // The funds behind a code are debited when it is issued, so a code that is // never scanned leaves a player short. The caller refunds what this returns — // which is why the tokens are handed back rather than quietly dropped. func (s *Service) Expired() []Token { now := time.Now() var out []Token s.mu.Lock() for k, t := range s.tokens { if now.After(t.Expires) { out = append(out, t) delete(s.tokens, k) } } s.mu.Unlock() return out } // Outstanding reports how many tokens are live, for tests and the admin view. func (s *Service) Outstanding() int { s.mu.Lock() defer s.mu.Unlock() return len(s.tokens) }