From d1e6a7d7b41120ad67d5fb57133dce7e25d255fe Mon Sep 17 00:00:00 2001 From: Damian Mee Date: Fri, 18 Jan 2019 15:50:43 +0700 Subject: [PATCH] GET /api/history?only_status(paid|expired|pending) added (#14 vol11); Streaming status for LN added to GET /api/payment (#30 beta) --- README.md | 23 +++-- clightning/client.go | 15 ++- common/common.go | 1 + go.mod | 4 + go.sum | 8 ++ lnd/client.go | 48 +++++---- main.go | 240 ++++++++++++++++++++++++++++--------------- 7 files changed, 223 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index d0d99cc..7937b53 100644 --- a/README.md +++ b/README.md @@ -121,16 +121,16 @@ On error, returns: ## `GET /api/payment?hash=LN-hash&address=BTC-address` -Takes two parameters: +#### Takes: * `hash` - hash of the preimage returned previously by the `POST /payment` endpoint * `address` - Bitcoin address returned previously by the `POST /payment` endpoint > **NOTE:** providing just one will run checks on one network only. -Returns: +#### Returns: -#### on expiry (code 408) +##### on expiry (code 408) ```json { @@ -138,7 +138,7 @@ Returns: } ``` -#### on LN success (code 200) +##### on LN success (code 200) ```json { @@ -151,7 +151,7 @@ Returns: } ``` -#### on BTC success w/exact amount (code 200) +##### on BTC success w/exact amount (code 200) ```json { @@ -166,7 +166,7 @@ Returns: } ``` -#### on BTC success w/too big amount (code 202) +##### on BTC success w/too big amount (code 202) ```json { @@ -181,7 +181,7 @@ Returns: } ``` -#### on BTC success w/too small amount (code 402) +##### on BTC success w/too small amount (code 402) ```json { @@ -197,17 +197,20 @@ Returns: } ``` -#### On any other error +##### On any other error ```json { "error": "error messageā€¦" } ``` - ## `GET /api/history` -Presently takes no arguments, and returns (various success cases included below): +#### Takes: + +* `only_status` - filter payments to only one specific state: `paid`, `expired` or `panding`. + +#### Returns (various cases included below): ```json { diff --git a/clightning/client.go b/clightning/client.go index cf69af9..d3c23fd 100644 --- a/clightning/client.go +++ b/clightning/client.go @@ -1,6 +1,7 @@ package cLightning import ( + "context" "github.com/lncm/invoicer/common" "github.com/pkg/errors" ) @@ -9,23 +10,27 @@ const ClientName = "clightning" type CLightning struct{} -func (cLightning CLightning) Invoice(amount int64, desc string) (invoice, hash string, err error) { +func (cLightning CLightning) NewInvoice(ctx context.Context, amount int64, desc string) (invoice, hash string, err error) { return invoice, hash, errors.New("not implemented yet") } -func (cLightning CLightning) Status(hash string) (s common.Status, err error) { +func (cLightning CLightning) Status(ctx context.Context, hash string) (s common.Status, err error) { return s, errors.New("not implemented yet") } -func (cLightning CLightning) Address(bool) (address string, err error) { +func (cLightning CLightning) StatusWait(ctx context.Context, hash string) (s common.Status, err error) { + return s, errors.New("not implemented yet") +} + +func (cLightning CLightning) Address(context.Context, bool) (address string, err error) { return address, errors.New("not implemented yet") } -func (cLightning CLightning) Info() (info common.Info, err error) { +func (cLightning CLightning) Info(ctx context.Context) (info common.Info, err error) { return info, errors.New("not implemented yet") } -func (cLightning CLightning) History() (invoices common.Invoices, err error) { +func (cLightning CLightning) History(ctx context.Context) (invoices common.Invoices, err error) { return invoices, errors.New("not implemented yet") } diff --git a/common/common.go b/common/common.go index c4c94aa..1eecfd6 100644 --- a/common/common.go +++ b/common/common.go @@ -78,6 +78,7 @@ type ( AddrsStatus []AddrStatus StatusReply struct { + Code int `json:"-"` Error string `json:"error,omitempty"` Ln *Status `json:"ln,omitempty"` Bitcoin *AddrStatus `json:"bitcoin,omitempty"` diff --git a/go.mod b/go.mod index cc8bf2f..70c07d8 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/gin-contrib/cors v0.0.0-20181008113111-488de3ec974f github.com/gin-contrib/gzip v0.0.0-20190101123152-0eb78e93402e github.com/gin-gonic/gin v1.3.0 + github.com/go-playground/locales v0.12.1 // indirect + github.com/go-playground/universal-translator v0.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway v1.5.1 // indirect github.com/juju/clock v0.0.0-20180808021310-bab88fc67299 // indirect github.com/juju/errors v0.0.0-20181118221551-089d3ea4e4d5 // indirect @@ -14,6 +16,7 @@ require ( github.com/juju/testing v0.0.0-20180920084828-472a3e8b2073 // indirect github.com/juju/utils v0.0.0-20180820210520-bf9cc5bdd62d // indirect github.com/juju/version v0.0.0-20180108022336-b64dbd566305 // indirect + github.com/leodido/go-urn v1.1.0 // indirect github.com/lightningnetwork/lnd v0.5.1-beta github.com/pkg/errors v0.8.0 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af // indirect @@ -21,6 +24,7 @@ require ( google.golang.org/genproto v0.0.0-20181127195345-31ac5d88444a // indirect google.golang.org/grpc v1.16.0 gopkg.in/errgo.v1 v1.0.0 // indirect + gopkg.in/go-playground/validator.v9 v9.25.0 gopkg.in/macaroon-bakery.v2 v2.1.0 // indirect gopkg.in/macaroon.v2 v2.0.0 gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce // indirect diff --git a/go.sum b/go.sum index a5a21d8..9cde9db 100644 --- a/go.sum +++ b/go.sum @@ -35,6 +35,10 @@ github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7 h1:AzN37oI0cOS+cou github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-gonic/gin v1.3.0 h1:kCmZyPklC0gVdL728E6Aj20uYBJV93nj/TkwBTKhFbs= github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= +github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= @@ -70,6 +74,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/lightninglabs/gozmq v0.0.0-20180324010646-462a8a753885/go.mod h1:KUh15naRlx/TmUMFS/p4JJrCrE6F7RGF7rsnvuu45E4= github.com/lightninglabs/neutrino v0.0.0-20181017011010-4d6069299130/go.mod h1:KJq43Fu9ceitbJsSXMILcT4mGDNI/crKmPIkDOZXFyM= github.com/lightningnetwork/lnd v0.5.1-beta h1:ft+kzuYDz8o4iE0VgU/qTyb8uc7wB8RI1EONWCQc3oI= @@ -138,6 +144,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXa gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v8 v8.18.2 h1:lFB4DoMU6B626w8ny76MV7VX6W2VHct2GVOI3xgiMrQ= gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/go-playground/validator.v9 v9.25.0 h1:Q3c4LgUofOEtz0wCE18Q2qwDkATLHLBUOmTvqjNCWkM= +gopkg.in/go-playground/validator.v9 v9.25.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/macaroon-bakery.v2 v2.1.0 h1:9Jw/+9XHBSutkaeVpWhDx38IcSNLJwWUICkOK98DHls= gopkg.in/macaroon-bakery.v2 v2.1.0/go.mod h1:B4/T17l+ZWGwxFSZQmlBwp25x+og7OkhETfr3S9MbIA= gopkg.in/macaroon.v2 v2.0.0 h1:LVWycAfeJBUjCIqfR9gqlo7I8vmiXRr51YEOZ1suop8= diff --git a/lnd/client.go b/lnd/client.go index a6f74a8..4e4ea31 100644 --- a/lnd/client.go +++ b/lnd/client.go @@ -31,10 +31,7 @@ var ( readOnlyMacaroon = flag.String("lnd-readonly", "readonly.macaroon", "Specify path to readonly.macaroon file") ) -func (lnd Lnd) Invoice(amount int64, desc string) (invoice, hash string, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - +func (lnd Lnd) NewInvoice(ctx context.Context, amount int64, desc string) (invoice, hash string, err error) { inv, err := lnd.invoiceClient.AddInvoice(ctx, &lnrpc.Invoice{ Memo: desc, Value: int64(amount), @@ -47,10 +44,32 @@ func (lnd Lnd) Invoice(amount int64, desc string) (invoice, hash string, err err return inv.PaymentRequest, hex.EncodeToString(inv.RHash), nil } -func (lnd Lnd) Status(hash string) (s common.Status, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() +// TODO: do not resubscribe on each request +func (lnd Lnd) StatusWait(ctx context.Context, hash string) (s common.Status, err error) { + invSub, err := lnd.invoiceClient.SubscribeInvoices(ctx, &lnrpc.InvoiceSubscription{}) + if err != nil { + return + } + + for { + var inv *lnrpc.Invoice + inv, err = invSub.Recv() + if err != nil { + return + } + + if hash == hex.EncodeToString(inv.RHash) { + return common.Status{ + Ts: inv.CreationDate, + Settled: inv.Settled, + Expiry: inv.Expiry, + Value: inv.Value, + }, nil + } + } +} +func (lnd Lnd) Status(ctx context.Context, hash string) (s common.Status, err error) { invId, err := hex.DecodeString(hash) if err != nil { return @@ -69,10 +88,7 @@ func (lnd Lnd) Status(hash string) (s common.Status, err error) { }, nil } -func (lnd Lnd) Address(bech32 bool) (address string, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - +func (lnd Lnd) Address(ctx context.Context, bech32 bool) (address string, err error) { addrType := lnrpc.NewAddressRequest_NESTED_PUBKEY_HASH if bech32 { addrType = lnrpc.NewAddressRequest_WITNESS_PUBKEY_HASH @@ -88,10 +104,7 @@ func (lnd Lnd) Address(bech32 bool) (address string, err error) { return addrResp.Address, nil } -func (lnd Lnd) Info() (info common.Info, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - defer cancel() - +func (lnd Lnd) Info(ctx context.Context) (info common.Info, err error) { i, err := lnd.readOnlyClient.GetInfo(ctx, &lnrpc.GetInfoRequest{}) if err != nil { return @@ -100,10 +113,7 @@ func (lnd Lnd) Info() (info common.Info, err error) { return common.Info{Uris: i.Uris}, nil } -func (lnd Lnd) History() (invoices common.Invoices, err error) { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - +func (lnd Lnd) History(ctx context.Context) (invoices common.Invoices, err error) { list, err := lnd.readOnlyClient.ListInvoices(ctx, &lnrpc.ListInvoiceRequest{ NumMaxInvoices: 100, Reversed: true, diff --git a/main.go b/main.go index 839822e..694c48c 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "context" "flag" "fmt" "github.com/gin-contrib/cors" @@ -12,6 +13,7 @@ import ( "github.com/lncm/invoicer/common" "github.com/lncm/invoicer/lnd" "github.com/pkg/errors" + "gopkg.in/go-playground/validator.v9" "os" "path" "strings" @@ -19,25 +21,20 @@ import ( ) type ( - BitcoinWallet interface { - Address(bech32 bool) (string, error) - } - BitcoinClient interface { - BitcoinWallet - + Address(bech32 bool) (string, error) BlockCount() (int64, error) ImportAddress(address, label string) error CheckAddress(address string) (common.AddrsStatus, error) } LightningClient interface { - BitcoinWallet - - Info() (common.Info, error) - Invoice(amount int64, desc string) (string, string, error) - Status(hash string) (common.Status, error) - History() (common.Invoices, error) + Address(ctx context.Context, bech32 bool) (string, error) + Info(ctx context.Context) (common.Info, error) + NewInvoice(ctx context.Context, amount int64, desc string) (string, string, error) + Status(ctx context.Context, hash string) (common.Status, error) + StatusWait(ctx context.Context, hash string) (common.Status, error) + History(ctx context.Context) (common.Invoices, error) } ) @@ -66,7 +63,7 @@ func init() { lnClient = lnd.New() case cLightning.ClientName: - lnClient = cLightning.New() + //lnClient = cLightning.New() default: panic("invalid LN client specified") @@ -145,7 +142,7 @@ func newPayment(c *gin.Context) { var payment common.NewPayment // Generate new LN invoice - payment.Bolt11, payment.Hash, err = lnClient.Invoice(data.Amount, data.Description) + payment.Bolt11, payment.Hash, err = lnClient.NewInvoice(c, data.Amount, data.Description) if err != nil { c.AbortWithStatusJSON(500, gin.H{ "error": errors.WithMessage(err, "can't create new LN invoice").Error(), @@ -154,7 +151,7 @@ func newPayment(c *gin.Context) { } // Extract invoice's creation date & expiry - invoice, err := lnClient.Status(payment.Hash) + invoice, err := lnClient.Status(c, payment.Hash) if err != nil { c.AbortWithStatusJSON(500, gin.H{ "error": errors.WithMessage(err, "can't get LN invoice").Error(), @@ -165,7 +162,7 @@ func newPayment(c *gin.Context) { payment.Expiry = invoice.Expiry // get BTC address - payment.Address, err = lnClient.Address(false) + payment.Address, err = lnClient.Address(c, false) if err != nil { c.AbortWithStatusJSON(500, gin.H{ "error": errors.WithMessage(err, "can't get Bitcoin address").Error(), @@ -189,6 +186,85 @@ func newPayment(c *gin.Context) { c.JSON(200, payment) } +func checkLn(ctx context.Context, hash string) *common.StatusReply { + lnStatus, err := lnClient.StatusWait(ctx, hash) + if err != nil { + return &common.StatusReply{ + Code: 500, + Error: fmt.Sprintf("unable to fetch invoice: %s", err), + } + } + + if lnStatus.Settled { + return &common.StatusReply{ + Code: 200, Ln: &lnStatus, + } + } + + if lnStatus.IsExpired() { + return &common.StatusReply{ + Code: 408, + Error: "expired", + } + } + + return nil +} + +func checkBtc(fin time.Time, addr string, lnProvided bool, desiredAmount int64) *common.StatusReply { + for time.Now().Before(fin) { + time.Sleep(2 * time.Second) + + btcStatuses, err := btcClient.CheckAddress(addr) + if err != nil { + if !lnProvided { + return &common.StatusReply{ + Code: 500, + Error: fmt.Sprintf("unable to check status: %s", err), + } + } + + // if LN hash available and fetching bitcoin status produced an error, disable checking bitcoin + return nil + } + + btcStatus := btcStatuses[0] + + receivedAmount := int64(btcStatus.Amount) * 1e8 + if btcStatus.Amount == 0 { + continue + } + + // no need to return it now; might be useful later + btcStatus.Label = "" + + if desiredAmount == receivedAmount { + return &common.StatusReply{ + Code: 200, + Bitcoin: &btcStatus, + } + } + + if receivedAmount > desiredAmount { + return &common.StatusReply{ + Code: 202, + Bitcoin: &btcStatus, + } + + } + + if desiredAmount > receivedAmount { + return &common.StatusReply{ + Code: 402, + Error: "not enough", + Bitcoin: &btcStatus, + } + } + } + + return nil +} + func status(c *gin.Context) { hash := c.Query("hash") addr := c.Query("address") @@ -205,7 +281,7 @@ func status(c *gin.Context) { // do initial LN invoice check, and adjust expiration if available fin := time.Now().Add(common.DefaultInvoiceExpiry * time.Second) if len(hash) > 0 { - lnStatus, err := lnClient.Status(hash) + lnStatus, err := lnClient.Status(c, hash) if err != nil { c.AbortWithStatusJSON(500, common.StatusReply{ Error: fmt.Sprintf("unable to fetch invoice: %s", err), @@ -227,81 +303,64 @@ func status(c *gin.Context) { desiredAmount = lnStatus.Value } - // keep polling for status update every N seconds - for time.Now().Before(fin) { - if len(addr) > 0 { - btcStatuses, err := btcClient.CheckAddress(addr) - if err != nil { - if len(hash) == 0 { - c.AbortWithStatusJSON(500, common.StatusReply{ - Error: fmt.Sprintf("unable to check status: %s", err), - }) - return - } - - // if LN hash available and fetching bitcoin status produced an error, disable checking bitcoin - addr = "" - } - - btcStatus := btcStatuses[0] - - receivedAmount := int64(btcStatus.Amount) * 1e8 - if btcStatus.Amount > 0 { - // no need to return it now; might be useful later - btcStatus.Label = "" - - if desiredAmount == receivedAmount { - c.JSON(200, common.StatusReply{Bitcoin: &btcStatus}) - return - } - - if receivedAmount > desiredAmount { - c.JSON(202, common.StatusReply{Bitcoin: &btcStatus}) - return - } - - if desiredAmount > receivedAmount { - c.AbortWithStatusJSON(402, common.StatusReply{ - Error: "not enough", - Bitcoin: &btcStatus, - }) - return - } - } + ctx, cancel := context.WithDeadline(c, fin) + defer cancel() - time.Sleep(2 * time.Second) - } + paymentStatus := make(chan *common.StatusReply) + if len(hash) > 0 { + go func() { + paymentStatus <- checkLn(ctx, hash) + }() + } - if len(hash) > 0 { - lnStatus, err := lnClient.Status(hash) - if err != nil { - c.AbortWithStatusJSON(500, common.StatusReply{ - Error: fmt.Sprintf("unable to fetch invoice: %s", err), - }) - return - } + // keep polling for status update every N seconds + if len(addr) > 0 { + go func() { + paymentStatus <- checkBtc(fin, addr, len(hash) > 0, desiredAmount) + }() + } - if lnStatus.Settled { - c.JSON(200, common.StatusReply{Ln: &lnStatus}) - return - } + status := <-paymentStatus - if lnStatus.IsExpired() { - c.AbortWithStatusJSON(408, common.StatusReply{Error: "expired"}) - return - } + if status == nil { + c.AbortWithStatusJSON(500, common.StatusReply{Error: "unknown error"}) + return + } - time.Sleep(2 * time.Second) - } + if status.Code < 300 { + c.JSON(status.Code, status) + return } - c.AbortWithStatusJSON(408, common.StatusReply{Error: "expired"}) + c.AbortWithStatusJSON(status.Code, status) } // TODO: pagination // TODO: only paid // TODO: limit func history(c *gin.Context) { + var queryParams struct { + Limit int64 `form:"limit"` + Offset int64 `form:"offset"` + OnlyStatus string `form:"only_status" validate:"omitempty,oneof=paid expired pending"` + } + + err := c.BindQuery(&queryParams) + if err != nil { + c.AbortWithStatusJSON(400, gin.H{ + "error": fmt.Sprintf("invalid request: %v", err), + }) + return + } + + err = validator.New().Struct(queryParams) + if err != nil { + c.AbortWithStatusJSON(400, gin.H{ + "error": fmt.Sprintf("invalid request: %v", err), + }) + return + } + var warning string // fetch bitcoin history btcAllAddresses, err := btcClient.CheckAddress("") @@ -318,7 +377,7 @@ func history(c *gin.Context) { } // fetch LN history - lnHistory, err := lnClient.History() + lnHistory, err := lnClient.History(c) if err != nil { c.AbortWithStatusJSON(500, gin.H{ "error": fmt.Sprintf("Can't get history from LN node: %v", err), @@ -336,6 +395,23 @@ func history(c *gin.Context) { payment.ApplyBtc(btcStatus) } + switch queryParams.OnlyStatus { + case "paid": + if !payment.Paid { + continue + } + + case "expired": + if !payment.Expired { + continue + } + + case "pending": + if payment.Paid || payment.Expired { + continue + } + } + history = append(history, payment) } @@ -349,7 +425,7 @@ func history(c *gin.Context) { } func info(c *gin.Context) { - info, err := lnClient.Info() + info, err := lnClient.Info(c) if err != nil { c.AbortWithStatusJSON(500, gin.H{ "error": fmt.Sprintf("Can't get info from LN node: %v", err), @@ -367,7 +443,7 @@ func healthCheck(c *gin.Context) { return } - _, err = lnClient.Info() + _, err = lnClient.Info(c) if err != nil { c.String(500, err.Error()) return