diff --git a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java new file mode 100644 index 00000000..474bbdf2 --- /dev/null +++ b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java @@ -0,0 +1,362 @@ +package org.qortal.account; + +import org.qortal.api.resource.TransactionsResource; +import org.qortal.asset.Asset; +import org.qortal.data.account.AccountData; +import org.qortal.data.naming.NameData; +import org.qortal.data.transaction.*; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.transaction.Transaction.TransactionType; + +import java.util.*; +import java.util.stream.Collectors; + +public class SelfSponsorshipAlgoV1 { + + private final Repository repository; + private final String address; + private final AccountData accountData; + private final long snapshotTimestamp; + private final boolean override; + + private int registeredNameCount = 0; + private int suspiciousCount = 0; + private int suspiciousPercent = 0; + private int consolidationCount = 0; + private int bulkIssuanceCount = 0; + private int recentSponsorshipCount = 0; + + private List sponsorshipRewardShares = new ArrayList<>(); + private final Map> paymentsByAddress = new HashMap<>(); + private final Set sponsees = new LinkedHashSet<>(); + private Set consolidatedAddresses = new LinkedHashSet<>(); + private final Set zeroTransactionAddreses = new LinkedHashSet<>(); + private final Set penaltyAddresses = new LinkedHashSet<>(); + + public SelfSponsorshipAlgoV1(Repository repository, String address, long snapshotTimestamp, boolean override) throws DataException { + this.repository = repository; + this.address = address; + this.accountData = this.repository.getAccountRepository().getAccount(this.address); + this.snapshotTimestamp = snapshotTimestamp; + this.override = override; + } + + public String getAddress() { + return this.address; + } + + public Set getPenaltyAddresses() { + return this.penaltyAddresses; + } + + + public void run() throws DataException { + this.fetchSponsorshipRewardShares(); + if (this.sponsorshipRewardShares.isEmpty()) { + // Nothing to do + return; + } + + this.findConsolidatedRewards(); + this.findBulkIssuance(); + this.findRegisteredNameCount(); + this.findRecentSponsorshipCount(); + + int score = this.calculateScore(); + if (score <= 0 && !override) { + return; + } + + String newAddress = this.getDestinationAccount(this.address); + while (newAddress != null) { + // Found destination account + this.penaltyAddresses.add(newAddress); + + // Run algo for this address, but in "override" mode because it has already been flagged + SelfSponsorshipAlgoV1 algoV1 = new SelfSponsorshipAlgoV1(this.repository, newAddress, this.snapshotTimestamp, true); + algoV1.run(); + this.penaltyAddresses.addAll(algoV1.getPenaltyAddresses()); + + newAddress = this.getDestinationAccount(newAddress); + } + + this.penaltyAddresses.add(this.address); + + if (this.override || this.recentSponsorshipCount < 20) { + this.penaltyAddresses.addAll(this.consolidatedAddresses); + this.penaltyAddresses.addAll(this.zeroTransactionAddreses); + } + else { + this.penaltyAddresses.addAll(this.sponsees); + } + } + + private String getDestinationAccount(String address) throws DataException { + List transferPrivsTransactions = fetchTransferPrivsForAddress(address); + if (transferPrivsTransactions.isEmpty()) { + // No TRANSFER_PRIVS transactions for this address + return null; + } + + AccountData accountData = this.repository.getAccountRepository().getAccount(address); + if (accountData == null) { + return null; + } + + for (TransactionData transactionData : transferPrivsTransactions) { + TransferPrivsTransactionData transferPrivsTransactionData = (TransferPrivsTransactionData) transactionData; + if (Arrays.equals(transferPrivsTransactionData.getSenderPublicKey(), accountData.getPublicKey())) { + return transferPrivsTransactionData.getRecipient(); + } + } + + return null; + } + + private void findConsolidatedRewards() throws DataException { + List sponseesThatSentRewards = new ArrayList<>(); + Map paymentRecipients = new HashMap<>(); + + // Collect outgoing payments of each sponsee + for (String sponseeAddress : this.sponsees) { + + // Firstly fetch all payments for address, since the functions below depend on this data + this.fetchPaymentsForAddress(sponseeAddress); + + // Check if the address has zero relevant transactions + if (this.hasZeroTransactions(sponseeAddress)) { + this.zeroTransactionAddreses.add(sponseeAddress); + } + + // Get payment recipients + List allPaymentRecipients = this.fetchOutgoingPaymentRecipientsForAddress(sponseeAddress); + if (allPaymentRecipients.isEmpty()) { + continue; + } + sponseesThatSentRewards.add(sponseeAddress); + + List addressesPaidByThisSponsee = new ArrayList<>(); + for (String paymentRecipient : allPaymentRecipients) { + if (addressesPaidByThisSponsee.contains(paymentRecipient)) { + // We already tracked this association - don't allow multiple to stack up + continue; + } + addressesPaidByThisSponsee.add(paymentRecipient); + + // Increment count for this recipient, or initialize to 1 if not present + if (paymentRecipients.computeIfPresent(paymentRecipient, (k, v) -> v + 1) == null) { + paymentRecipients.put(paymentRecipient, 1); + } + } + + } + + // Exclude addresses with a low number of payments + Map filteredPaymentRecipients = paymentRecipients.entrySet().stream() + .filter(p -> p.getValue() != null && p.getValue() >= 10) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + // Now check how many sponsees have sent to this subset of addresses + Map sponseesThatConsolidatedRewards = new HashMap<>(); + for (String sponseeAddress : sponseesThatSentRewards) { + List allPaymentRecipients = this.fetchOutgoingPaymentRecipientsForAddress(sponseeAddress); + // Remove any that aren't to one of the flagged recipients (i.e. consolidation) + allPaymentRecipients.removeIf(r -> !filteredPaymentRecipients.containsKey(r)); + + int count = allPaymentRecipients.size(); + if (count == 0) { + continue; + } + if (sponseesThatConsolidatedRewards.computeIfPresent(sponseeAddress, (k, v) -> v + count) == null) { + sponseesThatConsolidatedRewards.put(sponseeAddress, count); + } + } + + // Remove sponsees that have only sent a low number of payments to the filtered addresses + Map filteredSponseesThatConsolidatedRewards = sponseesThatConsolidatedRewards.entrySet().stream() + .filter(p -> p.getValue() != null && p.getValue() >= 2) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + this.consolidationCount = sponseesThatConsolidatedRewards.size(); + this.consolidatedAddresses = new LinkedHashSet<>(filteredSponseesThatConsolidatedRewards.keySet()); + this.suspiciousCount = this.consolidationCount + this.zeroTransactionAddreses.size(); + this.suspiciousPercent = (int)(this.suspiciousCount / (float) this.sponsees.size() * 100); + } + + private void findBulkIssuance() { + Long lastTimestamp = null; + for (RewardShareTransactionData rewardShareTransactionData : sponsorshipRewardShares) { + long timestamp = rewardShareTransactionData.getTimestamp(); + if (timestamp >= this.snapshotTimestamp) { + continue; + } + + if (lastTimestamp != null) { + if (timestamp - lastTimestamp < 3*60*1000L) { + this.bulkIssuanceCount++; + } + } + lastTimestamp = timestamp; + } + } + + private void findRegisteredNameCount() throws DataException { + int registeredNameCount = 0; + for (String sponseeAddress : sponsees) { + List names = repository.getNameRepository().getNamesByOwner(sponseeAddress); + for (NameData name : names) { + if (name.getRegistered() < this.snapshotTimestamp) { + registeredNameCount++; + break; + } + } + } + this.registeredNameCount = registeredNameCount; + } + + private void findRecentSponsorshipCount() { + final long referenceTimestamp = this.snapshotTimestamp - (365 * 24 * 60 * 60 * 1000L); + int recentSponsorshipCount = 0; + for (RewardShareTransactionData rewardShare : sponsorshipRewardShares) { + if (rewardShare.getTimestamp() >= referenceTimestamp) { + recentSponsorshipCount++; + } + } + this.recentSponsorshipCount = recentSponsorshipCount; + } + + private int calculateScore() { + final int suspiciousMultiplier = (this.suspiciousCount >= 100) ? this.suspiciousPercent : 1; + final int nameMultiplier = (this.sponsees.size() >= 50 && this.registeredNameCount == 0) ? 2 : 1; + final int consolidationMultiplier = Math.max(this.consolidationCount, 1); + final int bulkIssuanceMultiplier = Math.max(this.bulkIssuanceCount / 2, 1); + final int offset = 9; + return suspiciousMultiplier * nameMultiplier * consolidationMultiplier * bulkIssuanceMultiplier - offset; + } + + private void fetchSponsorshipRewardShares() throws DataException { + List sponsorshipRewardShares = new ArrayList<>(); + + // Define relevant transactions + List txTypes = List.of(TransactionType.REWARD_SHARE); + List transactionDataList = fetchTransactions(repository, txTypes, this.address, false); + + for (TransactionData transactionData : transactionDataList) { + if (transactionData.getType() != TransactionType.REWARD_SHARE) { + continue; + } + + RewardShareTransactionData rewardShareTransactionData = (RewardShareTransactionData) transactionData; + + // Skip removals + if (rewardShareTransactionData.getSharePercent() < 0) { + continue; + } + + // Skip if not sponsored by this account + if (!Arrays.equals(rewardShareTransactionData.getCreatorPublicKey(), accountData.getPublicKey())) { + continue; + } + + // Skip self shares + if (Objects.equals(rewardShareTransactionData.getRecipient(), this.address)) { + continue; + } + + boolean duplicateFound = false; + for (RewardShareTransactionData existingRewardShare : sponsorshipRewardShares) { + if (Objects.equals(existingRewardShare.getRecipient(), rewardShareTransactionData.getRecipient())) { + // Duplicate + duplicateFound = true; + break; + } + } + if (!duplicateFound) { + sponsorshipRewardShares.add(rewardShareTransactionData); + this.sponsees.add(rewardShareTransactionData.getRecipient()); + } + } + + this.sponsorshipRewardShares = sponsorshipRewardShares; + } + + private List fetchTransferPrivsForAddress(String address) throws DataException { + return fetchTransactions(repository, + List.of(TransactionType.TRANSFER_PRIVS), + address, true); + } + + private void fetchPaymentsForAddress(String address) throws DataException { + List payments = fetchTransactions(repository, + Arrays.asList(TransactionType.PAYMENT, TransactionType.TRANSFER_ASSET), + address, false); + this.paymentsByAddress.put(address, payments); + } + + private List fetchOutgoingPaymentRecipientsForAddress(String address) { + List outgoingPaymentRecipients = new ArrayList<>(); + + List transactionDataList = this.paymentsByAddress.get(address); + if (transactionDataList == null) transactionDataList = new ArrayList<>(); + transactionDataList.removeIf(t -> t.getTimestamp() >= this.snapshotTimestamp); + for (TransactionData transactionData : transactionDataList) { + switch (transactionData.getType()) { + + case PAYMENT: + PaymentTransactionData paymentTransactionData = (PaymentTransactionData) transactionData; + if (!Objects.equals(paymentTransactionData.getRecipient(), address)) { + // Outgoing payment from this account + outgoingPaymentRecipients.add(paymentTransactionData.getRecipient()); + } + break; + + case TRANSFER_ASSET: + TransferAssetTransactionData transferAssetTransactionData = (TransferAssetTransactionData) transactionData; + if (transferAssetTransactionData.getAssetId() == Asset.QORT) { + if (!Objects.equals(transferAssetTransactionData.getRecipient(), address)) { + // Outgoing payment from this account + outgoingPaymentRecipients.add(transferAssetTransactionData.getRecipient()); + } + } + break; + + default: + break; + } + } + + return outgoingPaymentRecipients; + } + + private boolean hasZeroTransactions(String address) { + List transactionDataList = this.paymentsByAddress.get(address); + if (transactionDataList == null) { + return true; + } + transactionDataList.removeIf(t -> t.getTimestamp() >= this.snapshotTimestamp); + return transactionDataList.size() == 0; + } + + private static List fetchTransactions(Repository repository, List txTypes, String address, boolean reverse) throws DataException { + // Fetch all relevant transactions for this account + List signatures = repository.getTransactionRepository() + .getSignaturesMatchingCriteria(null, null, null, txTypes, + null, null, address, TransactionsResource.ConfirmationStatus.CONFIRMED, + null, null, reverse); + + List transactionDataList = new ArrayList<>(); + + for (byte[] signature : signatures) { + // Fetch transaction data + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signature); + if (transactionData == null) { + continue; + } + transactionDataList.add(transactionData); + } + + return transactionDataList; + } + +}