Skip to content

Commit

Permalink
client: persist generated invoices for tip receiving
Browse files Browse the repository at this point in the history
  • Loading branch information
miki authored and miki-totefu committed Jun 21, 2023
1 parent a5a69db commit d27253e
Show file tree
Hide file tree
Showing 11 changed files with 498 additions and 24 deletions.
3 changes: 3 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1123,5 +1123,8 @@ func (c *Client) Run(ctx context.Context) error {
// Reload cached RGCMs.
g.Go(func() error { return c.loadCachedRGCMs(gctx) })

// Restart tracking tip receiving.
g.Go(func() error { return c.restartTrackGeneratedTipInvoices(gctx) })

return g.Wait()
}
100 changes: 86 additions & 14 deletions client/client_payments.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,51 @@ func (c *Client) TipUser(uid UserID, dcrAmount float64, maxAttempts int32) error
return nil
}

// trackGeneratedTipInvoice tracks an invoice generated by the local client for
// a remote tip payment. This blocks until the invoice is paid or expires.
func (c *Client) trackGeneratedTipInvoice(ctx context.Context, uid clientintf.UserID, invoice string,
wantMAtoms int64) {

var err error
defer func() {
if err != nil && !errors.Is(err, context.Canceled) {
c.log.Errorf("Unable to handle callback for paid invoice: %v", err)
}
}()

// Wait until invoice is settled or expires.
receivedMAtoms, err := c.pc.TrackInvoice(ctx, invoice, wantMAtoms)
if errors.Is(err, clientintf.ErrInvoiceExpired) {
err = c.db.Update(ctx, func(tx clientdb.ReadWriteTx) error {
return c.db.MarkGeneratedTipInvoiceExpired(tx, uid, invoice)
})
return
}
if err != nil {
return
}

// Invoice settled. Update DB and notify UI.
ru, err := c.rul.byID(uid)
if err != nil {
return
}

dcrAmt := float64(receivedMAtoms) / 1e11
ru.log.Infof("Received %f DCR as tip", dcrAmt)
err = c.dbUpdate(func(tx clientdb.ReadWriteTx) error {
if err := c.db.RecordUserPayEvent(tx, uid, "tip", receivedMAtoms, 0); err != nil {
return err
}
return c.db.MarkGeneratedTipInvoiceReceived(tx, uid, invoice, receivedMAtoms)
})
if err != nil {
return
}

c.ntfns.notifyTipReceived(ru, receivedMAtoms)
}

func (c *Client) handleGetInvoice(ru *RemoteUser, getInvoice rpc.RMGetInvoice) error {

// Helper to reply with an error.
Expand All @@ -120,21 +165,9 @@ func (c *Client) handleGetInvoice(ru *RemoteUser, getInvoice rpc.RMGetInvoice) e
return err
}

cb := func(receivedMAtoms int64) {
dcrAmt := float64(receivedMAtoms) / 1e11
ru.log.Infof("Received %f DCR as tip", dcrAmt)
err := c.dbUpdate(func(tx clientdb.ReadWriteTx) error {
return c.db.RecordUserPayEvent(tx, ru.ID(), "tip", receivedMAtoms, 0)
})
if err != nil {
c.log.Warnf("Error while updating DB to store tip payment status: %v", err)
}
c.ntfns.notifyTipReceived(ru, receivedMAtoms)
}

amountMAtoms := int64(getInvoice.MilliAtoms)
dcrAmount := float64(amountMAtoms) / 1e11
inv, err := c.pc.GetInvoice(c.ctx, amountMAtoms, cb)
inv, err := c.pc.GetInvoice(c.ctx, amountMAtoms, nil)
if err != nil {
c.ntfns.notifyInvoiceGenFailed(ru, dcrAmount, err)
replyWithErr(rpc.ErrUnableToGenerateInvoice)
Expand All @@ -155,14 +188,23 @@ func (c *Client) handleGetInvoice(ru *RemoteUser, getInvoice rpc.RMGetInvoice) e
dcrAmount)
}

// Persist the generated invoice.
err = c.dbUpdate(func(tx clientdb.ReadWriteTx) error {
return c.db.StoreGeneratedTipInvoice(tx, ru.ID(), inv, amountMAtoms)
})
if err != nil {
return err
}

go c.trackGeneratedTipInvoice(c.ctx, ru.ID(), inv, amountMAtoms)
c.ntfns.notifyTipUserInvoiceGenerated(ru, getInvoice.Tag, inv)

