feat(ux): scan-to-cash-out, first-run walkthrough, plain language
Cashing out was the least approachable thing here: open your wallet, create an invoice for exactly the right amount, copy it, come back, paste it. That is the step people abandon, leaving sats behind. LNURL-withdraw replaces it with a scan. The arcade shows a code, the wallet pulls the funds, and the player never handles an invoice or types an amount. The paste path is kept for wallets without LNURL support, but folded away. The withdraw token is a bearer instrument, so it is random, single-use, bound to one account and one amount, and expires in five minutes. Sixteen goroutines racing one code yield exactly one payment. Funds are debited when the code is issued — otherwise a player could cash out and bet the same sats before the wallet claimed them — and a sweep refunds any code that is never scanned. bech32 is verified against the BIP-173 vectors, including the invalid ones. Getting this wrong produces codes that silently fail to scan with no useful error for the player. Adds a three-card first-run walkthrough, an explanation of what a multiplier target means, and a one-time confirmation before a player's first real-money action — the interface is deliberately frictionless, and that is the one place a moment of friction is worth it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -229,6 +229,44 @@ func (s *Service) RequestWithdrawal(ctx context.Context, accountID int64, bolt11
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// PayHeld sends a payment for funds the caller has already debited.
|
||||
//
|
||||
// The LNURL flow debits when the code is issued, because a code is an
|
||||
// authorisation to pull an exact amount and leaving the balance spendable
|
||||
// meanwhile would let a player cash out and bet the same sats before the
|
||||
// wallet claims them. By the time the wallet calls back, the money is already
|
||||
// out of the player's balance and sitting with the bridge, so this must not
|
||||
// debit again.
|
||||
//
|
||||
// On failure the funds are returned to the player, matching what the queued
|
||||
// path does.
|
||||
func (s *Service) PayHeld(ctx context.Context, accountID int64, bolt11 string, amountMsat int64) (Payment, error) {
|
||||
maxFee := amountMsat * s.limits.MaxFeeRateBP / 10000
|
||||
|
||||
payment, err := s.node.PayInvoice(ctx, bolt11, maxFee)
|
||||
if err != nil {
|
||||
if _, rerr := s.ledger.Deposit(ctx, accountID, amountMsat); rerr != nil {
|
||||
return Payment{}, fmt.Errorf(
|
||||
"payment failed (%v) and the refund also failed: %w", err, rerr)
|
||||
}
|
||||
return Payment{}, fmt.Errorf("%w: %v", ErrPaymentFailed, err)
|
||||
}
|
||||
|
||||
// Record it alongside the queued withdrawals so the operator sees one
|
||||
// history rather than two.
|
||||
if _, err := s.pool.Exec(ctx,
|
||||
`INSERT INTO lightning_withdrawals
|
||||
(account_id, bolt11, amount_msat, status, payment_hash, fee_msat, resolved_at)
|
||||
VALUES ($1, $2, $3, 'paid', $4, $5, now())`,
|
||||
accountID, bolt11, amountMsat, payment.PaymentHash, payment.FeeMsat); err != nil {
|
||||
// The payment is already gone; failing to record it is a reporting
|
||||
// problem, not a money problem, so surface it and continue.
|
||||
fmt.Printf("lightning: paid %s but could not record it: %v\n",
|
||||
payment.PaymentHash, err)
|
||||
}
|
||||
return payment, nil
|
||||
}
|
||||
|
||||
// ProcessWithdrawals pays out queued withdrawals. Returns how many were paid.
|
||||
func (s *Service) ProcessWithdrawals(ctx context.Context, limit int) (int, error) {
|
||||
rows, err := s.pool.Query(ctx,
|
||||
|
||||
Reference in New Issue
Block a user