From d976904a8e771b1dd4c7214ffe11af3e7214d79b Mon Sep 17 00:00:00 2001 From: kennycud Date: Mon, 23 Sep 2024 08:04:44 -0700 Subject: [PATCH] added 2 endpoints providing sponsorship analytics --- .../api/resource/AddressesResource.java | 68 ++++ .../data/account/SponsorshipReport.java | 148 ++++++++ .../qortal/repository/AccountRepository.java | 34 ++ .../hsqldb/HSQLDBAccountRepository.java | 331 +++++++++++++++++- 4 files changed, 580 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/qortal/data/account/SponsorshipReport.java diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 66d8412c..349dd89d 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -23,6 +23,7 @@ import org.qortal.crypto.Crypto; import org.qortal.data.account.AccountData; import org.qortal.data.account.AccountPenaltyData; import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.SponsorshipReport; import org.qortal.data.network.OnlineAccountData; import org.qortal.data.network.OnlineAccountLevel; import org.qortal.data.transaction.PublicizeTransactionData; @@ -52,6 +53,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Path("/addresses") @@ -630,4 +632,70 @@ public class AddressesResource { } } + @GET + @Path("/sponsorship/{address}") + @Operation( + summary = "Returns sponsorship statistics for an account", + description = "Returns sponsorship statistics for an account", + responses = { + @ApiResponse( + description = "the statistics", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = SponsorshipReport.class))) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public SponsorshipReport getSponsorshipReport(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + SponsorshipReport report = repository.getAccountRepository().getSponsorshipReport(address); + // Not found? + if (report == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + return report; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/sponsorship/{address}/sponsor") + @Operation( + summary = "Returns sponsorship statistics for an account's sponsor", + description = "Returns sponsorship statistics for an account's sponsor", + responses = { + @ApiResponse( + description = "the statistics", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = SponsorshipReport.class))) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public SponsorshipReport getSponsorshipReportForSponsor(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + + // get sponsor + Optional sponsor = repository.getAccountRepository().getSponsor(address); + + // if there is not sponsor, throw error + if(sponsor.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + // get report for sponsor + SponsorshipReport report = repository.getAccountRepository().getSponsorshipReport(sponsor.get()); + + // Not found? + if (report == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); + + return report; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } } diff --git a/src/main/java/org/qortal/data/account/SponsorshipReport.java b/src/main/java/org/qortal/data/account/SponsorshipReport.java new file mode 100644 index 00000000..47470b6a --- /dev/null +++ b/src/main/java/org/qortal/data/account/SponsorshipReport.java @@ -0,0 +1,148 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import java.util.Arrays; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class SponsorshipReport { + + private String address; + + private int level; + + private int blocksMinted; + + private int adjustments; + + private int penalties; + + private String[] names; + + private int sponseeCount; + + private int nonRegisteredCount; + + private int avgBalance; + + private int arbitraryCount; + + private int transferAssetCount; + + private int sellCount; + + private int sellAmount; + + private int buyCount; + + private int buyAmount; + + // Constructors + + // For JAXB + protected SponsorshipReport() { + } + + public SponsorshipReport(String address, int level, int blocksMinted, int adjustments, int penalties, String[] names, int sponseeCount, int nonRegisteredCount, int avgBalance, int arbitraryCount, int transferAssetCount, int sellCount, int sellAmount, int buyCount, int buyAmount) { + this.address = address; + this.level = level; + this.blocksMinted = blocksMinted; + this.adjustments = adjustments; + this.penalties = penalties; + this.names = names; + this.sponseeCount = sponseeCount; + this.nonRegisteredCount = nonRegisteredCount; + this.avgBalance = avgBalance; + this.arbitraryCount = arbitraryCount; + this.transferAssetCount = transferAssetCount; + this.sellCount = sellCount; + this.sellAmount = sellAmount; + this.buyCount = buyCount; + this.buyAmount = buyAmount; + } + + // Getters / setters + + + public String getAddress() { + return address; + } + + public int getLevel() { + return level; + } + + public int getBlocksMinted() { + return blocksMinted; + } + + public int getAdjustments() { + return adjustments; + } + + public int getPenalties() { + return penalties; + } + + public String[] getNames() { + return names; + } + + public int getSponseeCount() { + return sponseeCount; + } + + public int getNonRegisteredCount() { + return nonRegisteredCount; + } + + public int getAvgBalance() { + return avgBalance; + } + + public int getArbitraryCount() { + return arbitraryCount; + } + + public int getTransferAssetCount() { + return transferAssetCount; + } + + public int getSellCount() { + return sellCount; + } + + public int getSellAmount() { + return sellAmount; + } + + public int getBuyCount() { + return buyCount; + } + + public int getBuyAmount() { + return buyAmount; + } + + @Override + public String toString() { + return "SponsorshipReport{" + + "address='" + address + '\'' + + ", level=" + level + + ", blocksMinted=" + blocksMinted + + ", adjustments=" + adjustments + + ", penalties=" + penalties + + ", names=" + Arrays.toString(names) + + ", sponseeCount=" + sponseeCount + + ", nonRegisteredCount=" + nonRegisteredCount + + ", avgBalance=" + avgBalance + + ", arbitraryCount=" + arbitraryCount + + ", transferAssetCount=" + transferAssetCount + + ", sellCount=" + sellCount + + ", sellAmount=" + sellAmount + + ", buyCount=" + buyCount + + ", buyAmount=" + buyAmount + + '}'; + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/AccountRepository.java b/src/main/java/org/qortal/repository/AccountRepository.java index bdad187b..d1ade684 100644 --- a/src/main/java/org/qortal/repository/AccountRepository.java +++ b/src/main/java/org/qortal/repository/AccountRepository.java @@ -3,6 +3,7 @@ package org.qortal.repository; import org.qortal.data.account.*; import java.util.List; +import java.util.Optional; import java.util.Set; public interface AccountRepository { @@ -131,6 +132,39 @@ public interface AccountRepository { /** Returns all account balances for given assetID, optionally excluding zero balances. */ public List getAssetBalances(long assetId, Boolean excludeZero) throws DataException; + /** + * Get Sponsorship Report + * + * @param address the sponsor's account address + * + * @return the report + * + * @throws DataException + */ + public SponsorshipReport getSponsorshipReport(String address) throws DataException; + + /** + * Get Sponsee Addresses + * + * @param account the sponsor's account address + * + * @return the sponsee addresses + * + * @throws DataException + */ + public List getSponseeAddresses(String account) throws DataException; + + /** + * Get Sponsor + * + * @param address the address of the account + * + * @return the address of accounts sponsor, empty if not sponsored + * + * @throws DataException + */ + public Optional getSponsor(String address) throws DataException; + /** How to order results when fetching asset balances. */ public enum BalanceOrdering { /** assetID first, then balance, then account address */ diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 7aef66ce..374c3a99 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -1,5 +1,7 @@ package org.qortal.repository.hsqldb; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qortal.asset.Asset; import org.qortal.data.account.*; import org.qortal.repository.AccountRepository; @@ -8,7 +10,11 @@ import org.qortal.repository.DataException; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -16,12 +22,15 @@ import static org.qortal.utils.Amounts.prettyAmount; public class HSQLDBAccountRepository implements AccountRepository { + public static final String SELL = "sell"; + public static final String BUY = "buy"; protected HSQLDBRepository repository; public HSQLDBAccountRepository(HSQLDBRepository repository) { this.repository = repository; } + protected static final Logger LOGGER = LogManager.getLogger(HSQLDBAccountRepository.class); // General account @Override @@ -1147,4 +1156,324 @@ public class HSQLDBAccountRepository implements AccountRepository { } } -} + @Override + public SponsorshipReport getSponsorshipReport(String account) throws DataException { + + try { + ResultSet accountResultSet = getAccountResultSet(account); + + if( accountResultSet == null ) throw new DataException("Unable to fetch account info from repository"); + + int level = accountResultSet.getInt(2); + int blocksMinted = accountResultSet.getInt(3); + int adjustments = accountResultSet.getInt(4); + int penalties = accountResultSet.getInt(5); + + List sponseeAddresses = getSponseeAddresses(account); + + if( sponseeAddresses.isEmpty() ){ + return new SponsorshipReport(account, level, blocksMinted, adjustments, penalties, new String[0], 0, 0, 0, 0, 0, 0, 0, 0, 0); + } + else { + return produceSponsorShipReport(account, level, blocksMinted, adjustments, penalties, sponseeAddresses); + } + } + catch (Exception e) { + LOGGER.error(e.getMessage(), e); + throw new DataException("Unable to fetch account info from repository", e); + } + } + + @Override + public List getSponseeAddresses(String account) throws DataException { + StringBuffer sponseeSql = new StringBuffer(); + + sponseeSql.append( "SELECT DISTINCT t.recipient sponsees " ); + sponseeSql.append( "FROM REWARDSHARETRANSACTIONS t "); + sponseeSql.append( "INNER JOIN ACCOUNTS a on t.minter_public_key = a.public_key "); + sponseeSql.append( "WHERE account = ? and t.recipient != a.account"); + + try { + ResultSet sponseeResultSet = this.repository.checkedExecute(sponseeSql.toString(), account); + + List sponseeAddresses; + + if( sponseeResultSet == null ) { + sponseeAddresses = new ArrayList<>(0); + } + else { + sponseeAddresses = new ArrayList<>(); + + do { + sponseeAddresses.add(sponseeResultSet.getString(1)); + } while (sponseeResultSet.next()); + } + + return sponseeAddresses; + } + catch (SQLException e) { + throw new DataException("can't get sponsees from blockchain data", e); + } + } + + @Override + public Optional getSponsor(String address) throws DataException { + + StringBuffer sponsorSql = new StringBuffer(); + + sponsorSql.append( "SELECT DISTINCT account, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty "); + sponsorSql.append( "FROM REWARDSHARETRANSACTIONS t "); + sponsorSql.append( "INNER JOIN ACCOUNTS a on a.public_key = t.minter_public_key "); + sponsorSql.append( "WHERE recipient = ? and recipient != account "); + + try { + ResultSet sponseeResultSet = this.repository.checkedExecute(sponsorSql.toString(), address); + + if( sponseeResultSet == null ){ + return Optional.empty(); + } + else { + return Optional.ofNullable( sponseeResultSet.getString(1)); + } + } catch (SQLException e) { + throw new DataException("can't get sponsor from blockchain data", e); + } + } + + /** + * Produce Sponsorship Report + * + * @param address the account address for the sponsor + * @param level the sponsor's level + * @param blocksMinted the blocks minted by the sponsor + * @param blocksMintedAdjustment + * @param blocksMintedPenalty + * @param sponseeAddresses + * + * @return the report + * + * @throws SQLException + */ + private SponsorshipReport produceSponsorShipReport( + String address, + int level, + int blocksMinted, + int blocksMintedAdjustment, + int blocksMintedPenalty, + List sponseeAddresses) throws SQLException { + + int sponseeCount = sponseeAddresses.size(); + + // get the registered nanmes of the sponsees + ResultSet namesResultSet = getNamesResultSet(sponseeAddresses, sponseeCount); + List sponseeNames = getNames(namesResultSet, sponseeCount); + + // get the average balance of the sponsees + ResultSet avgBalanceResultSet = getAverageBalanceResultSet(sponseeAddresses, sponseeCount); + int avgBalance = avgBalanceResultSet.getInt(1); + + // count the arbitrary and transfer asset transactions for all sponsees + ResultSet txTypeResultSet = getTxTypeResultSet(sponseeAddresses, sponseeCount); + + int arbitraryCount = 0; + int transferAssetCount = 0; + + if( txTypeResultSet != null) { + int txType = txTypeResultSet.getInt(1); + + // if arbitrary transaction type, then get the count and move to the next result + if (txType == 10) { + arbitraryCount = txTypeResultSet.getInt(2); + + // if there is another result, then get + if (txTypeResultSet.next()) + txType = txTypeResultSet.getInt(1); + } + + // if asset transfer type, then get the count and move to the next result + if (txType == 12) { + transferAssetCount = txTypeResultSet.getInt(2); + txTypeResultSet.next(); + } + } + + // count up the each the buy and sell foreign coin exchanges for all sponsees + // also sum up the balances of these exchanges + ResultSet buySellResultSet = getBuySellResultSet(sponseeAddresses, sponseeCount); + + // if there are results, then fill in the buy/sell amount/counts + if( buySellResultSet != null ) { + + Map countsByDirection = new HashMap<>(2); + Map amountsByDirection = new HashMap<>(2); + + do{ + String direction = buySellResultSet.getString(1).trim(); + + if( direction != null ) { + countsByDirection.put(direction, buySellResultSet.getInt(2)); + amountsByDirection.put(direction, buySellResultSet.getInt(3)); + } + } while( buySellResultSet.next()); + + + int sellCount = countsByDirection.getOrDefault(SELL, 0); + int sellAmount = amountsByDirection.getOrDefault(SELL, 0); + + int buyCount = countsByDirection.getOrDefault(BUY, 0); + int buyAmount = amountsByDirection.getOrDefault(BUY, 0); + + return new SponsorshipReport( + address, + level, + blocksMinted, + blocksMintedAdjustment, + blocksMintedPenalty, + sponseeNames.toArray(new String[sponseeNames.size()]), + sponseeCount, + sponseeCount - sponseeNames.size(), + avgBalance, + arbitraryCount, + transferAssetCount, + sellCount, + sellAmount, + buyCount, + buyAmount); + + } + // otherwise use zeros for the counts and amounts + + return new SponsorshipReport( + address, + level, + blocksMinted, + blocksMintedAdjustment, + blocksMintedPenalty, + sponseeNames.toArray(new String[sponseeNames.size()]), + sponseeCount, + sponseeCount - sponseeNames.size(), + avgBalance, + arbitraryCount, + transferAssetCount, + 0, + 0, + 0, + 0); + } + + private ResultSet getBuySellResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer buySellSql = new StringBuffer(); + buySellSql.append("SELECT "); + buySellSql.append("CASE "); + buySellSql.append(" WHEN participant = account THEN 'sell' "); + buySellSql.append(" WHEN participant != account THEN 'buy' "); + buySellSql.append("END AS direction, "); + buySellSql.append(" COUNT(*) as transactions, sum(tx.amount)/100000000 as amount "); + buySellSql.append("FROM TRANSACTIONPARTICIPANTS "); + buySellSql.append("INNER JOIN ATTRANSACTIONS tx using (signature) "); + buySellSql.append("INNER JOIN ATS ats using (at_address) "); + buySellSql.append("INNER JOIN ACCOUNTS a on ats.creator = a.public_key "); + buySellSql.append("WHERE participant in ( "); + buySellSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + buySellSql.append(") "); + buySellSql.append("GROUP BY "); + buySellSql.append("CASE "); + buySellSql.append(" WHEN participant = account THEN 'sell' "); + buySellSql.append(" WHEN participant != account THEN 'buy' "); + buySellSql.append("END; "); + + String[] sponsees = sponseeAddresses.toArray(new String[sponseeCount]); + ResultSet buySellResultSet = this.repository.checkedExecute(buySellSql.toString(), sponsees); + + return buySellResultSet; + } + + private ResultSet getAccountResultSet(String account) throws SQLException { + + StringBuffer accountSql = new StringBuffer(); + + accountSql.append( "SELECT DISTINCT account, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty "); + accountSql.append( "FROM ACCOUNTS "); + accountSql.append( "WHERE account = ? "); + + ResultSet accountResultSet = this.repository.checkedExecute( accountSql.toString(), account); + + return accountResultSet; + } + + + private ResultSet getTxTypeResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer txTypeTotalsSql = new StringBuffer(); + // Transaction Types, int values + // ARBITRARY = 10 + // TRANSFER_ASSET = 12 + // txTypeTotalsSql.append(" + txTypeTotalsSql.append("SELECT type, count(*) "); + txTypeTotalsSql.append("FROM TRANSACTIONPARTICIPANTS "); + txTypeTotalsSql.append("INNER JOIN TRANSACTIONS USING (signature) "); + txTypeTotalsSql.append("where participant in ( "); + txTypeTotalsSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + txTypeTotalsSql.append(") and type in (10, 12) "); + txTypeTotalsSql.append("group by type order by type"); + + String[] sponsees = sponseeAddresses.toArray(new String[sponseeCount]); + ResultSet txTypeResultSet = this.repository.checkedExecute(txTypeTotalsSql.toString(), sponsees); + return txTypeResultSet; + } + + private ResultSet getAverageBalanceResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer avgBalanceSql = new StringBuffer(); + avgBalanceSql.append("SELECT avg(balance)/100000000 FROM ACCOUNTBALANCES "); + avgBalanceSql.append("WHERE account in ("); + avgBalanceSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + avgBalanceSql.append(") and ASSET_ID = 0"); + + String[] sponsees = sponseeAddresses.toArray(new String[sponseeCount]); + return this.repository.checkedExecute(avgBalanceSql.toString(), sponsees); + } + + /** + * Get Names + * + * @param namesResultSet the result set to get the names from + * @param count the number of potential names + * + * @return the names + * + * @throws SQLException + */ + private static List getNames(ResultSet namesResultSet, int count) throws SQLException { + + List names = new ArrayList<>(count); + + int nonRegisteredCount = 0; + + do{ + String name = namesResultSet.getString(1); + + if( name != null ) { + names.add(name); + } + else { + nonRegisteredCount++; + } + + } while( namesResultSet.next() ); + + return names; + } + + private ResultSet getNamesResultSet(List sponseeAddresses, int sponseeCount) throws SQLException { + StringBuffer namesSql = new StringBuffer(); + namesSql.append("SELECT r.name "); + namesSql.append("FROM ACCOUNTS a "); + namesSql.append("LEFT JOIN REGISTERNAMETRANSACTIONS r on r.registrant = a.public_key "); + namesSql.append("WHERE account in ("); + namesSql.append(String.join(", ", Collections.nCopies(sponseeCount, "?"))); + namesSql.append(")"); + + String[] sponsees = sponseeAddresses.toArray(new String[sponseeCount]); + ResultSet namesResultSet = this.repository.checkedExecute(namesSql.toString(), sponsees); + return namesResultSet; + } +} \ No newline at end of file