diff --git a/accounts/interface.go b/accounts/interface.go index 709f40ec7..f78a66f7b 100644 --- a/accounts/interface.go +++ b/accounts/interface.go @@ -107,6 +107,12 @@ type AccountInvoices map[lntypes.Hash]struct{} // AccountPayments is the set of payments that are associated with an account. type AccountPayments map[lntypes.Hash]*PaymentEntry +// AccountPaymentEntry wraps a payment hash with its entry details. +type AccountPaymentEntry struct { + Hash lntypes.Hash + *PaymentEntry +} + // OffChainBalanceAccount holds all information that is needed to keep track of // a user's off-chain account balance. This balance can only be spent by paying // invoices. @@ -279,6 +285,16 @@ type Store interface { DeleteAccountPayment(_ context.Context, id AccountID, hash lntypes.Hash) error + // ListAccountPayments returns a paginated list of payments + // associated with the given account, sorted in ascending + // lexicographical order of their payment hash. + ListAccountPayments(ctx context.Context, id AccountID, offset, + limit int32) ([]*AccountPaymentEntry, error) + + // CountAccountPayments returns the total number of payments associated + // with the given account. + CountAccountPayments(ctx context.Context, id AccountID) (uint64, error) + // RemoveAccount finds an account by its ID and removes it from the¨ // store. RemoveAccount(ctx context.Context, id AccountID) error diff --git a/accounts/rpcserver.go b/accounts/rpcserver.go index 3aa8c152f..0fc92c159 100644 --- a/accounts/rpcserver.go +++ b/accounts/rpcserver.go @@ -5,18 +5,43 @@ import ( "encoding/hex" "errors" "fmt" + "sync" "time" "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/lightning-terminal/litrpc" litmac "github.com/lightninglabs/lightning-terminal/macaroons" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/macaroons" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "gopkg.in/macaroon-bakery.v2/bakery/checkers" "gopkg.in/macaroon.v2" ) +const ( + // DefaultMaxPayments is the default page size for listing payments. + DefaultMaxPayments = 20 + + // MaxPaymentsLimit is the maximum page size allowed for listing + // payments. + MaxPaymentsLimit = 50 + + // MaxIndexOffset is the maximum index offset allowed. + MaxIndexOffset = 0x7fffffff + + // DefaultTrackPaymentTimeout is the timeout for track payment RPC + // calls. + DefaultTrackPaymentTimeout = 8 * time.Second + + // MaxConcurrentTrackPayments is the maximum number of concurrent track + // payment requests. + MaxConcurrentTrackPayments = 10 +) + var ( // ErrServerNotActive indicates that the server has started but hasn't // fully finished the startup process. @@ -371,3 +396,223 @@ func marshalAccount(acct *OffChainBalanceAccount) *litrpc.Account { return rpcAccount } + +// AccountPayments returns the detailed payment history for the given account. +func (s *RPCServer) AccountPayments(ctx context.Context, + req *litrpc.AccountPaymentsRequest) ( + *litrpc.AccountPaymentsResponse, error) { + + if req.GetAccount() == nil { + return nil, fmt.Errorf("account param must be specified") + } + + var id, label string + switch idType := req.Account.Identifier.(type) { + case *litrpc.AccountIdentifier_Id: + id = idType.Id + case *litrpc.AccountIdentifier_Label: + label = idType.Label + } + + log.Infof("[accountpayments] id=%s, label=%v, max_payments=%d, "+ + "index_offset=%d, count_total_payments=%v", + id, label, req.MaxPayments, req.IndexOffset, + req.CountTotalPayments) + + accountID, err := s.findAccount(ctx, id, label) + if err != nil { + return nil, err + } + + // Determine limits. + limit := req.MaxPayments + if limit == 0 { + limit = DefaultMaxPayments + } else if limit > MaxPaymentsLimit { + return nil, fmt.Errorf( + "max_payments cannot exceed %d", MaxPaymentsLimit, + ) + } + + if req.IndexOffset > MaxIndexOffset { + return nil, fmt.Errorf("index_offset out of range") + } + offset := req.IndexOffset + + // Fetch the paginated payment entries from the store. + paymentsFromStore, err := s.service.store.ListAccountPayments( + ctx, accountID, int32(offset), int32(limit), + ) + if err != nil { + return nil, fmt.Errorf( + "unable to list account payments: %w", err, + ) + } + + // Fetch total payment count if requested. + var totalNumPayments uint64 + if req.CountTotalPayments { + total, err := s.service.store.CountAccountPayments( + ctx, accountID, + ) + if err != nil { + return nil, fmt.Errorf( + "unable to count account payments: %w", err, + ) + } + + totalNumPayments = total + } + + // Fetch the detailed payments concurrently from LND. + var ( + wg sync.WaitGroup + mu sync.Mutex + payments = make(map[lntypes.Hash]*lnrpc.Payment) + errs []error + sem = make(chan struct{}, MaxConcurrentTrackPayments) + ) + + rawCtx, _, client := s.service.routerClient.RawClientWithMacAuth(ctx) + + for _, entry := range paymentsFromStore { + entry := entry + + wg.Add(1) + go func() { + defer wg.Done() + + // Bounded concurrency using semaphore. + select { + case sem <- struct{}{}: + case <-rawCtx.Done(): + return + } + defer func() { <-sem }() + + // Add a timeout to prevent hanging streaming calls. + trackCtx, trackCancel := context.WithTimeout( + rawCtx, DefaultTrackPaymentTimeout, + ) + defer trackCancel() + + stream, err := client.TrackPaymentV2( + trackCtx, &routerrpc.TrackPaymentRequest{ + PaymentHash: entry.Hash[:], + NoInflightUpdates: false, + }, + ) + if err != nil { + // Skip error logging and recording if the + // parent context was cancelled or timed out. + if rawCtx.Err() != nil { + return + } + + sErr, ok := status.FromError(err) + if ok && sErr.Code() == codes.NotFound { + log.Warnf("Payment %x not found in "+ + "lnd, creating placeholder: %v", + entry.Hash[:], err) + + mu.Lock() + payments[entry.Hash] = + notFoundPayment(entry.Hash) + mu.Unlock() + + return + } + + log.Errorf("Failed to track payment %x: %v", + entry.Hash[:], err) + + mu.Lock() + errs = append(errs, err) + mu.Unlock() + + return + } + + payment, err := stream.Recv() + if err != nil { + // Skip error logging and recording if the + // parent context was cancelled or timed out. + if rawCtx.Err() != nil { + return + } + + sErr, ok := status.FromError(err) + if ok && sErr.Code() == codes.NotFound { + log.Warnf("Payment %x not found in "+ + "lnd, creating placeholder: %v", + entry.Hash[:], err) + + mu.Lock() + payments[entry.Hash] = + notFoundPayment(entry.Hash) + mu.Unlock() + + return + } + + log.Errorf("Failed to receive payment "+ + "update for %x: %v", entry.Hash[:], err) + + mu.Lock() + errs = append(errs, err) + mu.Unlock() + + return + } + + mu.Lock() + payments[entry.Hash] = payment + mu.Unlock() + }() + } + + wg.Wait() + + // Return immediately if the parent context was cancelled or timed out. + if err := rawCtx.Err(); err != nil { + return nil, err + } + + // If there were any errors tracking the payments, return the + // error. + if len(errs) > 0 { + return nil, fmt.Errorf( + "failed to fetch payment details: %w", errs[0], + ) + } + + var finalPayments []*lnrpc.Payment + for _, entry := range paymentsFromStore { + if p, ok := payments[entry.Hash]; ok { + finalPayments = append(finalPayments, p) + } + } + + var firstIndexOffset, lastIndexOffset uint64 + if len(finalPayments) > 0 { + firstIndexOffset = offset + lastIndexOffset = offset + uint64(len(finalPayments)) + } + + return &litrpc.AccountPaymentsResponse{ + Payments: finalPayments, + FirstIndexOffset: firstIndexOffset, + LastIndexOffset: lastIndexOffset, + TotalNumPayments: totalNumPayments, + }, nil +} + +// notFoundPayment creates a placeholder payment for a desynced entry. +func notFoundPayment(hash lntypes.Hash) *lnrpc.Payment { + return &lnrpc.Payment{ + PaymentHash: hex.EncodeToString(hash[:]), + Status: lnrpc.Payment_UNKNOWN, + FailureReason: lnrpc. + PaymentFailureReason_FAILURE_REASON_NONE, + } +} diff --git a/accounts/store_kvdb.go b/accounts/store_kvdb.go index eeb3168db..4a1c62c13 100644 --- a/accounts/store_kvdb.go +++ b/accounts/store_kvdb.go @@ -9,6 +9,7 @@ import ( "math" "os" "path/filepath" + "sort" "time" "github.com/btcsuite/btcwallet/walletdb" @@ -445,6 +446,60 @@ func (s *BoltStore) DeleteAccountPayment(_ context.Context, id AccountID, return s.updateAccount(id, update) } +// ListAccountPayments returns a paginated list of payments +// associated with the given account, sorted in ascending lexicographical +// order of their payment hash. +func (s *BoltStore) ListAccountPayments(ctx context.Context, id AccountID, + offset, limit int32) ([]*AccountPaymentEntry, error) { + + account, err := s.Account(ctx, id) + if err != nil { + return nil, err + } + + var matchedPayments []*AccountPaymentEntry + for hash, entry := range account.Payments { + matchedPayments = append(matchedPayments, &AccountPaymentEntry{ + Hash: hash, + PaymentEntry: entry, + }) + } + + // Sort target hashes lexicographically to ensure pagination is + // deterministic across all store backends. + sort.Slice(matchedPayments, func(i, j int) bool { + hashI := matchedPayments[i].Hash[:] + hashJ := matchedPayments[j].Hash[:] + return bytes.Compare(hashI, hashJ) < 0 + }) + + // Apply pagination limits and offsets. + total := int32(len(matchedPayments)) + if offset >= total { + return nil, nil + } + + end := offset + limit + if limit < 0 || end > total { + end = total + } + + return matchedPayments[offset:end], nil +} + +// CountAccountPayments returns the total number of payments associated with +// the given account. +func (s *BoltStore) CountAccountPayments(ctx context.Context, + id AccountID) (uint64, error) { + + account, err := s.Account(ctx, id) + if err != nil { + return 0, err + } + + return uint64(len(account.Payments)), nil +} + func (s *BoltStore) updateAccount(id AccountID, updateFn func(*OffChainBalanceAccount) error) error { diff --git a/accounts/store_sql.go b/accounts/store_sql.go index 61780c122..277aaf76a 100644 --- a/accounts/store_sql.go +++ b/accounts/store_sql.go @@ -46,6 +46,8 @@ type SQLQueries interface { ListAllAccountPayments(ctx context.Context) ([]sqlc.AccountPayment, error) ListAccountInvoices(ctx context.Context, id int64) ([]sqlc.AccountInvoice, error) ListAccountPayments(ctx context.Context, id int64) ([]sqlc.AccountPayment, error) + AccountPaymentsPaginated(ctx context.Context, arg sqlc.AccountPaymentsPaginatedParams) ([]sqlc.AccountPayment, error) + CountAccountPayments(ctx context.Context, accountID int64) (int64, error) ListAllAccounts(ctx context.Context) ([]sqlc.Account, error) SetAccountIndex(ctx context.Context, arg sqlc.SetAccountIndexParams) error UpdateAccountBalance(ctx context.Context, arg sqlc.UpdateAccountBalanceParams) (int64, error) @@ -779,6 +781,80 @@ func (s *SQLStore) DeleteAccountPayment(ctx context.Context, alias AccountID, }, sqldb.NoOpReset) } +// ListAccountPayments returns a paginated list of payments +// associated with the given account, sorted in ascending lexicographical +// order of their payment hash. +func (s *SQLStore) ListAccountPayments(ctx context.Context, alias AccountID, + offset, limit int32) ([]*AccountPaymentEntry, error) { + + var ( + readTxOpts = db.NewQueryReadTx() + payments []*AccountPaymentEntry + ) + err := s.db.ExecTx(ctx, &readTxOpts, func(db SQLQueries) error { + id, err := getAccountIDByAlias(ctx, db, alias) + if err != nil { + return err + } + + var dbPayments []sqlc.AccountPayment + dbPayments, err = db.AccountPaymentsPaginated( + ctx, sqlc.AccountPaymentsPaginatedParams{ + AccountID: id, + Limit: limit, + Offset: offset, + }, + ) + if err != nil { + return err + } + + payments = make([]*AccountPaymentEntry, len(dbPayments)) + for i, p := range dbPayments { + var hash lntypes.Hash + copy(hash[:], p.Hash) + payments[i] = &AccountPaymentEntry{ + Hash: hash, + PaymentEntry: &PaymentEntry{ + Status: lnrpc.Payment_PaymentStatus( + p.Status, + ), + FullAmount: lnwire.MilliSatoshi( + p.FullAmountMsat, + ), + }, + } + } + + return nil + }, sqldb.NoOpReset) + + return payments, err +} + +// CountAccountPayments returns the total number of payments associated with +// the given account. +func (s *SQLStore) CountAccountPayments(ctx context.Context, + alias AccountID) (uint64, error) { + + var ( + readTxOpts = db.NewQueryReadTx() + count int64 + ) + err := s.db.ExecTx(ctx, &readTxOpts, func(db SQLQueries) error { + id, err := getAccountIDByAlias(ctx, db, alias) + if err != nil { + return err + } + + count, err = db.CountAccountPayments(ctx, id) + + return err + }, sqldb.NoOpReset) + + return uint64(count), err +} + // LastIndexes returns the last invoice add and settle index or // ErrNoInvoiceIndexKnown if no indexes are known yet. // diff --git a/accounts/store_test.go b/accounts/store_test.go index c3610093a..100f5801a 100644 --- a/accounts/store_test.go +++ b/accounts/store_test.go @@ -906,3 +906,77 @@ func TestCheckLabel(t *testing.T) { }) } } + +// TestListAccountPayments tests listing and counting payment entries associated +// with a given account. +func TestListAccountPayments(t *testing.T) { + t.Parallel() + ctx := context.Background() + + store := NewTestDB(t, clock.NewTestClock(time.Now())) + + // Listing payments for non-existent account should fail. + _, err := store.ListAccountPayments( + ctx, AccountID{}, 0, 0, + ) + require.ErrorIs(t, err, ErrAccNotFound) + + acct, err := store.NewAccount( + ctx, 10000, time.Time{}, "payment-list", + ) + require.NoError(t, err) + + // Initially, there should be no payments. + payments, err := store.ListAccountPayments( + ctx, acct.ID, 0, 0, + ) + require.NoError(t, err) + require.Empty(t, payments) + + count, err := store.CountAccountPayments(ctx, acct.ID) + require.NoError(t, err) + require.Zero(t, count) + + // Add 3 payments. + hash1 := lntypes.Hash{1} + hash2 := lntypes.Hash{2} + hash3 := lntypes.Hash{3} + + _, err = store.UpsertAccountPayment( + ctx, acct.ID, hash1, 100, lnrpc.Payment_IN_FLIGHT, + ) + require.NoError(t, err) + + _, err = store.UpsertAccountPayment( + ctx, acct.ID, hash2, 200, lnrpc.Payment_SUCCEEDED, + ) + require.NoError(t, err) + + _, err = store.UpsertAccountPayment( + ctx, acct.ID, hash3, 300, lnrpc.Payment_FAILED, + ) + require.NoError(t, err) + + // Test counting all payments. + count, err = store.CountAccountPayments(ctx, acct.ID) + require.NoError(t, err) + require.EqualValues(t, 3, count) + + // List all payments in default order (ascending by hash). + payments, err = store.ListAccountPayments( + ctx, acct.ID, 0, 3, + ) + require.NoError(t, err) + require.Len(t, payments, 3) + require.Equal(t, hash1, payments[0].Hash) + require.Equal(t, hash2, payments[1].Hash) + require.Equal(t, hash3, payments[2].Hash) + + // Test offset and limit. + payments, err = store.ListAccountPayments( + ctx, acct.ID, 1, 1, + ) + require.NoError(t, err) + require.Len(t, payments, 1) + require.Equal(t, hash2, payments[0].Hash) +} diff --git a/app/scripts/build-protos.js b/app/scripts/build-protos.js index ebf1e0df6..e63bb1660 100644 --- a/app/scripts/build-protos.js +++ b/app/scripts/build-protos.js @@ -128,6 +128,10 @@ const sanitize = async () => { 'import "lnrpc/lightning.proto"', 'import "lnd.proto"', ); + content = content.replace( + 'import "proto/lnd.proto";', + 'import "lnd.proto";', + ); await fs.writeFile(path, content); } }; diff --git a/app/src/types/generated/lit-accounts_pb.d.ts b/app/src/types/generated/lit-accounts_pb.d.ts index da1c15435..44ff560c6 100644 --- a/app/src/types/generated/lit-accounts_pb.d.ts +++ b/app/src/types/generated/lit-accounts_pb.d.ts @@ -2,6 +2,7 @@ // file: lit-accounts.proto import * as jspb from "google-protobuf"; +import * as lnd_pb from "./lnd_pb"; export class CreateAccountRequest extends jspb.Message { getAccountBalance(): string; @@ -432,3 +433,71 @@ export namespace AccountIdentifier { } } +export class AccountPaymentsRequest extends jspb.Message { + hasAccount(): boolean; + clearAccount(): void; + getAccount(): AccountIdentifier | undefined; + setAccount(value?: AccountIdentifier): void; + + getMaxPayments(): string; + setMaxPayments(value: string): void; + + getIndexOffset(): string; + setIndexOffset(value: string): void; + + getCountTotalPayments(): boolean; + setCountTotalPayments(value: boolean): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): AccountPaymentsRequest.AsObject; + static toObject(includeInstance: boolean, msg: AccountPaymentsRequest): AccountPaymentsRequest.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: AccountPaymentsRequest, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): AccountPaymentsRequest; + static deserializeBinaryFromReader(message: AccountPaymentsRequest, reader: jspb.BinaryReader): AccountPaymentsRequest; +} + +export namespace AccountPaymentsRequest { + export type AsObject = { + account?: AccountIdentifier.AsObject, + maxPayments: string, + indexOffset: string, + countTotalPayments: boolean, + } +} + +export class AccountPaymentsResponse extends jspb.Message { + clearPaymentsList(): void; + getPaymentsList(): Array; + setPaymentsList(value: Array): void; + addPayments(value?: lnd_pb.Payment, index?: number): lnd_pb.Payment; + + getFirstIndexOffset(): string; + setFirstIndexOffset(value: string): void; + + getLastIndexOffset(): string; + setLastIndexOffset(value: string): void; + + getTotalNumPayments(): string; + setTotalNumPayments(value: string): void; + + serializeBinary(): Uint8Array; + toObject(includeInstance?: boolean): AccountPaymentsResponse.AsObject; + static toObject(includeInstance: boolean, msg: AccountPaymentsResponse): AccountPaymentsResponse.AsObject; + static extensions: {[key: number]: jspb.ExtensionFieldInfo}; + static extensionsBinary: {[key: number]: jspb.ExtensionFieldBinaryInfo}; + static serializeBinaryToWriter(message: AccountPaymentsResponse, writer: jspb.BinaryWriter): void; + static deserializeBinary(bytes: Uint8Array): AccountPaymentsResponse; + static deserializeBinaryFromReader(message: AccountPaymentsResponse, reader: jspb.BinaryReader): AccountPaymentsResponse; +} + +export namespace AccountPaymentsResponse { + export type AsObject = { + paymentsList: Array, + firstIndexOffset: string, + lastIndexOffset: string, + totalNumPayments: string, + } +} + diff --git a/app/src/types/generated/lit-accounts_pb.js b/app/src/types/generated/lit-accounts_pb.js index 64acda55d..a1fdc24a3 100644 --- a/app/src/types/generated/lit-accounts_pb.js +++ b/app/src/types/generated/lit-accounts_pb.js @@ -24,12 +24,16 @@ var global = (function () { return this; }).call(null) || Function('return this')(); +var lnd_pb = require('./lnd_pb.js'); +goog.object.extend(proto, lnd_pb); goog.exportSymbol('proto.litrpc.Account', null, global); goog.exportSymbol('proto.litrpc.AccountIdentifier', null, global); goog.exportSymbol('proto.litrpc.AccountIdentifier.IdentifierCase', null, global); goog.exportSymbol('proto.litrpc.AccountInfoRequest', null, global); goog.exportSymbol('proto.litrpc.AccountInvoice', null, global); goog.exportSymbol('proto.litrpc.AccountPayment', null, global); +goog.exportSymbol('proto.litrpc.AccountPaymentsRequest', null, global); +goog.exportSymbol('proto.litrpc.AccountPaymentsResponse', null, global); goog.exportSymbol('proto.litrpc.CreateAccountRequest', null, global); goog.exportSymbol('proto.litrpc.CreateAccountResponse', null, global); goog.exportSymbol('proto.litrpc.CreditAccountRequest', null, global); @@ -377,6 +381,48 @@ if (goog.DEBUG && !COMPILED) { */ proto.litrpc.AccountIdentifier.displayName = 'proto.litrpc.AccountIdentifier'; } +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.litrpc.AccountPaymentsRequest = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, null, null); +}; +goog.inherits(proto.litrpc.AccountPaymentsRequest, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.litrpc.AccountPaymentsRequest.displayName = 'proto.litrpc.AccountPaymentsRequest'; +} +/** + * Generated by JsPbCodeGenerator. + * @param {Array=} opt_data Optional initial data array, typically from a + * server response, or constructed directly in Javascript. The array is used + * in place and becomes part of the constructed object. It is not cloned. + * If no data is provided, the constructed object will be empty, but still + * valid. + * @extends {jspb.Message} + * @constructor + */ +proto.litrpc.AccountPaymentsResponse = function(opt_data) { + jspb.Message.initialize(this, opt_data, 0, -1, proto.litrpc.AccountPaymentsResponse.repeatedFields_, null); +}; +goog.inherits(proto.litrpc.AccountPaymentsResponse, jspb.Message); +if (goog.DEBUG && !COMPILED) { + /** + * @public + * @override + */ + proto.litrpc.AccountPaymentsResponse.displayName = 'proto.litrpc.AccountPaymentsResponse'; +} @@ -3351,4 +3397,495 @@ proto.litrpc.AccountIdentifier.prototype.hasLabel = function() { }; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.litrpc.AccountPaymentsRequest.prototype.toObject = function(opt_includeInstance) { + return proto.litrpc.AccountPaymentsRequest.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.litrpc.AccountPaymentsRequest} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.litrpc.AccountPaymentsRequest.toObject = function(includeInstance, msg) { + var f, obj = { + account: (f = msg.getAccount()) && proto.litrpc.AccountIdentifier.toObject(includeInstance, f), + maxPayments: jspb.Message.getFieldWithDefault(msg, 2, "0"), + indexOffset: jspb.Message.getFieldWithDefault(msg, 3, "0"), + countTotalPayments: jspb.Message.getBooleanFieldWithDefault(msg, 4, false) + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.litrpc.AccountPaymentsRequest} + */ +proto.litrpc.AccountPaymentsRequest.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.litrpc.AccountPaymentsRequest; + return proto.litrpc.AccountPaymentsRequest.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.litrpc.AccountPaymentsRequest} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.litrpc.AccountPaymentsRequest} + */ +proto.litrpc.AccountPaymentsRequest.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = new proto.litrpc.AccountIdentifier; + reader.readMessage(value,proto.litrpc.AccountIdentifier.deserializeBinaryFromReader); + msg.setAccount(value); + break; + case 2: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setMaxPayments(value); + break; + case 3: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setIndexOffset(value); + break; + case 4: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setCountTotalPayments(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.litrpc.AccountPaymentsRequest.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.litrpc.AccountPaymentsRequest.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.litrpc.AccountPaymentsRequest} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.litrpc.AccountPaymentsRequest.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getAccount(); + if (f != null) { + writer.writeMessage( + 1, + f, + proto.litrpc.AccountIdentifier.serializeBinaryToWriter + ); + } + f = message.getMaxPayments(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 2, + f + ); + } + f = message.getIndexOffset(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 3, + f + ); + } + f = message.getCountTotalPayments(); + if (f) { + writer.writeBool( + 4, + f + ); + } +}; + + +/** + * optional AccountIdentifier account = 1; + * @return {?proto.litrpc.AccountIdentifier} + */ +proto.litrpc.AccountPaymentsRequest.prototype.getAccount = function() { + return /** @type{?proto.litrpc.AccountIdentifier} */ ( + jspb.Message.getWrapperField(this, proto.litrpc.AccountIdentifier, 1)); +}; + + +/** + * @param {?proto.litrpc.AccountIdentifier|undefined} value + * @return {!proto.litrpc.AccountPaymentsRequest} returns this +*/ +proto.litrpc.AccountPaymentsRequest.prototype.setAccount = function(value) { + return jspb.Message.setWrapperField(this, 1, value); +}; + + +/** + * Clears the message field making it undefined. + * @return {!proto.litrpc.AccountPaymentsRequest} returns this + */ +proto.litrpc.AccountPaymentsRequest.prototype.clearAccount = function() { + return this.setAccount(undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.litrpc.AccountPaymentsRequest.prototype.hasAccount = function() { + return jspb.Message.getField(this, 1) != null; +}; + + +/** + * optional uint64 max_payments = 2; + * @return {string} + */ +proto.litrpc.AccountPaymentsRequest.prototype.getMaxPayments = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.litrpc.AccountPaymentsRequest} returns this + */ +proto.litrpc.AccountPaymentsRequest.prototype.setMaxPayments = function(value) { + return jspb.Message.setProto3StringIntField(this, 2, value); +}; + + +/** + * optional uint64 index_offset = 3; + * @return {string} + */ +proto.litrpc.AccountPaymentsRequest.prototype.getIndexOffset = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.litrpc.AccountPaymentsRequest} returns this + */ +proto.litrpc.AccountPaymentsRequest.prototype.setIndexOffset = function(value) { + return jspb.Message.setProto3StringIntField(this, 3, value); +}; + + +/** + * optional bool count_total_payments = 4; + * @return {boolean} + */ +proto.litrpc.AccountPaymentsRequest.prototype.getCountTotalPayments = function() { + return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 4, false)); +}; + + +/** + * @param {boolean} value + * @return {!proto.litrpc.AccountPaymentsRequest} returns this + */ +proto.litrpc.AccountPaymentsRequest.prototype.setCountTotalPayments = function(value) { + return jspb.Message.setProto3BooleanField(this, 4, value); +}; + + + +/** + * List of repeated fields within this message type. + * @private {!Array} + * @const + */ +proto.litrpc.AccountPaymentsResponse.repeatedFields_ = [1]; + + + +if (jspb.Message.GENERATE_TO_OBJECT) { +/** + * Creates an object representation of this proto. + * Field names that are reserved in JavaScript and will be renamed to pb_name. + * Optional fields that are not set will be set to undefined. + * To access a reserved field use, foo.pb_, eg, foo.pb_default. + * For the list of reserved names please see: + * net/proto2/compiler/js/internal/generator.cc#kKeyword. + * @param {boolean=} opt_includeInstance Deprecated. whether to include the + * JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @return {!Object} + */ +proto.litrpc.AccountPaymentsResponse.prototype.toObject = function(opt_includeInstance) { + return proto.litrpc.AccountPaymentsResponse.toObject(opt_includeInstance, this); +}; + + +/** + * Static version of the {@see toObject} method. + * @param {boolean|undefined} includeInstance Deprecated. Whether to include + * the JSPB instance for transitional soy proto support: + * http://goto/soy-param-migration + * @param {!proto.litrpc.AccountPaymentsResponse} msg The msg instance to transform. + * @return {!Object} + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.litrpc.AccountPaymentsResponse.toObject = function(includeInstance, msg) { + var f, obj = { + paymentsList: jspb.Message.toObjectList(msg.getPaymentsList(), + lnd_pb.Payment.toObject, includeInstance), + firstIndexOffset: jspb.Message.getFieldWithDefault(msg, 2, "0"), + lastIndexOffset: jspb.Message.getFieldWithDefault(msg, 3, "0"), + totalNumPayments: jspb.Message.getFieldWithDefault(msg, 4, "0") + }; + + if (includeInstance) { + obj.$jspbMessageInstance = msg; + } + return obj; +}; +} + + +/** + * Deserializes binary data (in protobuf wire format). + * @param {jspb.ByteSource} bytes The bytes to deserialize. + * @return {!proto.litrpc.AccountPaymentsResponse} + */ +proto.litrpc.AccountPaymentsResponse.deserializeBinary = function(bytes) { + var reader = new jspb.BinaryReader(bytes); + var msg = new proto.litrpc.AccountPaymentsResponse; + return proto.litrpc.AccountPaymentsResponse.deserializeBinaryFromReader(msg, reader); +}; + + +/** + * Deserializes binary data (in protobuf wire format) from the + * given reader into the given message object. + * @param {!proto.litrpc.AccountPaymentsResponse} msg The message object to deserialize into. + * @param {!jspb.BinaryReader} reader The BinaryReader to use. + * @return {!proto.litrpc.AccountPaymentsResponse} + */ +proto.litrpc.AccountPaymentsResponse.deserializeBinaryFromReader = function(msg, reader) { + while (reader.nextField()) { + if (reader.isEndGroup()) { + break; + } + var field = reader.getFieldNumber(); + switch (field) { + case 1: + var value = new lnd_pb.Payment; + reader.readMessage(value,lnd_pb.Payment.deserializeBinaryFromReader); + msg.addPayments(value); + break; + case 2: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setFirstIndexOffset(value); + break; + case 3: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setLastIndexOffset(value); + break; + case 4: + var value = /** @type {string} */ (reader.readUint64String()); + msg.setTotalNumPayments(value); + break; + default: + reader.skipField(); + break; + } + } + return msg; +}; + + +/** + * Serializes the message to binary data (in protobuf wire format). + * @return {!Uint8Array} + */ +proto.litrpc.AccountPaymentsResponse.prototype.serializeBinary = function() { + var writer = new jspb.BinaryWriter(); + proto.litrpc.AccountPaymentsResponse.serializeBinaryToWriter(this, writer); + return writer.getResultBuffer(); +}; + + +/** + * Serializes the given message to binary data (in protobuf wire + * format), writing to the given BinaryWriter. + * @param {!proto.litrpc.AccountPaymentsResponse} message + * @param {!jspb.BinaryWriter} writer + * @suppress {unusedLocalVariables} f is only used for nested messages + */ +proto.litrpc.AccountPaymentsResponse.serializeBinaryToWriter = function(message, writer) { + var f = undefined; + f = message.getPaymentsList(); + if (f.length > 0) { + writer.writeRepeatedMessage( + 1, + f, + lnd_pb.Payment.serializeBinaryToWriter + ); + } + f = message.getFirstIndexOffset(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 2, + f + ); + } + f = message.getLastIndexOffset(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 3, + f + ); + } + f = message.getTotalNumPayments(); + if (parseInt(f, 10) !== 0) { + writer.writeUint64String( + 4, + f + ); + } +}; + + +/** + * repeated lnrpc.Payment payments = 1; + * @return {!Array} + */ +proto.litrpc.AccountPaymentsResponse.prototype.getPaymentsList = function() { + return /** @type{!Array} */ ( + jspb.Message.getRepeatedWrapperField(this, lnd_pb.Payment, 1)); +}; + + +/** + * @param {!Array} value + * @return {!proto.litrpc.AccountPaymentsResponse} returns this +*/ +proto.litrpc.AccountPaymentsResponse.prototype.setPaymentsList = function(value) { + return jspb.Message.setRepeatedWrapperField(this, 1, value); +}; + + +/** + * @param {!proto.lnrpc.Payment=} opt_value + * @param {number=} opt_index + * @return {!proto.lnrpc.Payment} + */ +proto.litrpc.AccountPaymentsResponse.prototype.addPayments = function(opt_value, opt_index) { + return jspb.Message.addToRepeatedWrapperField(this, 1, opt_value, proto.lnrpc.Payment, opt_index); +}; + + +/** + * Clears the list making it empty but non-null. + * @return {!proto.litrpc.AccountPaymentsResponse} returns this + */ +proto.litrpc.AccountPaymentsResponse.prototype.clearPaymentsList = function() { + return this.setPaymentsList([]); +}; + + +/** + * optional uint64 first_index_offset = 2; + * @return {string} + */ +proto.litrpc.AccountPaymentsResponse.prototype.getFirstIndexOffset = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 2, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.litrpc.AccountPaymentsResponse} returns this + */ +proto.litrpc.AccountPaymentsResponse.prototype.setFirstIndexOffset = function(value) { + return jspb.Message.setProto3StringIntField(this, 2, value); +}; + + +/** + * optional uint64 last_index_offset = 3; + * @return {string} + */ +proto.litrpc.AccountPaymentsResponse.prototype.getLastIndexOffset = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 3, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.litrpc.AccountPaymentsResponse} returns this + */ +proto.litrpc.AccountPaymentsResponse.prototype.setLastIndexOffset = function(value) { + return jspb.Message.setProto3StringIntField(this, 3, value); +}; + + +/** + * optional uint64 total_num_payments = 4; + * @return {string} + */ +proto.litrpc.AccountPaymentsResponse.prototype.getTotalNumPayments = function() { + return /** @type {string} */ (jspb.Message.getFieldWithDefault(this, 4, "0")); +}; + + +/** + * @param {string} value + * @return {!proto.litrpc.AccountPaymentsResponse} returns this + */ +proto.litrpc.AccountPaymentsResponse.prototype.setTotalNumPayments = function(value) { + return jspb.Message.setProto3StringIntField(this, 4, value); +}; + + goog.object.extend(exports, proto.litrpc); diff --git a/app/src/types/generated/lit-accounts_pb_service.d.ts b/app/src/types/generated/lit-accounts_pb_service.d.ts index 33a0d2be6..fb255a052 100644 --- a/app/src/types/generated/lit-accounts_pb_service.d.ts +++ b/app/src/types/generated/lit-accounts_pb_service.d.ts @@ -67,6 +67,15 @@ type AccountsRemoveAccount = { readonly responseType: typeof lit_accounts_pb.RemoveAccountResponse; }; +type AccountsAccountPayments = { + readonly methodName: string; + readonly service: typeof Accounts; + readonly requestStream: false; + readonly responseStream: false; + readonly requestType: typeof lit_accounts_pb.AccountPaymentsRequest; + readonly responseType: typeof lit_accounts_pb.AccountPaymentsResponse; +}; + export class Accounts { static readonly serviceName: string; static readonly CreateAccount: AccountsCreateAccount; @@ -76,6 +85,7 @@ export class Accounts { static readonly ListAccounts: AccountsListAccounts; static readonly AccountInfo: AccountsAccountInfo; static readonly RemoveAccount: AccountsRemoveAccount; + static readonly AccountPayments: AccountsAccountPayments; } export type ServiceError = { message: string, code: number; metadata: grpc.Metadata } @@ -173,5 +183,14 @@ export class AccountsClient { requestMessage: lit_accounts_pb.RemoveAccountRequest, callback: (error: ServiceError|null, responseMessage: lit_accounts_pb.RemoveAccountResponse|null) => void ): UnaryResponse; + accountPayments( + requestMessage: lit_accounts_pb.AccountPaymentsRequest, + metadata: grpc.Metadata, + callback: (error: ServiceError|null, responseMessage: lit_accounts_pb.AccountPaymentsResponse|null) => void + ): UnaryResponse; + accountPayments( + requestMessage: lit_accounts_pb.AccountPaymentsRequest, + callback: (error: ServiceError|null, responseMessage: lit_accounts_pb.AccountPaymentsResponse|null) => void + ): UnaryResponse; } diff --git a/app/src/types/generated/lit-accounts_pb_service.js b/app/src/types/generated/lit-accounts_pb_service.js index bf169c3b4..d4e2ab124 100644 --- a/app/src/types/generated/lit-accounts_pb_service.js +++ b/app/src/types/generated/lit-accounts_pb_service.js @@ -73,6 +73,15 @@ Accounts.RemoveAccount = { responseType: lit_accounts_pb.RemoveAccountResponse }; +Accounts.AccountPayments = { + methodName: "AccountPayments", + service: Accounts, + requestStream: false, + responseStream: false, + requestType: lit_accounts_pb.AccountPaymentsRequest, + responseType: lit_accounts_pb.AccountPaymentsResponse +}; + exports.Accounts = Accounts; function AccountsClient(serviceHost, options) { @@ -297,5 +306,36 @@ AccountsClient.prototype.removeAccount = function removeAccount(requestMessage, }; }; +AccountsClient.prototype.accountPayments = function accountPayments(requestMessage, metadata, callback) { + if (arguments.length === 2) { + callback = arguments[1]; + } + var client = grpc.unary(Accounts.AccountPayments, { + request: requestMessage, + host: this.serviceHost, + metadata: metadata, + transport: this.options.transport, + debug: this.options.debug, + onEnd: function (response) { + if (callback) { + if (response.status !== grpc.Code.OK) { + var err = new Error(response.statusMessage); + err.code = response.status; + err.metadata = response.trailers; + callback(err, null); + } else { + callback(null, response.message); + } + } + } + }); + return { + cancel: function () { + callback = null; + client.close(); + } + }; +}; + exports.AccountsClient = AccountsClient; diff --git a/cmd/litcli/accounts.go b/cmd/litcli/accounts.go index a67ece85a..e06ed1f3d 100644 --- a/cmd/litcli/accounts.go +++ b/cmd/litcli/accounts.go @@ -30,6 +30,7 @@ var accountsCommands = []cli.Command{ listAccountsCommand, accountInfoCommand, removeAccountCommand, + accountPaymentsCommand, }, Description: "Manage accounts.", }, @@ -541,3 +542,84 @@ func parseIDOrLabel(ctx *cli.Context) (string, string, cli.Args, error) { return accountID, label, args, nil } + +var accountPaymentsCommand = cli.Command{ + Name: "payments", + ShortName: "p", + Usage: "Show detailed payment history for a single " + + "off-chain account.", + ArgsUsage: "[id | label]", + Description: "Returns the detailed payment history for an " + + "account by fetching their stored hashes and querying " + + "LND. The results are returned paginated and " + + "sorted in ascending lexicographical order of their " + + "payment hash.", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: idName, + Usage: "The ID of the account.", + }, + cli.StringFlag{ + Name: labelName, + Usage: "(optional) The unique label of the account.", + }, + cli.Uint64Flag{ + Name: "max_payments", + Usage: fmt.Sprintf("The maximum number of payments to "+ + "return. The default value is %d and "+ + "the maximum is %d.", + accounts.DefaultMaxPayments, + accounts.MaxPaymentsLimit), + Value: accounts.DefaultMaxPayments, + }, + cli.Uint64Flag{ + Name: "index_offset", + Usage: "The row offset into the list of payments " + + "that will be used as the start of the " + + "query.", + }, + cli.BoolFlag{ + Name: "count_total_payments", + Usage: "If true, the total number of payments " + + "matching the query will be returned.", + }, + }, + Action: accountPayments, +} + +func accountPayments(cli *cli.Context) error { + ctx := getContext() + clientConn, cleanup, err := connectClient(cli, false) + if err != nil { + return err + } + defer cleanup() + client := litrpc.NewAccountsClient(clientConn) + + account, _, err := parseAccountIdentifier(cli) + if err != nil { + return err + } + + maxPayments := cli.Uint64("max_payments") + if maxPayments > accounts.MaxPaymentsLimit { + return fmt.Errorf( + "max_payments cannot exceed %d", + accounts.MaxPaymentsLimit, + ) + } + + req := &litrpc.AccountPaymentsRequest{ + Account: account, + MaxPayments: maxPayments, + IndexOffset: cli.Uint64("index_offset"), + CountTotalPayments: cli.Bool("count_total_payments"), + } + resp, err := client.AccountPayments(ctx, req) + if err != nil { + return err + } + + printRespJSON(resp) + return nil +} diff --git a/db/sqlc/accounts.sql.go b/db/sqlc/accounts.sql.go index 6322e7092..7b399fc68 100644 --- a/db/sqlc/accounts.sql.go +++ b/db/sqlc/accounts.sql.go @@ -11,6 +11,48 @@ import ( "time" ) +const accountPaymentsPaginated = `-- name: AccountPaymentsPaginated :many +SELECT account_id, hash, status, full_amount_msat +FROM account_payments +WHERE account_id = $1 +ORDER BY hash ASC +LIMIT $2 OFFSET $3 +` + +type AccountPaymentsPaginatedParams struct { + AccountID int64 + Limit int32 + Offset int32 +} + +func (q *Queries) AccountPaymentsPaginated(ctx context.Context, arg AccountPaymentsPaginatedParams) ([]AccountPayment, error) { + rows, err := q.db.QueryContext(ctx, accountPaymentsPaginated, arg.AccountID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AccountPayment + for rows.Next() { + var i AccountPayment + if err := rows.Scan( + &i.AccountID, + &i.Hash, + &i.Status, + &i.FullAmountMsat, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const addAccountInvoice = `-- name: AddAccountInvoice :exec INSERT INTO account_invoices (account_id, hash) VALUES ($1, $2) @@ -26,6 +68,19 @@ func (q *Queries) AddAccountInvoice(ctx context.Context, arg AddAccountInvoicePa return err } +const countAccountPayments = `-- name: CountAccountPayments :one +SELECT COUNT(*) +FROM account_payments +WHERE account_id = $1 +` + +func (q *Queries) CountAccountPayments(ctx context.Context, accountID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countAccountPayments, accountID) + var count int64 + err := row.Scan(&count) + return count, err +} + const deleteAccount = `-- name: DeleteAccount :exec DELETE FROM accounts WHERE id = $1 diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 4b32259f8..96cea34b5 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -10,7 +10,9 @@ import ( ) type Querier interface { + AccountPaymentsPaginated(ctx context.Context, arg AccountPaymentsPaginatedParams) ([]AccountPayment, error) AddAccountInvoice(ctx context.Context, arg AddAccountInvoiceParams) error + CountAccountPayments(ctx context.Context, accountID int64) (int64, error) DeleteAccount(ctx context.Context, id int64) error DeleteAccountPayment(ctx context.Context, arg DeleteAccountPaymentParams) error DeleteAllTempKVStores(ctx context.Context) error diff --git a/db/sqlc/queries/accounts.sql b/db/sqlc/queries/accounts.sql index 4fff012a2..416b74d7a 100644 --- a/db/sqlc/queries/accounts.sql +++ b/db/sqlc/queries/accounts.sql @@ -80,6 +80,18 @@ WHERE account_id = $1; SELECT * FROM account_payments; +-- name: AccountPaymentsPaginated :many +SELECT * +FROM account_payments +WHERE account_id = $1 +ORDER BY hash ASC +LIMIT $2 OFFSET $3; + +-- name: CountAccountPayments :one +SELECT COUNT(*) +FROM account_payments +WHERE account_id = $1; + -- name: ListAccountInvoices :many SELECT * FROM account_invoices diff --git a/docs/release-notes/release-notes-0.17.1.md b/docs/release-notes/release-notes-0.17.1.md index 888c9cfca..ce3ca83d4 100644 --- a/docs/release-notes/release-notes-0.17.1.md +++ b/docs/release-notes/release-notes-0.17.1.md @@ -33,6 +33,12 @@ ### Functional Changes/Additions +* [Add accounts payments history subcommand](https://github.com/lightninglabs/lightning-terminal/pull/1316): + Added the `litcli accounts payments` subcommand and corresponding gRPC + endpoint `AccountPayments` to retrieve the off-chain payment history of + an account, supporting pagination (sorted in ascending lexicographical + order of their payment hash) and counting of total payments. + ### Technical and Architectural Updates * [Report litd's own version for `litd @@ -57,4 +63,5 @@ * 0xfandom * bitromortac +* Cyberguru1 * Vandit Singh diff --git a/itest/litd_accounts_test.go b/itest/litd_accounts_test.go index 9fa8d55a4..a578f214e 100644 --- a/itest/litd_accounts_test.go +++ b/itest/litd_accounts_test.go @@ -2,8 +2,11 @@ package itest import ( "context" + "crypto/rand" + "encoding/hex" "fmt" "os" + "sort" "strings" "testing" "time" @@ -14,8 +17,10 @@ import ( "github.com/lightninglabs/taproot-assets/rpcutils" "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lnrpc/routerrpc" "github.com/lightningnetwork/lnd/lntest" + "github.com/lightningnetwork/lnd/lntypes" "github.com/stretchr/testify/require" "google.golang.org/grpc" ) @@ -164,6 +169,225 @@ func runAccountSystemTest(t *harnessTest, node *HarnessNode, hostPort, ctxa, t, rawConn, charlie, acctBalance, ) + // Initiate a HODL payment (which remains IN_FLIGHT) and a FAILED + // payment to verify that the AccountPayments RPC correctly retrieves + // and reports payments across all potential lifecycles (SUCCEEDED, + // IN_FLIGHT, FAILED). + testCtx, testCancel := context.WithTimeout(ctxb, defaultTimeout) + defer testCancel() + + routerClient := routerrpc.NewRouterClient(rawConn) + + // 1. Initiate HODL payment (IN_FLIGHT) + var preimage lntypes.Preimage + _, err = rand.Read(preimage[:]) + require.NoError(t.t, err) + holdHash := preimage.Hash() + + holdInv, err := charlie.AddHoldInvoice( + testCtx, &invoicesrpc.AddHoldInvoiceRequest{ + Hash: holdHash[:], + Value: 2222, + }, + ) + require.NoError(t.t, err) + + sendReqHold := &routerrpc.SendPaymentRequest{ + PaymentRequest: holdInv.PaymentRequest, + TimeoutSeconds: 2, + FeeLimitMsat: 1000, + } + holdStream, err := routerClient.SendPaymentV2(ctxa, sendReqHold) + require.NoError(t.t, err) + + holdPayment, err := getPaymentResult(holdStream, true) + require.NoError(t.t, err) + require.Equal(t.t, lnrpc.Payment_IN_FLIGHT, holdPayment.Status) + + // Wait for the hold invoice to be accepted by Charlie. + require.Eventually(t.t, func() bool { + inv, err := charlie.LookupInvoice( + testCtx, &lnrpc.PaymentHash{ + RHash: holdHash[:], + }, + ) + + return err == nil && + inv.State == lnrpc.Invoice_ACCEPTED + }, defaultTimeout, 100*time.Millisecond) + + // 2. Initiate a FAILED payment + var failedHash lntypes.Hash + _, err = rand.Read(failedHash[:]) + require.NoError(t.t, err) + + var fakePubKey [33]byte + fakePubKey[0] = 0x02 + sendReqFailed := &routerrpc.SendPaymentRequest{ + Dest: fakePubKey[:], + Amt: 1111, + PaymentHash: failedHash[:], + TimeoutSeconds: 2, + FeeLimitMsat: 1000, + } + failedStream, err := routerClient.SendPaymentV2(ctxa, sendReqFailed) + require.NoError(t.t, err) + + failedPayment, err := getPaymentResult(failedStream, false) + require.NoError(t.t, err) + require.Equal(t.t, lnrpc.Payment_FAILED, failedPayment.Status) + + // Test AccountPayments RPC with all 3 payments (succeeded, + // in-flight, failed). + paymentsResp, err := acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + CountTotalPayments: true, + }, + ) + require.NoError(t.t, err) + require.Len(t.t, paymentsResp.Payments, 3) + + // Sort the payments by value descending to ensure index-based + // assertions are deterministic regardless of database sorting by hash. + sort.Slice(paymentsResp.Payments, func(i, j int) bool { + valI := paymentsResp.Payments[i].ValueSat + valJ := paymentsResp.Payments[j].ValueSat + + return valI > valJ + }) + + // Check the succeeded payment. This corresponds to the 4444 sat + // payment initiated earlier in testAccountRestrictions using this + // account. + require.Equal( + t.t, uint64(4444), + uint64(paymentsResp.Payments[0].ValueSat), + ) + require.Equal( + t.t, lnrpc.Payment_SUCCEEDED, + paymentsResp.Payments[0].Status, + ) + + // Check In-flight payment + require.Equal( + t.t, uint64(2222), + uint64(paymentsResp.Payments[1].ValueSat), + ) + require.Equal( + t.t, lnrpc.Payment_IN_FLIGHT, + paymentsResp.Payments[1].Status, + ) + + // Check Failed payment + require.Equal( + t.t, uint64(1111), + uint64(paymentsResp.Payments[2].ValueSat), + ) + require.Equal( + t.t, lnrpc.Payment_FAILED, + paymentsResp.Payments[2].Status, + ) + + require.EqualValues(t.t, 3, paymentsResp.TotalNumPayments) + require.EqualValues(t.t, 0, paymentsResp.FirstIndexOffset) + require.EqualValues(t.t, 3, paymentsResp.LastIndexOffset) + + // Query by label. + paymentsResp, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Label{ + Label: acctLabel, + }, + }, + }, + ) + require.NoError(t.t, err) + require.Len(t.t, paymentsResp.Payments, 3) + + // Query with pagination limit. + paymentsResp, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + MaxPayments: 2, + }, + ) + require.NoError(t.t, err) + require.Len(t.t, paymentsResp.Payments, 2) + require.EqualValues(t.t, 0, paymentsResp.FirstIndexOffset) + require.EqualValues(t.t, 2, paymentsResp.LastIndexOffset) + + // Query with pagination offset out of bounds. + paymentsResp, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + IndexOffset: 3, + }, + ) + require.NoError(t.t, err) + require.Empty(t.t, paymentsResp.Payments) + require.EqualValues(t.t, 0, paymentsResp.FirstIndexOffset) + require.EqualValues(t.t, 0, paymentsResp.LastIndexOffset) + + // Query with pagination offset inside bounds, where the number of + // payments returned is fewer than MaxPayments. + paymentsResp, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + IndexOffset: 2, + MaxPayments: 2, + }, + ) + require.NoError(t.t, err) + require.Len(t.t, paymentsResp.Payments, 1) + require.EqualValues(t.t, 2, paymentsResp.FirstIndexOffset) + require.EqualValues(t.t, 3, paymentsResp.LastIndexOffset) + + // Query with invalid pagination max_payments (exceeding 50). + _, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + MaxPayments: 51, + }, + ) + require.Error(t.t, err) + require.Contains(t.t, err.Error(), "max_payments cannot exceed 50") + + // Query with invalid pagination index_offset (exceeding 31-bit int). + _, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + IndexOffset: 0x80000000, + }, + ) + require.Error(t.t, err) + require.Contains(t.t, err.Error(), "index_offset out of range") + // Test the same account restrictions with an LNC session that is bound // to the account. testAccountRestrictionsLNC( @@ -176,6 +400,84 @@ func runAccountSystemTest(t *harnessTest, node *HarnessNode, hostPort, ctxa, t, rawConn, charlie, newAcctBalance, ) + // Settle the HODL invoice to clean up node state. + _, err = charlie.SettleInvoice(testCtx, &invoicesrpc.SettleInvoiceMsg{ + Preimage: preimage[:], + }) + require.NoError(t.t, err) + + // Delete a single failed payment from LND to simulate a desync case + // where this payment exists in LiT's account database but is + // deleted/absent in LND. + _, err = node.LightningClient.DeletePayment( + testCtx, &lnrpc.DeletePaymentRequest{ + PaymentHash: failedHash[:], + }, + ) + require.NoError(t.t, err) + + // Now call AccountPayments. It should find all 3 payments in the local + // store, returning a placeholder entry with status Payment_UNKNOWN for + // the deleted payment that was not found in LND. + paymentsResp, err = acctClient.AccountPayments( + ctxm, &litrpc.AccountPaymentsRequest{ + Account: &litrpc.AccountIdentifier{ + Identifier: &litrpc.AccountIdentifier_Id{ + Id: acctResp.Account.Id, + }, + }, + CountTotalPayments: true, + }, + ) + require.NoError(t.t, err) + require.Len(t.t, paymentsResp.Payments, 3) + + // Sort the payments by value descending to ensure index-based + // assertions are deterministic regardless of database sorting by hash. + sort.Slice(paymentsResp.Payments, func(i, j int) bool { + valI := paymentsResp.Payments[i].ValueSat + valJ := paymentsResp.Payments[j].ValueSat + + return valI > valJ + }) + + // Succeeded payment + require.Equal( + t.t, uint64(4444), + uint64(paymentsResp.Payments[0].ValueSat), + ) + require.Equal( + t.t, lnrpc.Payment_SUCCEEDED, + paymentsResp.Payments[0].Status, + ) + + // Settled hold payment + require.Equal( + t.t, uint64(2222), + uint64(paymentsResp.Payments[1].ValueSat), + ) + require.Equal( + t.t, lnrpc.Payment_SUCCEEDED, + paymentsResp.Payments[1].Status, + ) + + // Placeholder payment for the desynced/deleted payment + require.Equal( + t.t, hex.EncodeToString(failedHash[:]), + paymentsResp.Payments[2].PaymentHash, + ) + require.Equal( + t.t, lnrpc.Payment_UNKNOWN, + paymentsResp.Payments[2].Status, + ) + require.Equal( + t.t, lnrpc.PaymentFailureReason_FAILURE_REASON_NONE, + paymentsResp.Payments[2].FailureReason, + ) + + require.EqualValues(t.t, 3, paymentsResp.TotalNumPayments) + require.EqualValues(t.t, 0, paymentsResp.FirstIndexOffset) + require.EqualValues(t.t, 3, paymentsResp.LastIndexOffset) // Clean up our channel and payments, so we can start the next test // iteration with a clean slate. closeChannelAndAssert(t, net, node, channelOp, false) @@ -246,7 +548,7 @@ func testAccountRestrictionsLNC(ctxm context.Context, t *harnessTest, // There should be invoices and payments from the previous test over RPC // directly. assertNumInvoices(ctxt, t.t, lightningClient, 1) - assertNumPayments(ctxt, t.t, lightningClient, 1) + assertNumPayments(ctxt, t.t, lightningClient, 3) } // testAccountRestrictions tests the different scenarios in which the account diff --git a/litrpc/accounts.pb.json.go b/litrpc/accounts.pb.json.go index 37773a39c..21618b811 100644 --- a/litrpc/accounts.pb.json.go +++ b/litrpc/accounts.pb.json.go @@ -195,4 +195,29 @@ func RegisterAccountsJSONCallbacks(registry map[string]func(ctx context.Context, } callback(string(respBytes), nil) } + + registry["litrpc.Accounts.AccountPayments"] = func(ctx context.Context, + conn *grpc.ClientConn, reqJSON string, callback func(string, error)) { + + req := &AccountPaymentsRequest{} + err := marshaler.Unmarshal([]byte(reqJSON), req) + if err != nil { + callback("", err) + return + } + + client := NewAccountsClient(conn) + resp, err := client.AccountPayments(ctx, req) + if err != nil { + callback("", err) + return + } + + respBytes, err := marshaler.Marshal(resp) + if err != nil { + callback("", err) + return + } + callback(string(respBytes), nil) + } } diff --git a/litrpc/lit-accounts.pb.go b/litrpc/lit-accounts.pb.go index 2fc9aed0b..f87de7005 100644 --- a/litrpc/lit-accounts.pb.go +++ b/litrpc/lit-accounts.pb.go @@ -7,6 +7,7 @@ package litrpc import ( + lnrpc "github.com/lightningnetwork/lnd/lnrpc" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -960,11 +961,162 @@ func (*AccountIdentifier_Id) isAccountIdentifier_Identifier() {} func (*AccountIdentifier_Label) isAccountIdentifier_Identifier() {} +type AccountPaymentsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The identifier of the account to query payments for. + Account *AccountIdentifier `protobuf:"bytes,1,opt,name=account,proto3" json:"account,omitempty"` + // The maximum number of payments to return. If set to 0, it will default + // to 20. Capped at 50. + MaxPayments uint64 `protobuf:"varint,2,opt,name=max_payments,json=maxPayments,proto3" json:"max_payments,omitempty"` + // The row offset into the list of payments that will be used as the start of + // the query. The payments are returned in ascending lexicographical order of + // their payment hash. + IndexOffset uint64 `protobuf:"varint,3,opt,name=index_offset,json=indexOffset,proto3" json:"index_offset,omitempty"` + // If set, the total number of payments matching the query will be returned + // in the response. + CountTotalPayments bool `protobuf:"varint,4,opt,name=count_total_payments,json=countTotalPayments,proto3" json:"count_total_payments,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountPaymentsRequest) Reset() { + *x = AccountPaymentsRequest{} + mi := &file_lit_accounts_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountPaymentsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountPaymentsRequest) ProtoMessage() {} + +func (x *AccountPaymentsRequest) ProtoReflect() protoreflect.Message { + mi := &file_lit_accounts_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountPaymentsRequest.ProtoReflect.Descriptor instead. +func (*AccountPaymentsRequest) Descriptor() ([]byte, []int) { + return file_lit_accounts_proto_rawDescGZIP(), []int{16} +} + +func (x *AccountPaymentsRequest) GetAccount() *AccountIdentifier { + if x != nil { + return x.Account + } + return nil +} + +func (x *AccountPaymentsRequest) GetMaxPayments() uint64 { + if x != nil { + return x.MaxPayments + } + return 0 +} + +func (x *AccountPaymentsRequest) GetIndexOffset() uint64 { + if x != nil { + return x.IndexOffset + } + return 0 +} + +func (x *AccountPaymentsRequest) GetCountTotalPayments() bool { + if x != nil { + return x.CountTotalPayments + } + return false +} + +type AccountPaymentsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The detailed payments associated with the account, sorted in ascending + // lexicographical order of their payment hash. + Payments []*lnrpc.Payment `protobuf:"bytes,1,rep,name=payments,proto3" json:"payments,omitempty"` + // The row offset of the first payment returned. + FirstIndexOffset uint64 `protobuf:"varint,2,opt,name=first_index_offset,json=firstIndexOffset,proto3" json:"first_index_offset,omitempty"` + // The row offset of the last payment returned. This can be used as the + // index_offset in a subsequent query to paginate forwards. + LastIndexOffset uint64 `protobuf:"varint,3,opt,name=last_index_offset,json=lastIndexOffset,proto3" json:"last_index_offset,omitempty"` + // The total number of payments matching the query (only set if + // count_total_payments was true in the request). + TotalNumPayments uint64 `protobuf:"varint,4,opt,name=total_num_payments,json=totalNumPayments,proto3" json:"total_num_payments,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AccountPaymentsResponse) Reset() { + *x = AccountPaymentsResponse{} + mi := &file_lit_accounts_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AccountPaymentsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AccountPaymentsResponse) ProtoMessage() {} + +func (x *AccountPaymentsResponse) ProtoReflect() protoreflect.Message { + mi := &file_lit_accounts_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AccountPaymentsResponse.ProtoReflect.Descriptor instead. +func (*AccountPaymentsResponse) Descriptor() ([]byte, []int) { + return file_lit_accounts_proto_rawDescGZIP(), []int{17} +} + +func (x *AccountPaymentsResponse) GetPayments() []*lnrpc.Payment { + if x != nil { + return x.Payments + } + return nil +} + +func (x *AccountPaymentsResponse) GetFirstIndexOffset() uint64 { + if x != nil { + return x.FirstIndexOffset + } + return 0 +} + +func (x *AccountPaymentsResponse) GetLastIndexOffset() uint64 { + if x != nil { + return x.LastIndexOffset + } + return 0 +} + +func (x *AccountPaymentsResponse) GetTotalNumPayments() uint64 { + if x != nil { + return x.TotalNumPayments + } + return 0 +} + var File_lit_accounts_proto protoreflect.FileDescriptor const file_lit_accounts_proto_rawDesc = "" + "\n" + - "\x12lit-accounts.proto\x12\x06litrpc\"~\n" + + "\x12lit-accounts.proto\x12\x06litrpc\x1a\x0fproto/lnd.proto\"~\n" + "\x14CreateAccountRequest\x12'\n" + "\x0faccount_balance\x18\x01 \x01(\x04R\x0eaccountBalance\x12'\n" + "\x0fexpiration_date\x18\x02 \x01(\x03R\x0eexpirationDate\x12\x14\n" + @@ -1019,7 +1171,17 @@ const file_lit_accounts_proto_rawDesc = "" + "\x02id\x18\x01 \x01(\tH\x00R\x02id\x12\x16\n" + "\x05label\x18\x02 \x01(\tH\x00R\x05labelB\f\n" + "\n" + - "identifier2\x86\x04\n" + + "identifier\"\xc5\x01\n" + + "\x16AccountPaymentsRequest\x123\n" + + "\aaccount\x18\x01 \x01(\v2\x19.litrpc.AccountIdentifierR\aaccount\x12!\n" + + "\fmax_payments\x18\x02 \x01(\x04R\vmaxPayments\x12!\n" + + "\findex_offset\x18\x03 \x01(\x04R\vindexOffset\x120\n" + + "\x14count_total_payments\x18\x04 \x01(\bR\x12countTotalPayments\"\xcd\x01\n" + + "\x17AccountPaymentsResponse\x12*\n" + + "\bpayments\x18\x01 \x03(\v2\x0e.lnrpc.PaymentR\bpayments\x12,\n" + + "\x12first_index_offset\x18\x02 \x01(\x04R\x10firstIndexOffset\x12*\n" + + "\x11last_index_offset\x18\x03 \x01(\x04R\x0flastIndexOffset\x12,\n" + + "\x12total_num_payments\x18\x04 \x01(\x04R\x10totalNumPayments2\xda\x04\n" + "\bAccounts\x12L\n" + "\rCreateAccount\x12\x1c.litrpc.CreateAccountRequest\x1a\x1d.litrpc.CreateAccountResponse\x12>\n" + "\rUpdateAccount\x12\x1c.litrpc.UpdateAccountRequest\x1a\x0f.litrpc.Account\x12L\n" + @@ -1027,7 +1189,8 @@ const file_lit_accounts_proto_rawDesc = "" + "\fDebitAccount\x12\x1b.litrpc.DebitAccountRequest\x1a\x1c.litrpc.DebitAccountResponse\x12I\n" + "\fListAccounts\x12\x1b.litrpc.ListAccountsRequest\x1a\x1c.litrpc.ListAccountsResponse\x12:\n" + "\vAccountInfo\x12\x1a.litrpc.AccountInfoRequest\x1a\x0f.litrpc.Account\x12L\n" + - "\rRemoveAccount\x12\x1c.litrpc.RemoveAccountRequest\x1a\x1d.litrpc.RemoveAccountResponseB4Z2github.com/lightninglabs/lightning-terminal/litrpcb\x06proto3" + "\rRemoveAccount\x12\x1c.litrpc.RemoveAccountRequest\x1a\x1d.litrpc.RemoveAccountResponse\x12R\n" + + "\x0fAccountPayments\x12\x1e.litrpc.AccountPaymentsRequest\x1a\x1f.litrpc.AccountPaymentsResponseB4Z2github.com/lightninglabs/lightning-terminal/litrpcb\x06proto3" var ( file_lit_accounts_proto_rawDescOnce sync.Once @@ -1041,24 +1204,27 @@ func file_lit_accounts_proto_rawDescGZIP() []byte { return file_lit_accounts_proto_rawDescData } -var file_lit_accounts_proto_msgTypes = make([]protoimpl.MessageInfo, 16) +var file_lit_accounts_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_lit_accounts_proto_goTypes = []any{ - (*CreateAccountRequest)(nil), // 0: litrpc.CreateAccountRequest - (*CreateAccountResponse)(nil), // 1: litrpc.CreateAccountResponse - (*Account)(nil), // 2: litrpc.Account - (*AccountInvoice)(nil), // 3: litrpc.AccountInvoice - (*AccountPayment)(nil), // 4: litrpc.AccountPayment - (*UpdateAccountRequest)(nil), // 5: litrpc.UpdateAccountRequest - (*CreditAccountRequest)(nil), // 6: litrpc.CreditAccountRequest - (*CreditAccountResponse)(nil), // 7: litrpc.CreditAccountResponse - (*DebitAccountRequest)(nil), // 8: litrpc.DebitAccountRequest - (*DebitAccountResponse)(nil), // 9: litrpc.DebitAccountResponse - (*ListAccountsRequest)(nil), // 10: litrpc.ListAccountsRequest - (*ListAccountsResponse)(nil), // 11: litrpc.ListAccountsResponse - (*AccountInfoRequest)(nil), // 12: litrpc.AccountInfoRequest - (*RemoveAccountRequest)(nil), // 13: litrpc.RemoveAccountRequest - (*RemoveAccountResponse)(nil), // 14: litrpc.RemoveAccountResponse - (*AccountIdentifier)(nil), // 15: litrpc.AccountIdentifier + (*CreateAccountRequest)(nil), // 0: litrpc.CreateAccountRequest + (*CreateAccountResponse)(nil), // 1: litrpc.CreateAccountResponse + (*Account)(nil), // 2: litrpc.Account + (*AccountInvoice)(nil), // 3: litrpc.AccountInvoice + (*AccountPayment)(nil), // 4: litrpc.AccountPayment + (*UpdateAccountRequest)(nil), // 5: litrpc.UpdateAccountRequest + (*CreditAccountRequest)(nil), // 6: litrpc.CreditAccountRequest + (*CreditAccountResponse)(nil), // 7: litrpc.CreditAccountResponse + (*DebitAccountRequest)(nil), // 8: litrpc.DebitAccountRequest + (*DebitAccountResponse)(nil), // 9: litrpc.DebitAccountResponse + (*ListAccountsRequest)(nil), // 10: litrpc.ListAccountsRequest + (*ListAccountsResponse)(nil), // 11: litrpc.ListAccountsResponse + (*AccountInfoRequest)(nil), // 12: litrpc.AccountInfoRequest + (*RemoveAccountRequest)(nil), // 13: litrpc.RemoveAccountRequest + (*RemoveAccountResponse)(nil), // 14: litrpc.RemoveAccountResponse + (*AccountIdentifier)(nil), // 15: litrpc.AccountIdentifier + (*AccountPaymentsRequest)(nil), // 16: litrpc.AccountPaymentsRequest + (*AccountPaymentsResponse)(nil), // 17: litrpc.AccountPaymentsResponse + (*lnrpc.Payment)(nil), // 18: lnrpc.Payment } var file_lit_accounts_proto_depIdxs = []int32{ 2, // 0: litrpc.CreateAccountResponse.account:type_name -> litrpc.Account @@ -1069,25 +1235,29 @@ var file_lit_accounts_proto_depIdxs = []int32{ 15, // 5: litrpc.DebitAccountRequest.account:type_name -> litrpc.AccountIdentifier 2, // 6: litrpc.DebitAccountResponse.account:type_name -> litrpc.Account 2, // 7: litrpc.ListAccountsResponse.accounts:type_name -> litrpc.Account - 0, // 8: litrpc.Accounts.CreateAccount:input_type -> litrpc.CreateAccountRequest - 5, // 9: litrpc.Accounts.UpdateAccount:input_type -> litrpc.UpdateAccountRequest - 6, // 10: litrpc.Accounts.CreditAccount:input_type -> litrpc.CreditAccountRequest - 8, // 11: litrpc.Accounts.DebitAccount:input_type -> litrpc.DebitAccountRequest - 10, // 12: litrpc.Accounts.ListAccounts:input_type -> litrpc.ListAccountsRequest - 12, // 13: litrpc.Accounts.AccountInfo:input_type -> litrpc.AccountInfoRequest - 13, // 14: litrpc.Accounts.RemoveAccount:input_type -> litrpc.RemoveAccountRequest - 1, // 15: litrpc.Accounts.CreateAccount:output_type -> litrpc.CreateAccountResponse - 2, // 16: litrpc.Accounts.UpdateAccount:output_type -> litrpc.Account - 7, // 17: litrpc.Accounts.CreditAccount:output_type -> litrpc.CreditAccountResponse - 9, // 18: litrpc.Accounts.DebitAccount:output_type -> litrpc.DebitAccountResponse - 11, // 19: litrpc.Accounts.ListAccounts:output_type -> litrpc.ListAccountsResponse - 2, // 20: litrpc.Accounts.AccountInfo:output_type -> litrpc.Account - 14, // 21: litrpc.Accounts.RemoveAccount:output_type -> litrpc.RemoveAccountResponse - 15, // [15:22] is the sub-list for method output_type - 8, // [8:15] is the sub-list for method input_type - 8, // [8:8] is the sub-list for extension type_name - 8, // [8:8] is the sub-list for extension extendee - 0, // [0:8] is the sub-list for field type_name + 15, // 8: litrpc.AccountPaymentsRequest.account:type_name -> litrpc.AccountIdentifier + 18, // 9: litrpc.AccountPaymentsResponse.payments:type_name -> lnrpc.Payment + 0, // 10: litrpc.Accounts.CreateAccount:input_type -> litrpc.CreateAccountRequest + 5, // 11: litrpc.Accounts.UpdateAccount:input_type -> litrpc.UpdateAccountRequest + 6, // 12: litrpc.Accounts.CreditAccount:input_type -> litrpc.CreditAccountRequest + 8, // 13: litrpc.Accounts.DebitAccount:input_type -> litrpc.DebitAccountRequest + 10, // 14: litrpc.Accounts.ListAccounts:input_type -> litrpc.ListAccountsRequest + 12, // 15: litrpc.Accounts.AccountInfo:input_type -> litrpc.AccountInfoRequest + 13, // 16: litrpc.Accounts.RemoveAccount:input_type -> litrpc.RemoveAccountRequest + 16, // 17: litrpc.Accounts.AccountPayments:input_type -> litrpc.AccountPaymentsRequest + 1, // 18: litrpc.Accounts.CreateAccount:output_type -> litrpc.CreateAccountResponse + 2, // 19: litrpc.Accounts.UpdateAccount:output_type -> litrpc.Account + 7, // 20: litrpc.Accounts.CreditAccount:output_type -> litrpc.CreditAccountResponse + 9, // 21: litrpc.Accounts.DebitAccount:output_type -> litrpc.DebitAccountResponse + 11, // 22: litrpc.Accounts.ListAccounts:output_type -> litrpc.ListAccountsResponse + 2, // 23: litrpc.Accounts.AccountInfo:output_type -> litrpc.Account + 14, // 24: litrpc.Accounts.RemoveAccount:output_type -> litrpc.RemoveAccountResponse + 17, // 25: litrpc.Accounts.AccountPayments:output_type -> litrpc.AccountPaymentsResponse + 18, // [18:26] is the sub-list for method output_type + 10, // [10:18] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_lit_accounts_proto_init() } @@ -1105,7 +1275,7 @@ func file_lit_accounts_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_lit_accounts_proto_rawDesc), len(file_lit_accounts_proto_rawDesc)), NumEnums: 0, - NumMessages: 16, + NumMessages: 18, NumExtensions: 0, NumServices: 1, }, diff --git a/litrpc/lit-accounts.pb.gw.go b/litrpc/lit-accounts.pb.gw.go index 9db31213d..d08e7d829 100644 --- a/litrpc/lit-accounts.pb.gw.go +++ b/litrpc/lit-accounts.pb.gw.go @@ -325,6 +325,42 @@ func local_request_Accounts_RemoveAccount_0(ctx context.Context, marshaler runti } +var ( + filter_Accounts_AccountPayments_0 = &utilities.DoubleArray{Encoding: map[string]int{}, Base: []int(nil), Check: []int(nil)} +) + +func request_Accounts_AccountPayments_0(ctx context.Context, marshaler runtime.Marshaler, client AccountsClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq AccountPaymentsRequest + var metadata runtime.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Accounts_AccountPayments_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.AccountPayments(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_Accounts_AccountPayments_0(ctx context.Context, marshaler runtime.Marshaler, server AccountsServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq AccountPaymentsRequest + var metadata runtime.ServerMetadata + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_Accounts_AccountPayments_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.AccountPayments(ctx, &protoReq) + return msg, metadata, err + +} + // RegisterAccountsHandlerServer registers the http handlers for service Accounts to "mux". // UnaryRPC :call AccountsServer directly. // StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. @@ -482,6 +518,31 @@ func RegisterAccountsHandlerServer(ctx context.Context, mux *runtime.ServeMux, s }) + mux.Handle("GET", pattern_Accounts_AccountPayments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/litrpc.Accounts/AccountPayments", runtime.WithHTTPPathPattern("/v1/accounts/payments")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_Accounts_AccountPayments_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_Accounts_AccountPayments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + return nil } @@ -655,6 +716,28 @@ func RegisterAccountsHandlerClient(ctx context.Context, mux *runtime.ServeMux, c }) + mux.Handle("GET", pattern_Accounts_AccountPayments_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/litrpc.Accounts/AccountPayments", runtime.WithHTTPPathPattern("/v1/accounts/payments")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_Accounts_AccountPayments_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_Accounts_AccountPayments_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + return nil } @@ -670,6 +753,8 @@ var ( pattern_Accounts_ListAccounts_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"v1", "accounts"}, "")) pattern_Accounts_RemoveAccount_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"v1", "accounts", "id"}, "")) + + pattern_Accounts_AccountPayments_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2}, []string{"v1", "accounts", "payments"}, "")) ) var ( @@ -684,4 +769,6 @@ var ( forward_Accounts_ListAccounts_0 = runtime.ForwardResponseMessage forward_Accounts_RemoveAccount_0 = runtime.ForwardResponseMessage + + forward_Accounts_AccountPayments_0 = runtime.ForwardResponseMessage ) diff --git a/litrpc/lit-accounts.proto b/litrpc/lit-accounts.proto index 8545cdc89..951d63e60 100644 --- a/litrpc/lit-accounts.proto +++ b/litrpc/lit-accounts.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package litrpc; +import "proto/lnd.proto"; + option go_package = "github.com/lightninglabs/lightning-terminal/litrpc"; service Accounts { @@ -52,6 +54,12 @@ service Accounts { RemoveAccount removes the given account from the account database. */ rpc RemoveAccount (RemoveAccountRequest) returns (RemoveAccountResponse); + + /* litcli: `accounts payments` + AccountPayments returns the detailed payment history for the given account. + */ + rpc AccountPayments (AccountPaymentsRequest) + returns (AccountPaymentsResponse); } message CreateAccountRequest { @@ -244,4 +252,55 @@ message AccountIdentifier { // The label of the account. string label = 2; } +} + +message AccountPaymentsRequest { + /* + The identifier of the account to query payments for. + */ + AccountIdentifier account = 1; + + /* + The maximum number of payments to return. If set to 0, it will default + to 20. Capped at 50. + */ + uint64 max_payments = 2; + + /* + The row offset into the list of payments that will be used as the start of + the query. The payments are returned in ascending lexicographical order of + their payment hash. + */ + uint64 index_offset = 3; + + /* + If set, the total number of payments matching the query will be returned + in the response. + */ + bool count_total_payments = 4; +} + +message AccountPaymentsResponse { + /* + The detailed payments associated with the account, sorted in ascending + lexicographical order of their payment hash. + */ + repeated lnrpc.Payment payments = 1; + + /* + The row offset of the first payment returned. + */ + uint64 first_index_offset = 2; + + /* + The row offset of the last payment returned. This can be used as the + index_offset in a subsequent query to paginate forwards. + */ + uint64 last_index_offset = 3; + + /* + The total number of payments matching the query (only set if + count_total_payments was true in the request). + */ + uint64 total_num_payments = 4; } \ No newline at end of file diff --git a/litrpc/lit-accounts.swagger.json b/litrpc/lit-accounts.swagger.json index 76b6dd2a3..7382ccd60 100644 --- a/litrpc/lit-accounts.swagger.json +++ b/litrpc/lit-accounts.swagger.json @@ -151,6 +151,68 @@ ] } }, + "/v1/accounts/payments": { + "get": { + "summary": "litcli: `accounts payments`\nAccountPayments returns the detailed payment history for the given account.", + "operationId": "Accounts_AccountPayments", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/litrpcAccountPaymentsResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "account.id", + "description": "The ID of the account.", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "account.label", + "description": "The label of the account.", + "in": "query", + "required": false, + "type": "string" + }, + { + "name": "max_payments", + "description": "The maximum number of payments to return. If set to 0, it will default\nto 20. Capped at 50.", + "in": "query", + "required": false, + "type": "string", + "format": "uint64" + }, + { + "name": "index_offset", + "description": "The row offset into the list of payments that will be used as the start of\nthe query. The payments are returned in ascending lexicographical order of\ntheir payment hash.", + "in": "query", + "required": false, + "type": "string", + "format": "uint64" + }, + { + "name": "count_total_payments", + "description": "If set, the total number of payments matching the query will be returned\nin the response.", + "in": "query", + "required": false, + "type": "boolean" + } + ], + "tags": [ + "Accounts" + ] + } + }, "/v1/accounts/{id}": { "delete": { "summary": "litcli: `accounts remove`\nRemoveAccount removes the given account from the account database.", @@ -295,6 +357,63 @@ } } }, + "FailureFailureCode": { + "type": "string", + "enum": [ + "RESERVED", + "INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS", + "INCORRECT_PAYMENT_AMOUNT", + "FINAL_INCORRECT_CLTV_EXPIRY", + "FINAL_INCORRECT_HTLC_AMOUNT", + "FINAL_EXPIRY_TOO_SOON", + "INVALID_REALM", + "EXPIRY_TOO_SOON", + "INVALID_ONION_VERSION", + "INVALID_ONION_HMAC", + "INVALID_ONION_KEY", + "AMOUNT_BELOW_MINIMUM", + "FEE_INSUFFICIENT", + "INCORRECT_CLTV_EXPIRY", + "CHANNEL_DISABLED", + "TEMPORARY_CHANNEL_FAILURE", + "REQUIRED_NODE_FEATURE_MISSING", + "REQUIRED_CHANNEL_FEATURE_MISSING", + "UNKNOWN_NEXT_PEER", + "TEMPORARY_NODE_FAILURE", + "PERMANENT_NODE_FAILURE", + "PERMANENT_CHANNEL_FAILURE", + "EXPIRY_TOO_FAR", + "MPP_TIMEOUT", + "INVALID_ONION_PAYLOAD", + "INVALID_ONION_BLINDING", + "INTERNAL_FAILURE", + "UNKNOWN_FAILURE", + "UNREADABLE_FAILURE" + ], + "default": "RESERVED", + "description": " - RESERVED: The numbers assigned in this enumeration match the failure codes as\ndefined in BOLT #4. Because protobuf 3 requires enums to start with 0,\na RESERVED value is added.\n - INTERNAL_FAILURE: An internal error occurred.\n - UNKNOWN_FAILURE: The error source is known, but the failure itself couldn't be decoded.\n - UNREADABLE_FAILURE: An unreadable failure result is returned if the received failure message\ncannot be decrypted. In that case the error source is unknown." + }, + "HTLCAttemptHTLCStatus": { + "type": "string", + "enum": [ + "IN_FLIGHT", + "SUCCEEDED", + "FAILED" + ], + "default": "IN_FLIGHT" + }, + "PaymentPaymentStatus": { + "type": "string", + "enum": [ + "UNKNOWN", + "IN_FLIGHT", + "SUCCEEDED", + "FAILED", + "INITIATED" + ], + "default": "UNKNOWN", + "description": " - UNKNOWN: Deprecated. This status will never be returned.\n - IN_FLIGHT: Payment has inflight HTLCs.\n - SUCCEEDED: Payment is settled.\n - FAILED: Payment is failed.\n - INITIATED: Payment is created and has not attempted any HTLCs." + }, "litrpcAccount": { "type": "object", "properties": { @@ -386,6 +505,34 @@ } } }, + "litrpcAccountPaymentsResponse": { + "type": "object", + "properties": { + "payments": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/lnrpcPayment" + }, + "description": "The detailed payments associated with the account, sorted in ascending\nlexicographical order of their payment hash." + }, + "first_index_offset": { + "type": "string", + "format": "uint64", + "description": "The row offset of the first payment returned." + }, + "last_index_offset": { + "type": "string", + "format": "uint64", + "description": "The row offset of the last payment returned. This can be used as the\nindex_offset in a subsequent query to paginate forwards." + }, + "total_num_payments": { + "type": "string", + "format": "uint64", + "description": "The total number of payments matching the query (only set if\ncount_total_payments was true in the request)." + } + } + }, "litrpcCreateAccountRequest": { "type": "object", "properties": { @@ -453,6 +600,409 @@ "litrpcRemoveAccountResponse": { "type": "object" }, + "lnrpcAMPRecord": { + "type": "object", + "properties": { + "root_share": { + "type": "string", + "format": "byte" + }, + "set_id": { + "type": "string", + "format": "byte" + }, + "child_index": { + "type": "integer", + "format": "int64" + } + } + }, + "lnrpcChannelUpdate": { + "type": "object", + "properties": { + "signature": { + "type": "string", + "format": "byte", + "description": "The signature that validates the announced data and proves the ownership\nof node id." + }, + "chain_hash": { + "type": "string", + "format": "byte", + "description": "The target chain that this channel was opened within. This value\nshould be the genesis hash of the target chain. Along with the short\nchannel ID, this uniquely identifies the channel globally in a\nblockchain." + }, + "chan_id": { + "type": "string", + "format": "uint64", + "description": "The unique description of the funding transaction." + }, + "timestamp": { + "type": "integer", + "format": "int64", + "description": "A timestamp that allows ordering in the case of multiple announcements.\nWe should ignore the message if timestamp is not greater than the\nlast-received." + }, + "message_flags": { + "type": "integer", + "format": "int64", + "description": "The bitfield that describes whether optional fields are present in this\nupdate. Currently, the least-significant bit must be set to 1 if the\noptional field MaxHtlc is present." + }, + "channel_flags": { + "type": "integer", + "format": "int64", + "description": "The bitfield that describes additional meta-data concerning how the\nupdate is to be interpreted. Currently, the least-significant bit must be\nset to 0 if the creating node corresponds to the first node in the\npreviously sent channel announcement and 1 otherwise. If the second bit\nis set, then the channel is set to be disabled." + }, + "time_lock_delta": { + "type": "integer", + "format": "int64", + "description": "The minimum number of blocks this node requires to be added to the expiry\nof HTLCs. This is a security parameter determined by the node operator.\nThis value represents the required gap between the time locks of the\nincoming and outgoing HTLC's set to this node." + }, + "htlc_minimum_msat": { + "type": "string", + "format": "uint64", + "description": "The minimum HTLC value which will be accepted." + }, + "base_fee": { + "type": "integer", + "format": "int64", + "description": "The base fee that must be used for incoming HTLC's to this particular\nchannel. This value will be tacked onto the required for a payment\nindependent of the size of the payment." + }, + "fee_rate": { + "type": "integer", + "format": "int64", + "description": "The fee rate that will be charged per millionth of a satoshi." + }, + "htlc_maximum_msat": { + "type": "string", + "format": "uint64", + "description": "The maximum HTLC value which will be accepted." + }, + "extra_opaque_data": { + "type": "string", + "format": "byte", + "description": "The set of data that was appended to this message, some of which we may\nnot actually know how to iterate or parse. By holding onto this data, we\nensure that we're able to properly validate the set of signatures that\ncover these new fields, and ensure we're able to make upgrades to the\nnetwork in a forwards compatible manner." + } + } + }, + "lnrpcFailure": { + "type": "object", + "properties": { + "code": { + "$ref": "#/definitions/FailureFailureCode", + "title": "Failure code as defined in the Lightning spec" + }, + "channel_update": { + "$ref": "#/definitions/lnrpcChannelUpdate", + "description": "An optional channel update message." + }, + "htlc_msat": { + "type": "string", + "format": "uint64", + "description": "A failure type-dependent htlc value." + }, + "onion_sha_256": { + "type": "string", + "format": "byte", + "description": "The sha256 sum of the onion payload." + }, + "cltv_expiry": { + "type": "integer", + "format": "int64", + "description": "A failure type-dependent cltv expiry value." + }, + "flags": { + "type": "integer", + "format": "int64", + "description": "A failure type-dependent flags value." + }, + "failure_source_index": { + "type": "integer", + "format": "int64", + "description": "The position in the path of the intermediate or final node that generated\nthe failure message. Position zero is the sender node." + }, + "height": { + "type": "integer", + "format": "int64", + "description": "A failure type-dependent block height." + } + } + }, + "lnrpcHTLCAttempt": { + "type": "object", + "properties": { + "attempt_id": { + "type": "string", + "format": "uint64", + "description": "The unique ID that is used for this attempt." + }, + "status": { + "$ref": "#/definitions/HTLCAttemptHTLCStatus", + "description": "The status of the HTLC." + }, + "route": { + "$ref": "#/definitions/lnrpcRoute", + "description": "The route taken by this HTLC." + }, + "attempt_time_ns": { + "type": "string", + "format": "int64", + "description": "The time in UNIX nanoseconds at which this HTLC was sent." + }, + "resolve_time_ns": { + "type": "string", + "format": "int64", + "description": "The time in UNIX nanoseconds at which this HTLC was settled or failed.\nThis value will not be set if the HTLC is still IN_FLIGHT." + }, + "failure": { + "$ref": "#/definitions/lnrpcFailure", + "description": "Detailed htlc failure info." + }, + "preimage": { + "type": "string", + "format": "byte", + "description": "The preimage that was used to settle the HTLC." + } + } + }, + "lnrpcHop": { + "type": "object", + "properties": { + "chan_id": { + "type": "string", + "format": "uint64", + "description": "The unique channel ID for the channel. The first 3 bytes are the block\nheight, the next 3 the index within the block, and the last 2 bytes are the\noutput index for the channel." + }, + "chan_capacity": { + "type": "string", + "format": "int64" + }, + "amt_to_forward": { + "type": "string", + "format": "int64" + }, + "fee": { + "type": "string", + "format": "int64" + }, + "expiry": { + "type": "integer", + "format": "int64" + }, + "amt_to_forward_msat": { + "type": "string", + "format": "int64" + }, + "fee_msat": { + "type": "string", + "format": "int64" + }, + "pub_key": { + "type": "string", + "description": "An optional public key of the hop. If the public key is given, the payment\ncan be executed without relying on a copy of the channel graph." + }, + "tlv_payload": { + "type": "boolean", + "description": "If set to true, then this hop will be encoded using the new variable length\nTLV format. Note that if any custom tlv_records below are specified, then\nthis field MUST be set to true for them to be encoded properly." + }, + "mpp_record": { + "$ref": "#/definitions/lnrpcMPPRecord", + "description": "An optional TLV record that signals the use of an MPP payment. If present,\nthe receiver will enforce that the same mpp_record is included in the final\nhop payload of all non-zero payments in the HTLC set. If empty, a regular\nsingle-shot payment is or was attempted." + }, + "amp_record": { + "$ref": "#/definitions/lnrpcAMPRecord", + "description": "An optional TLV record that signals the use of an AMP payment. If present,\nthe receiver will treat all received payments including the same\n(payment_addr, set_id) pair as being part of one logical payment. The\npayment will be settled by XORing the root_share's together and deriving the\nchild hashes and preimages according to BOLT XX. Must be used in conjunction\nwith mpp_record." + }, + "custom_records": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "byte" + }, + "description": "An optional set of key-value TLV records. This is useful within the context\nof the SendToRoute call as it allows callers to specify arbitrary K-V pairs\nto drop off at each hop within the onion." + }, + "metadata": { + "type": "string", + "format": "byte", + "description": "The payment metadata to send along with the payment to the payee." + }, + "blinding_point": { + "type": "string", + "format": "byte", + "description": "Blinding point is an optional blinding point included for introduction\nnodes in blinded paths. This field is mandatory for hops that represents\nthe introduction point in a blinded path." + }, + "encrypted_data": { + "type": "string", + "format": "byte", + "description": "Encrypted data is a receiver-produced blob of data that provides hops\nin a blinded route with forwarding data. As this data is encrypted by\nthe recipient, we will not be able to parse it - it is essentially an\narbitrary blob of data from our node's perspective. This field is\nmandatory for all hops in a blinded path, including the introduction\nnode." + }, + "total_amt_msat": { + "type": "string", + "format": "uint64", + "description": "The total amount that is sent to the recipient (possibly across multiple\nHTLCs), as specified by the sender when making a payment to a blinded path.\nThis value is only set in the final hop payload of a blinded payment. This\nvalue is analogous to the MPPRecord that is used for regular (non-blinded)\nMPP payments." + } + } + }, + "lnrpcMPPRecord": { + "type": "object", + "properties": { + "payment_addr": { + "type": "string", + "format": "byte", + "description": "A unique, random identifier used to authenticate the sender as the intended\npayer of a multi-path payment. The payment_addr must be the same for all\nsubpayments, and match the payment_addr provided in the receiver's invoice.\nThe same payment_addr must be used on all subpayments. This is also called\npayment secret in specifications (e.g. BOLT 11)." + }, + "total_amt_msat": { + "type": "string", + "format": "int64", + "description": "The total amount in milli-satoshis being sent as part of a larger multi-path\npayment. The caller is responsible for ensuring subpayments to the same node\nand payment_hash sum exactly to total_amt_msat. The same\ntotal_amt_msat must be used on all subpayments." + } + } + }, + "lnrpcPayment": { + "type": "object", + "properties": { + "payment_hash": { + "type": "string", + "title": "The payment hash" + }, + "value": { + "type": "string", + "format": "int64", + "description": "Deprecated, use value_sat or value_msat." + }, + "creation_date": { + "type": "string", + "format": "int64", + "title": "Deprecated, use creation_time_ns" + }, + "fee": { + "type": "string", + "format": "int64", + "description": "Deprecated, use fee_sat or fee_msat." + }, + "payment_preimage": { + "type": "string", + "title": "The payment preimage" + }, + "value_sat": { + "type": "string", + "format": "int64", + "title": "The value of the payment in satoshis" + }, + "value_msat": { + "type": "string", + "format": "int64", + "title": "The value of the payment in milli-satoshis" + }, + "payment_request": { + "type": "string", + "description": "The optional payment request being fulfilled." + }, + "status": { + "$ref": "#/definitions/PaymentPaymentStatus", + "description": "The status of the payment." + }, + "fee_sat": { + "type": "string", + "format": "int64", + "title": "The fee paid for this payment in satoshis" + }, + "fee_msat": { + "type": "string", + "format": "int64", + "title": "The fee paid for this payment in milli-satoshis" + }, + "creation_time_ns": { + "type": "string", + "format": "int64", + "description": "The time in UNIX nanoseconds at which the payment was created." + }, + "htlcs": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/lnrpcHTLCAttempt" + }, + "description": "The HTLCs made in attempt to settle the payment." + }, + "payment_index": { + "type": "string", + "format": "uint64", + "description": "The creation index of this payment. Each payment can be uniquely identified\nby this index, which may not strictly increment by 1 for payments made in\nolder versions of lnd." + }, + "failure_reason": { + "$ref": "#/definitions/lnrpcPaymentFailureReason" + }, + "first_hop_custom_records": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "byte" + }, + "description": "The custom TLV records that were sent to the first hop as part of the HTLC\nwire message for this payment." + } + } + }, + "lnrpcPaymentFailureReason": { + "type": "string", + "enum": [ + "FAILURE_REASON_NONE", + "FAILURE_REASON_TIMEOUT", + "FAILURE_REASON_NO_ROUTE", + "FAILURE_REASON_ERROR", + "FAILURE_REASON_INCORRECT_PAYMENT_DETAILS", + "FAILURE_REASON_INSUFFICIENT_BALANCE", + "FAILURE_REASON_CANCELED" + ], + "default": "FAILURE_REASON_NONE", + "description": " - FAILURE_REASON_NONE: Payment isn't failed (yet).\n - FAILURE_REASON_TIMEOUT: There are more routes to try, but the payment timeout was exceeded.\n - FAILURE_REASON_NO_ROUTE: All possible routes were tried and failed permanently. Or were no\nroutes to the destination at all.\n - FAILURE_REASON_ERROR: A non-recoverable error has occured.\n - FAILURE_REASON_INCORRECT_PAYMENT_DETAILS: Payment details incorrect (unknown hash, invalid amt or\ninvalid final cltv delta)\n - FAILURE_REASON_INSUFFICIENT_BALANCE: Insufficient local balance.\n - FAILURE_REASON_CANCELED: The payment was canceled." + }, + "lnrpcRoute": { + "type": "object", + "properties": { + "total_time_lock": { + "type": "integer", + "format": "int64", + "description": "The cumulative (final) time lock across the entire route. This is the CLTV\nvalue that should be extended to the first hop in the route. All other hops\nwill decrement the time-lock as advertised, leaving enough time for all\nhops to wait for or present the payment preimage to complete the payment." + }, + "total_fees": { + "type": "string", + "format": "int64", + "description": "The sum of the fees paid at each hop within the final route. In the case\nof a one-hop payment, this value will be zero as we don't need to pay a fee\nto ourselves." + }, + "total_amt": { + "type": "string", + "format": "int64", + "description": "The total amount of funds required to complete a payment over this route.\nThis value includes the cumulative fees at each hop. As a result, the HTLC\nextended to the first-hop in the route will need to have at least this many\nsatoshis, otherwise the route will fail at an intermediate node due to an\ninsufficient amount of fees." + }, + "hops": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/lnrpcHop" + }, + "description": "Contains details concerning the specific forwarding details at each hop." + }, + "total_fees_msat": { + "type": "string", + "format": "int64", + "description": "The total fees in millisatoshis." + }, + "total_amt_msat": { + "type": "string", + "format": "int64", + "description": "The total amount in millisatoshis." + }, + "first_hop_amount_msat": { + "type": "string", + "format": "int64", + "description": "The actual on-chain amount that was sent out to the first hop. This value is\nonly different from the total_amt_msat field if this is a custom channel\npayment and the value transported in the HTLC is different from the BTC\namount in the HTLC. If this value is zero, then this is an old payment that\ndidn't have this value yet and can be ignored." + }, + "custom_channel_data": { + "type": "string", + "format": "byte", + "description": "Custom channel data that might be populated in custom channels." + } + }, + "description": "A path through the channel graph which runs over one or more channels in\nsuccession. This struct carries all the information required to craft the\nSphinx onion packet, and send the payment along the first hop in the path. A\nroute is only selected as valid if all the channels have sufficient capacity to\ncarry the initial payment amount after fees are accounted for." + }, "protobufAny": { "type": "object", "properties": { diff --git a/litrpc/lit-accounts.yaml b/litrpc/lit-accounts.yaml index 7fa8e2d6e..f917b7896 100644 --- a/litrpc/lit-accounts.yaml +++ b/litrpc/lit-accounts.yaml @@ -21,3 +21,5 @@ http: - selector: litrpc.Accounts.DebitAccount post: "/v1/accounts/debit/{account.id}" body: "*" + - selector: litrpc.Accounts.AccountPayments + get: "/v1/accounts/payments" diff --git a/litrpc/lit-accounts_grpc.pb.go b/litrpc/lit-accounts_grpc.pb.go index 7b6e72eba..7e00dd3e6 100644 --- a/litrpc/lit-accounts_grpc.pb.go +++ b/litrpc/lit-accounts_grpc.pb.go @@ -52,6 +52,9 @@ type AccountsClient interface { // litcli: `accounts remove` // RemoveAccount removes the given account from the account database. RemoveAccount(ctx context.Context, in *RemoveAccountRequest, opts ...grpc.CallOption) (*RemoveAccountResponse, error) + // litcli: `accounts payments` + // AccountPayments returns the detailed payment history for the given account. + AccountPayments(ctx context.Context, in *AccountPaymentsRequest, opts ...grpc.CallOption) (*AccountPaymentsResponse, error) } type accountsClient struct { @@ -125,6 +128,15 @@ func (c *accountsClient) RemoveAccount(ctx context.Context, in *RemoveAccountReq return out, nil } +func (c *accountsClient) AccountPayments(ctx context.Context, in *AccountPaymentsRequest, opts ...grpc.CallOption) (*AccountPaymentsResponse, error) { + out := new(AccountPaymentsResponse) + err := c.cc.Invoke(ctx, "/litrpc.Accounts/AccountPayments", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // AccountsServer is the server API for Accounts service. // All implementations must embed UnimplementedAccountsServer // for forward compatibility @@ -163,6 +175,9 @@ type AccountsServer interface { // litcli: `accounts remove` // RemoveAccount removes the given account from the account database. RemoveAccount(context.Context, *RemoveAccountRequest) (*RemoveAccountResponse, error) + // litcli: `accounts payments` + // AccountPayments returns the detailed payment history for the given account. + AccountPayments(context.Context, *AccountPaymentsRequest) (*AccountPaymentsResponse, error) mustEmbedUnimplementedAccountsServer() } @@ -191,6 +206,9 @@ func (UnimplementedAccountsServer) AccountInfo(context.Context, *AccountInfoRequ func (UnimplementedAccountsServer) RemoveAccount(context.Context, *RemoveAccountRequest) (*RemoveAccountResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method RemoveAccount not implemented") } +func (UnimplementedAccountsServer) AccountPayments(context.Context, *AccountPaymentsRequest) (*AccountPaymentsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AccountPayments not implemented") +} func (UnimplementedAccountsServer) mustEmbedUnimplementedAccountsServer() {} // UnsafeAccountsServer may be embedded to opt out of forward compatibility for this service. @@ -330,6 +348,24 @@ func _Accounts_RemoveAccount_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _Accounts_AccountPayments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AccountPaymentsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AccountsServer).AccountPayments(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/litrpc.Accounts/AccountPayments", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AccountsServer).AccountPayments(ctx, req.(*AccountPaymentsRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Accounts_ServiceDesc is the grpc.ServiceDesc for Accounts service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -365,6 +401,10 @@ var Accounts_ServiceDesc = grpc.ServiceDesc{ MethodName: "RemoveAccount", Handler: _Accounts_RemoveAccount_Handler, }, + { + MethodName: "AccountPayments", + Handler: _Accounts_AccountPayments_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "lit-accounts.proto", diff --git a/perms/permissions.go b/perms/permissions.go index e2f2f8e07..a93289eae 100644 --- a/perms/permissions.go +++ b/perms/permissions.go @@ -44,6 +44,10 @@ var ( Entity: "account", Action: "read", }}, + "/litrpc.Accounts/AccountPayments": {{ + Entity: "account", + Action: "read", + }}, "/litrpc.Accounts/RemoveAccount": {{ Entity: "account", Action: "write", diff --git a/proto/lit-accounts.proto b/proto/lit-accounts.proto index 3ab21cc28..aab1c1260 100644 --- a/proto/lit-accounts.proto +++ b/proto/lit-accounts.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package litrpc; +import "lnd.proto"; + option go_package = "github.com/lightninglabs/lightning-terminal/litrpc"; service Accounts { @@ -52,6 +54,12 @@ service Accounts { RemoveAccount removes the given account from the account database. */ rpc RemoveAccount (RemoveAccountRequest) returns (RemoveAccountResponse); + + /* litcli: `accounts payments` + AccountPayments returns the detailed payment history for the given account. + */ + rpc AccountPayments (AccountPaymentsRequest) + returns (AccountPaymentsResponse); } message CreateAccountRequest { @@ -244,4 +252,55 @@ message AccountIdentifier { // The label of the account. string label = 2; } +} + +message AccountPaymentsRequest { + /* + The identifier of the account to query payments for. + */ + AccountIdentifier account = 1; + + /* + The maximum number of payments to return. If set to 0, it will default + to 20. Capped at 50. + */ + uint64 max_payments = 2 [jstype = JS_STRING]; + + /* + The row offset into the list of payments that will be used as the start of + the query. The payments are returned in ascending lexicographical order of + their payment hash. + */ + uint64 index_offset = 3 [jstype = JS_STRING]; + + /* + If set, the total number of payments matching the query will be returned + in the response. + */ + bool count_total_payments = 4; +} + +message AccountPaymentsResponse { + /* + The detailed payments associated with the account, sorted in ascending + lexicographical order of their payment hash. + */ + repeated lnrpc.Payment payments = 1; + + /* + The row offset of the first payment returned. + */ + uint64 first_index_offset = 2 [jstype = JS_STRING]; + + /* + The row offset of the last payment returned. This can be used as the + index_offset in a subsequent query to paginate forwards. + */ + uint64 last_index_offset = 3 [jstype = JS_STRING]; + + /* + The total number of payments matching the query (only set if + count_total_payments was true in the request). + */ + uint64 total_num_payments = 4 [jstype = JS_STRING]; } \ No newline at end of file