// Send reply.
reply := rpc.RMInvoice{
Invoice: inv,
Tag: getInvoice.Tag,
}
return ru.sendRM(reply, "getinvoicereply")
return c.sendWithSendQ("getinvoicereply", reply, ru.ID())
}

// handleTipUserPaymentResult takes the appropriate action after a payment
Expand Down Expand Up @@ -732,3 +774,33 @@ func (c *Client) ClearPayStats(uid *UserID) error {
return c.db.ClearPayStats(tx, uid)
})
}

// restartTrackGeneratedTipInvoices restarts tracking of invoices generated
// for tipping.
func (c *Client) restartTrackGeneratedTipInvoices(ctx context.Context) error {
<-c.abLoaded

// List outstanding invoices.
var invoices []clientdb.GeneratedInvoiceForTip
err := c.db.View(ctx, func(tx clientdb.ReadTx) error {
var err error
invoices, err = c.db.ListGeneratedTipInvoices(tx)
return err
})
if err != nil {
return err
}

if len(invoices) == 0 {
c.log.Debugf("No generated invoices to track for receiving tips")
} else {
c.log.Infof("Tracking %d invoices for receiving tips", len(invoices))
}

// Check ones that are expired.
for _, inv := range invoices {
go c.trackGeneratedTipInvoice(ctx, inv.UID, inv.Invoice, int64(inv.MilliAtoms))
}

return nil
}
3 changes: 3 additions & 0 deletions client/clientdb/fscdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ const (

pageSessionsDir = "pagesessions"
pageSessionOverviewFile = "overview.json"
genTipInvoicesFile = "generated-tip-invoices.json"
recvTipInvoicesFile = "received-tip-invoices.json"
expiredTipInvoicesFile = "expired-tip-invoices.json"
)

var (
Expand Down
7 changes: 7 additions & 0 deletions client/clientdb/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,13 @@ type UnkxdUserInfo struct {
AddedToGCTime *time.Time `json:"added_to_gc_time"`
}

type GeneratedInvoiceForTip struct {
UID UserID `json:"uid"`
Created time.Time `json:"created"`
Invoice string `json:"invoice"`
MilliAtoms uint64 `json:"milli_atoms"`
}

var (
ErrLocalIDEmpty = errors.New("local ID is not initialized")
ErrServerIDEmpty = errors.New("server ID is not known")
Expand Down
138 changes: 138 additions & 0 deletions client/clientdb/payments.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package clientdb

import (
"encoding/json"
"os"
"path/filepath"
"strconv"
Expand Down Expand Up @@ -184,3 +185,140 @@ func (db *DB) ListOldestValidTipUserAttempts(tx ReadTx, maxLifetime time.Duratio

return res, nil
}

// StoreGeneratedTipInvoice stores the specified invoice as one generated for
// the remote client to pay the local client for a tip.
func (db *DB) StoreGeneratedTipInvoice(tx ReadWriteTx, uid UserID, invoice string, amountMAtoms int64) error {
fname := filepath.Join(db.root, inboundDir, uid.String(), genTipInvoicesFile)
data := GeneratedInvoiceForTip{
UID: uid,
Created: time.Now(),
Invoice: invoice,
MilliAtoms: uint64(amountMAtoms),
}
return db.appendToJsonFile(fname, data)
}

// ListGeneratedTipInvoices lists all invoices generated for tipping from all
// users.
func (db *DB) ListGeneratedTipInvoices(tx ReadTx) ([]GeneratedInvoiceForTip, error) {
pattern := filepath.Join(db.root, inboundDir, "*", genTipInvoicesFile)
files, err := filepath.Glob(pattern)
if err != nil {
return nil, err
}

var res []GeneratedInvoiceForTip
for _, fname := range files {
f, err := os.Open(fname)
if err != nil {
db.log.Warnf("Unable to open file %s for reading "+
"generated tip invoices: %v", fname, err)
continue
}

dec := json.NewDecoder(f)
for {
var inv GeneratedInvoiceForTip
err := dec.Decode(&inv)
if err != nil {
break
}
res = append(res, inv)
}
_ = f.Close()
}

return res, nil
}

// removeFromGeneratedTipInvoice removes the invoice from the list of invoices
// generated for the remote user to tip the local client.
//
// Returns the data corresponding to the invoice.
func (db *DB) removeFromGeneratedTipInvoice(uid UserID, invoice string) (GeneratedInvoiceForTip, error) {
var data GeneratedInvoiceForTip

// Open file.
genFname := filepath.Join(db.root, inboundDir, uid.String(), genTipInvoicesFile)
f, err := os.Open(genFname)
if os.IsNotExist(err) {
return data, ErrNotFound
}
if err != nil {
return data, err
}

// Read list of existing generated invoices, adding to res[] all but
// the target one.
var res []GeneratedInvoiceForTip
found := false
dec := json.NewDecoder(f)
for {
var inv GeneratedInvoiceForTip
err := dec.Decode(&inv)
if err != nil {
break
}
if inv.Invoice == invoice {
found = true
data = inv
} else {
res = append(res, inv)
}
}
if err := f.Close(); err != nil {
return data, err
}

if !found {
return data, ErrNotFound
}

// Either remove the file (if no other invoices remain) or rewrite the
// file with the remaining invoices.
if len(res) == 0 {
err := removeIfExists(genFname)
if err != nil {
return data, err
}
} else {
f, err := os.Create(genFname)
if err != nil {
return data, err
}
enc := json.NewEncoder(f)
for _, inv := range res {
if err := enc.Encode(inv); err != nil {
return data, err
}
}
if err := f.Close(); err != nil {
return data, err
}
}

return data, nil
}

// MarkGeneratedTipInvoiceExpired marks an invoice generated for tipping as
// having expired.
func (db *DB) MarkGeneratedTipInvoiceExpired(tx ReadWriteTx, uid UserID, invoice string) error {
data, err := db.removeFromGeneratedTipInvoice(uid, invoice)
if err != nil {
return err
}
expiredFname := filepath.Join(db.root, inboundDir, uid.String(), expiredTipInvoicesFile)
return db.appendToJsonFile(expiredFname, data)
}

// MarkGeneratedTipInvoiceReceived marks an invoice generated for tipping as
// having been received (i.e. invoice was paid).
func (db *DB) MarkGeneratedTipInvoiceReceived(tx ReadWriteTx, uid UserID, invoice string, receivedMAtoms int64) error {
data, err := db.removeFromGeneratedTipInvoice(uid, invoice)
if err != nil {
return err
}
recvFname := filepath.Join(db.root, inboundDir, uid.String(), recvTipInvoicesFile)
return db.appendToJsonFile(recvFname, data)
}
5 changes: 5 additions & 0 deletions client/clientintf/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ type PaymentClient interface {
GetInvoice(context.Context, int64, func(int64)) (string, error)
DecodeInvoice(context.Context, string) (DecodedInvoice, error)
IsInvoicePaid(context.Context, int64, string) error
TrackInvoice(context.Context, string, int64) (int64, error)
IsPaymentCompleted(context.Context, string) (int64, error)
}

Expand All @@ -124,6 +125,9 @@ func (pc FreePaymentClient) IsInvoicePaid(context.Context, int64, string) error
func (pc FreePaymentClient) IsPaymentCompleted(context.Context, string) (int64, error) {
return 0, nil
}
func (pc FreePaymentClient) TrackInvoice(ctx context.Context, inv string, minMAtoms int64) (int64, error) {
return 0, nil
}

// farFutureExpiryTime is a time far in the future for the expiration of free
// invoices.
Expand Down Expand Up @@ -179,6 +183,7 @@ type ReceivedGCMsg struct {
var (
ErrSubsysExiting = errors.New("subsys exiting")
ErrInvoiceInsufficientlyPaid = errors.New("invoice insufficiently paid")
ErrInvoiceExpired = errors.New("invoice expired")
ErrOnboardNoFunds = errors.New("onboarding invite does not have any funds")
ErrRetriablePayment = errors.New("retriable payment error")
)
2 changes: 1 addition & 1 deletion client/internal/lowlevel/rmq.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func (q *RMQ) isRVInvoicePaid(ctx context.Context, rv RVID, amt int64, pc client
// Check invoice payment actually completed.
fees, err := pc.IsPaymentCompleted(ctx, payInvoice)
if err != nil {
q.log.Warnf("Push payment attempt stored failed IsInvoicePaid "+
q.log.Warnf("Push payment attempt stored failed IsPaymentCompleted"+
"check: %v", err)
return 0, nil
}
Expand Down
Loading

0 comments on commit d27253e

Please sign in to comment.