From 5581b83c577f3406f593685a50e7f4a32d151883 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 11:03:06 +0100 Subject: [PATCH 01/97] Added initial admin approval features for groups owned by the null account. * The dev group (ID 1) is owned by the null account with public key 11111111111111111111111111111111 * To regain access to otherwise blocked owner-based rules, it has different validation logic * which applies to groups with this same null owner. * * The main difference is that approval is required for certain transaction types relating to * null-owned groups. This allows existing admins to approve updates to the group (using group's * approval threshold) instead of these actions being performed by the owner. * * Since these apply to all null-owned groups, this allows anyone to update their group to * the null owner if they want to take advantage of this decentralized approval system. * * Currently, the affected transaction types are: * - AddGroupAdminTransaction * - RemoveGroupAdminTransaction * * This same approach could ultimately be applied to other group transactions too. --- .../data/transaction/TransactionData.java | 4 + src/main/java/org/qortal/group/Group.java | 3 + .../transaction/AddGroupAdminTransaction.java | 10 +- .../RemoveGroupAdminTransaction.java | 11 +- .../org/qortal/transaction/Transaction.java | 19 +- .../qortal/test/group/DevGroupAdminTests.java | 388 ++++++++++++++++++ src/test/resources/test-chain-v2.json | 2 + 7 files changed, 423 insertions(+), 14 deletions(-) create mode 100644 src/test/java/org/qortal/test/group/DevGroupAdminTests.java diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 060901f2..ec1139f4 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -128,6 +128,10 @@ public abstract class TransactionData { return this.txGroupId; } + public void setTxGroupId(int txGroupId) { + this.txGroupId = txGroupId; + } + public byte[] getReference() { return this.reference; } diff --git a/src/main/java/org/qortal/group/Group.java b/src/main/java/org/qortal/group/Group.java index 1dbb18b0..465743a9 100644 --- a/src/main/java/org/qortal/group/Group.java +++ b/src/main/java/org/qortal/group/Group.java @@ -80,6 +80,9 @@ public class Group { // Useful constants public static final int NO_GROUP = 0; + // Null owner address corresponds with public key "11111111111111111111111111111111" + public static String NULL_OWNER_ADDRESS = "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG"; + public static final int MIN_NAME_SIZE = 3; public static final int MAX_NAME_SIZE = 32; public static final int MAX_DESCRIPTION_SIZE = 128; diff --git a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java index 15dc51bf..3cd9845d 100644 --- a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; @@ -64,9 +65,14 @@ public class AddGroupAdminTransaction extends Transaction { Account owner = getOwner(); String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); - // Check transaction's public key matches group's current owner - if (!owner.getAddress().equals(groupOwner)) + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + + // Check transaction's public key matches group's current owner (except for groups owned by the null account) + if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; // Check address is a group member diff --git a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java index 3e5f1e6d..8d538143 100644 --- a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java @@ -2,6 +2,7 @@ package org.qortal.transaction; import java.util.Collections; import java.util.List; +import java.util.Objects; import org.qortal.account.Account; import org.qortal.asset.Asset; @@ -65,9 +66,15 @@ public class RemoveGroupAdminTransaction extends Transaction { return ValidationResult.GROUP_DOES_NOT_EXIST; Account owner = getOwner(); + String groupOwner = this.repository.getGroupRepository().getOwner(groupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); - // Check transaction's public key matches group's current owner - if (!owner.getAddress().equals(groupData.getOwner())) + // Require approval if transaction relates to a group owned by the null account + if (groupOwnedByNullAccount && !this.needsGroupApproval()) + return ValidationResult.GROUP_APPROVAL_REQUIRED; + + // Check transaction's public key matches group's current owner (except for groups owned by the null account) + if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; Account admin = getAdmin(); diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index b56d48cf..203cc342 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -1,13 +1,7 @@ package org.qortal.transaction; import java.math.BigInteger; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; -import java.util.EnumSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; @@ -69,8 +63,8 @@ public abstract class Transaction { AT(21, false), CREATE_GROUP(22, true), UPDATE_GROUP(23, true), - ADD_GROUP_ADMIN(24, false), - REMOVE_GROUP_ADMIN(25, false), + ADD_GROUP_ADMIN(24, true), + REMOVE_GROUP_ADMIN(25, true), GROUP_BAN(26, false), CANCEL_GROUP_BAN(27, false), GROUP_KICK(28, false), @@ -250,6 +244,7 @@ public abstract class Transaction { INVALID_TIMESTAMP_SIGNATURE(95), ADDRESS_BLOCKED(96), NAME_BLOCKED(97), + GROUP_APPROVAL_REQUIRED(98), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); @@ -760,9 +755,13 @@ public abstract class Transaction { // Group no longer exists? Possibly due to blockchain orphaning undoing group creation? return true; // stops tx being included in block but it will eventually expire + String groupOwner = this.repository.getGroupRepository().getOwner(txGroupId); + boolean groupOwnedByNullAccount = Objects.equals(groupOwner, Group.NULL_OWNER_ADDRESS); + // If transaction's creator is group admin (of group with ID txGroupId) then auto-approve + // This is disabled for null-owned groups, since these require approval from other admins PublicKeyAccount creator = this.getCreator(); - if (groupRepository.adminExists(txGroupId, creator.getAddress())) + if (!groupOwnedByNullAccount && groupRepository.adminExists(txGroupId, creator.getAddress())) return false; return true; diff --git a/src/test/java/org/qortal/test/group/DevGroupAdminTests.java b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java new file mode 100644 index 00000000..131359c6 --- /dev/null +++ b/src/test/java/org/qortal/test/group/DevGroupAdminTests.java @@ -0,0 +1,388 @@ +package org.qortal.test.group; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.*; +import org.qortal.group.Group; +import org.qortal.group.Group.ApprovalThreshold; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.GroupUtils; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.utils.Base58; + +import static org.junit.Assert.*; + +/** + * Dev group admin tests + * + * The dev group (ID 1) is owned by the null account with public key 11111111111111111111111111111111 + * To regain access to otherwise blocked owner-based rules, it has different validation logic + * which applies to groups with this same null owner. + * + * The main difference is that approval is required for certain transaction types relating to + * null-owned groups. This allows existing admins to approve updates to the group (using group's + * approval threshold) instead of these actions being performed by the owner. + * + * Since these apply to all null-owned groups, this allows anyone to update their group to + * the null owner if they want to take advantage of this decentralized approval system. + * + * Currently, the affected transaction types are: + * - AddGroupAdminTransaction + * - RemoveGroupAdminTransaction + * + * This same approach could ultimately be applied to other group transactions too. + */ +public class DevGroupAdminTests extends Common { + + private static final int DEV_GROUP_ID = 1; + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + @After + public void afterTest() throws DataException { + Common.orphanCheck(); + } + + @Test + public void testGroupKickMember() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + ValidationResult result = groupKick(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + joinGroup(repository, bob, groupId); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + result = groupKick(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + + @Test + public void testGroupKickAdmin() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + joinGroup(repository, bob, groupId); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Promote Bob to admin + TransactionData addGroupAdminTransactionData = addGroupAdmin(repository, alice, groupId, bob.getAddress()); + + // Confirm transaction needs approval, and hasn't been approved + Transaction.ApprovalStatus approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); + + // Have Alice approve Bob's approval-needed transaction + GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + + // Mint a block so that the transaction becomes approved + BlockUtils.mintBlock(repository); + + // Confirm transaction is approved + approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.APPROVED, approvalStatus); + + // Confirm Bob is now admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Attempt to kick Bob + ValidationResult result = groupKick(repository, alice, groupId, bob.getAddress()); + // Shouldn't be allowed + assertEquals(ValidationResult.INVALID_GROUP_OWNER, result); + + // Confirm Bob is still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Confirm Bob still an admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob no longer an admin (ADD_GROUP_ADMIN no longer approved) + assertFalse(isAdmin(repository, bob.getAddress(), groupId)); + + // Have Alice try to kick herself! + result = groupKick(repository, alice, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Have Bob try to kick Alice + result = groupKick(repository, bob, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + } + } + + @Test + public void testGroupBanMember() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to cancel non-existent Bob ban + ValidationResult result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed join-group transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Orphan last block (Cancel Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed cancel-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + + @Test + public void testGroupBanAdmin() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Dev group + int groupId = DEV_GROUP_ID; + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Alice to invite Bob, as it's a closed group + groupInvite(repository, alice, groupId, bob.getAddress(), 3600); + + // Bob to join + ValidationResult result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Promote Bob to admin + TransactionData addGroupAdminTransactionData = addGroupAdmin(repository, alice, groupId, bob.getAddress()); + + // Confirm transaction needs approval, and hasn't been approved + Transaction.ApprovalStatus approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.PENDING, approvalStatus); + + // Have Alice approve Bob's approval-needed transaction + GroupUtils.approveTransaction(repository, "alice", addGroupAdminTransactionData.getSignature(), true); + + // Mint a block so that the transaction becomes approved + BlockUtils.mintBlock(repository); + + // Confirm transaction is approved + approvalStatus = GroupUtils.getApprovalStatus(repository, addGroupAdminTransactionData.getSignature()); + assertEquals("incorrect transaction approval status", Transaction.ApprovalStatus.APPROVED, approvalStatus); + + // Confirm Bob is now admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Attempt to ban Bob + result = groupBan(repository, alice, groupId, bob.getAddress()); + // .. but we can't, because Bob is an admin and the group has no owner + assertEquals(ValidationResult.INVALID_GROUP_OWNER, result); + + // Confirm Bob still a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // ... and still an admin + assertTrue(isAdmin(repository, bob.getAddress(), groupId)); + + // Have Alice try to ban herself! + result = groupBan(repository, alice, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Have Bob try to ban Alice + result = groupBan(repository, bob, groupId, alice.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + } + } + + + private ValidationResult joinGroup(Repository repository, PrivateKeyAccount joiner, int groupId) throws DataException { + JoinGroupTransactionData transactionData = new JoinGroupTransactionData(TestTransaction.generateBase(joiner), groupId); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, joiner); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private void groupInvite(Repository repository, PrivateKeyAccount admin, int groupId, String invitee, int timeToLive) throws DataException { + GroupInviteTransactionData transactionData = new GroupInviteTransactionData(TestTransaction.generateBase(admin), groupId, invitee, timeToLive); + TransactionUtils.signAndMint(repository, transactionData, admin); + } + + private ValidationResult groupKick(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + GroupKickTransactionData transactionData = new GroupKickTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing"); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", 0); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private ValidationResult cancelGroupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { + CancelGroupBanTransactionData transactionData = new CancelGroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member); + ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); + + if (result == ValidationResult.OK) + BlockUtils.mintBlock(repository); + + return result; + } + + private TransactionData addGroupAdmin(Repository repository, PrivateKeyAccount owner, int groupId, String member) throws DataException { + AddGroupAdminTransactionData transactionData = new AddGroupAdminTransactionData(TestTransaction.generateBase(owner), groupId, member); + transactionData.setTxGroupId(groupId); + TransactionUtils.signAndMint(repository, transactionData, owner); + return transactionData; + } + + private boolean isMember(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().memberExists(groupId, address); + } + + private boolean isAdmin(Repository repository, String address, int groupId) throws DataException { + return repository.getGroupRepository().adminExists(groupId, address); + } + +} diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..e3a2f4f2 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -90,6 +90,8 @@ { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + { "type": "UPDATE_GROUP", "ownerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "TEST", "description": "test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "issuerPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, From 93fd80e289b9923e348c87bcd4e089d340215cb2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 19 Sep 2022 16:34:31 +0100 Subject: [PATCH 02/97] Require that add/remove admin transactions can only be created by group members. For regular groups, we require that the owner adds/removes the admins, so group membership is adequately checked. However for null-owned groups this check is skipped. So we need an additional condition to prevent non-group members from issuing a transaction for approval by the group admins. --- .../java/org/qortal/transaction/AddGroupAdminTransaction.java | 4 ++++ .../org/qortal/transaction/RemoveGroupAdminTransaction.java | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java index 3cd9845d..f38638c5 100644 --- a/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/AddGroupAdminTransaction.java @@ -79,6 +79,10 @@ public class AddGroupAdminTransaction extends Transaction { if (!this.repository.getGroupRepository().memberExists(groupId, memberAddress)) return ValidationResult.NOT_GROUP_MEMBER; + // Check transaction creator is a group member + if (!this.repository.getGroupRepository().memberExists(groupId, this.getCreator().getAddress())) + return ValidationResult.NOT_GROUP_MEMBER; + // Check group member is not already an admin if (this.repository.getGroupRepository().adminExists(groupId, memberAddress)) return ValidationResult.ALREADY_GROUP_ADMIN; diff --git a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java index 8d538143..043b5423 100644 --- a/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java +++ b/src/main/java/org/qortal/transaction/RemoveGroupAdminTransaction.java @@ -77,6 +77,10 @@ public class RemoveGroupAdminTransaction extends Transaction { if (!groupOwnedByNullAccount && !owner.getAddress().equals(groupOwner)) return ValidationResult.INVALID_GROUP_OWNER; + // Check transaction creator is a group member + if (!this.repository.getGroupRepository().memberExists(groupId, this.getCreator().getAddress())) + return ValidationResult.NOT_GROUP_MEMBER; + Account admin = getAdmin(); // Check member is an admin From 910191b07443dc4ea42d90f466e9f4ca8bb8c596 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 21 Oct 2022 15:58:23 +0100 Subject: [PATCH 03/97] Added optional chatReference field to CHAT transactions. This allows one message to reference another, e.g. for replies, edits, and reactions. We can't use the existing reference field as this is used for encryption and generally points to the user's lastReference at the time of signing. "chatReference" is based on the "nameReference" field used in various name transactions, for similar purposes. This needs a feature trigger timestamp to activate, and that same timestamp will need to be used in the UI since that is responsible for building the chat transactions. --- .../org/qortal/api/resource/ChatResource.java | 6 ++++ .../api/websocket/ChatMessagesWebSocket.java | 2 ++ .../java/org/qortal/block/BlockChain.java | 7 ++++- .../data/transaction/ChatTransactionData.java | 9 +++++- .../org/qortal/repository/ChatRepository.java | 2 +- .../hsqldb/HSQLDBChatRepository.java | 7 ++++- .../hsqldb/HSQLDBDatabaseUpdates.java | 6 ++++ .../HSQLDBChatTransactionRepository.java | 5 +-- .../ChatTransactionTransformer.java | 31 +++++++++++++++++-- src/main/resources/blockchain.json | 3 +- .../test-chain-v2-block-timestamps.json | 3 +- .../test-chain-v2-disable-reference.json | 3 +- .../test-chain-v2-founder-rewards.json | 3 +- .../test-chain-v2-leftover-reward.json | 3 +- src/test/resources/test-chain-v2-minting.json | 3 +- .../test-chain-v2-qora-holder-extremes.json | 3 +- .../test-chain-v2-qora-holder-reduction.json | 3 +- .../resources/test-chain-v2-qora-holder.json | 3 +- .../test-chain-v2-reward-levels.json | 3 +- .../test-chain-v2-reward-scaling.json | 3 +- .../test-chain-v2-reward-shares.json | 3 +- src/test/resources/test-chain-v2.json | 3 +- 22 files changed, 93 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index ee2a8599..8c0f94c3 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -70,6 +70,7 @@ public class ChatResource { @QueryParam("txGroupId") Integer txGroupId, @QueryParam("involving") List involvingAddresses, @QueryParam("reference") String reference, + @QueryParam("chatreference") String chatReference, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -92,12 +93,17 @@ public class ChatResource { if (reference != null) referenceBytes = Base58.decode(reference); + byte[] chatReferenceBytes = null; + if (chatReference != null) + chatReferenceBytes = Base58.decode(chatReference); + try (final Repository repository = RepositoryManager.getRepository()) { return repository.getChatRepository().getMessagesMatchingCriteria( before, after, txGroupId, referenceBytes, + chatReferenceBytes, involvingAddresses, limit, offset, reverse); } catch (DataException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index 9760b7f0..dbe36d9f 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -47,6 +47,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { txGroupId, null, null, + null, null, null, null); sendMessages(session, chatMessages); @@ -74,6 +75,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, involvingAddresses, null, null, null); diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 42692a18..d483f8d7 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -73,7 +73,8 @@ public class BlockChain { calcChainWeightTimestamp, transactionV5Timestamp, transactionV6Timestamp, - disableReferenceTimestamp; + disableReferenceTimestamp, + chatReferenceTimestamp; } // Custom transaction fees @@ -486,6 +487,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.disableReferenceTimestamp.name()).longValue(); } + public long getChatReferenceTimestamp() { + return this.featureTriggers.get(FeatureTrigger.chatReferenceTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java index 36ce6124..81bdb2b7 100644 --- a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java @@ -26,6 +26,8 @@ public class ChatTransactionData extends TransactionData { private String recipient; // can be null + private byte[] chatReference; // can be null + @Schema(description = "raw message data, possibly UTF8 text", example = "2yGEbwRFyhPZZckKA") private byte[] data; @@ -44,13 +46,14 @@ public class ChatTransactionData extends TransactionData { } public ChatTransactionData(BaseTransactionData baseTransactionData, - String sender, int nonce, String recipient, byte[] data, boolean isText, boolean isEncrypted) { + String sender, int nonce, String recipient, byte[] chatReference, byte[] data, boolean isText, boolean isEncrypted) { super(TransactionType.CHAT, baseTransactionData); this.senderPublicKey = baseTransactionData.creatorPublicKey; this.sender = sender; this.nonce = nonce; this.recipient = recipient; + this.chatReference = chatReference; this.data = data; this.isText = isText; this.isEncrypted = isEncrypted; @@ -78,6 +81,10 @@ public class ChatTransactionData extends TransactionData { return this.recipient; } + public byte[] getChatReference() { + return this.chatReference; + } + public byte[] getData() { return this.data; } diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index 2ecd8a34..ebdc22e4 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -14,7 +14,7 @@ public interface ChatRepository { * Expects EITHER non-null txGroupID OR non-null sender and recipient addresses. */ public List getMessagesMatchingCriteria(Long before, Long after, - Integer txGroupId, byte[] reference, List involving, + Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) throws DataException; public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index 2f570686..d4c9d7e0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -24,7 +24,7 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - List involving, Integer limit, Integer offset, Boolean reverse) + byte[] chatReferenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations if ((txGroupId != null && involving != null && !involving.isEmpty()) @@ -62,6 +62,11 @@ public class HSQLDBChatRepository implements ChatRepository { bindParams.add(referenceBytes); } + if (chatReferenceBytes != null) { + whereClauses.add("chat_reference = ?"); + bindParams.add(chatReferenceBytes); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 1174f5c8..53458484 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -975,6 +975,12 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN receiving_account_info SET DATA TYPE VARBINARY(128)"); break; + case 44: + // Add a chat reference, to allow one message to reference another, and for this to be easily + // searchable. Null values are allowed as most transactions won't have a reference. + stmt.execute("ALTER TABLE ChatTransactions ADD chat_reference Signature"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java index 449922f4..0dd3c0e3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java @@ -17,7 +17,7 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository } TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException { - String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data FROM ChatTransactions WHERE signature = ?"; + String sql = "SELECT sender, nonce, recipient, is_text, is_encrypted, data, chat_reference FROM ChatTransactions WHERE signature = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) { if (resultSet == null) @@ -29,8 +29,9 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository boolean isText = resultSet.getBoolean(4); boolean isEncrypted = resultSet.getBoolean(5); byte[] data = resultSet.getBytes(6); + byte[] chatReference = resultSet.getBytes(7); - return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, chatReference, data, isText, isEncrypted); } catch (SQLException e) { throw new DataException("Unable to fetch chat transaction from repository", e); } diff --git a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java index 69a9ef5b..d482dacd 100644 --- a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java @@ -4,6 +4,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.ByteBuffer; +import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.ChatTransactionData; @@ -22,11 +23,13 @@ public class ChatTransactionTransformer extends TransactionTransformer { private static final int NONCE_LENGTH = INT_LENGTH; private static final int HAS_RECIPIENT_LENGTH = BOOLEAN_LENGTH; private static final int RECIPIENT_LENGTH = ADDRESS_LENGTH; + private static final int HAS_CHAT_REFERENCE_LENGTH = BOOLEAN_LENGTH; + private static final int CHAT_REFERENCE_LENGTH = SIGNATURE_LENGTH; private static final int DATA_SIZE_LENGTH = INT_LENGTH; private static final int IS_TEXT_LENGTH = BOOLEAN_LENGTH; private static final int IS_ENCRYPTED_LENGTH = BOOLEAN_LENGTH; - private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + HAS_CHAT_REFERENCE_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; protected static final TransactionLayout layout; @@ -63,6 +66,17 @@ public class ChatTransactionTransformer extends TransactionTransformer { boolean hasRecipient = byteBuffer.get() != 0; String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null; + byte[] chatReference = null; + + if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { + boolean hasChatReference = byteBuffer.get() != 0; + + if (hasChatReference) { + chatReference = new byte[CHAT_REFERENCE_LENGTH]; + byteBuffer.get(chatReference); + } + } + int dataSize = byteBuffer.getInt(); // Don't allow invalid dataSize here to avoid run-time issues if (dataSize > ChatTransaction.MAX_DATA_SIZE) @@ -83,7 +97,7 @@ public class ChatTransactionTransformer extends TransactionTransformer { BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature); String sender = Crypto.toAddress(senderPublicKey); - return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, data, isText, isEncrypted); + return new ChatTransactionData(baseTransactionData, sender, nonce, recipient, chatReference, data, isText, isEncrypted); } public static int getDataLength(TransactionData transactionData) { @@ -94,6 +108,9 @@ public class ChatTransactionTransformer extends TransactionTransformer { if (chatTransactionData.getRecipient() != null) dataLength += RECIPIENT_LENGTH; + if (chatTransactionData.getChatReference() != null) + dataLength += CHAT_REFERENCE_LENGTH; + return dataLength; } @@ -114,6 +131,16 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write((byte) 0); } + if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { + // Include chat reference if it's not null + if (chatTransactionData.getChatReference() != null) { + bytes.write((byte) 1); + bytes.write(chatTransactionData.getChatReference()); + } else { + bytes.write((byte) 0); + } + } + bytes.write(Ints.toByteArray(chatTransactionData.getData().length)); bytes.write(chatTransactionData.getData()); diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index e9f1500d..2255c0a8 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -80,7 +80,8 @@ "calcChainWeightTimestamp": 1620579600000, "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 1655222400000 + "disableReferenceTimestamp": 1655222400000, + "chatReferenceTimestamp": 9999999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 37224684..028519f8 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -69,7 +69,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 7ea0b86d..541ce779 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -72,7 +72,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 0 + "disableReferenceTimestamp": 0, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 85a50f83..7392d5ae 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index ebc3ccfa..ed46cd56 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index cc91f993..a705016c 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 085d1dbf..880af61b 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 75858057..27451201 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "aggregateSignatureTimestamp": 0 + "aggregateSignatureTimestamp": 0, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 0706c5bb..fbc58d80 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index b3644d6b..0f7adf6f 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1c68dda4..802ad8fe 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 10d2aab3..2becc875 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -73,7 +73,8 @@ "newConsensusTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 5f439602..c3b740ff 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -73,7 +73,8 @@ "calcChainWeightTimestamp": 0, "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, - "disableReferenceTimestamp": 9999999999999 + "disableReferenceTimestamp": 9999999999999, + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, From a4759a0ef4f169b6934240c562bda218bfc285a6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 12:43:40 +0100 Subject: [PATCH 04/97] Re-ordered chat transaction transformation, to simplify UI code. New additions are now at the end of the data bytes. --- .../ChatTransactionTransformer.java | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java index d482dacd..b966ed2b 100644 --- a/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java +++ b/src/main/java/org/qortal/transform/transaction/ChatTransactionTransformer.java @@ -29,7 +29,7 @@ public class ChatTransactionTransformer extends TransactionTransformer { private static final int IS_TEXT_LENGTH = BOOLEAN_LENGTH; private static final int IS_ENCRYPTED_LENGTH = BOOLEAN_LENGTH; - private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + HAS_CHAT_REFERENCE_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH; + private static final int EXTRAS_LENGTH = NONCE_LENGTH + HAS_RECIPIENT_LENGTH + DATA_SIZE_LENGTH + IS_ENCRYPTED_LENGTH + IS_TEXT_LENGTH + HAS_CHAT_REFERENCE_LENGTH; protected static final TransactionLayout layout; @@ -66,17 +66,6 @@ public class ChatTransactionTransformer extends TransactionTransformer { boolean hasRecipient = byteBuffer.get() != 0; String recipient = hasRecipient ? Serialization.deserializeAddress(byteBuffer) : null; - byte[] chatReference = null; - - if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { - boolean hasChatReference = byteBuffer.get() != 0; - - if (hasChatReference) { - chatReference = new byte[CHAT_REFERENCE_LENGTH]; - byteBuffer.get(chatReference); - } - } - int dataSize = byteBuffer.getInt(); // Don't allow invalid dataSize here to avoid run-time issues if (dataSize > ChatTransaction.MAX_DATA_SIZE) @@ -91,6 +80,17 @@ public class ChatTransactionTransformer extends TransactionTransformer { long fee = byteBuffer.getLong(); + byte[] chatReference = null; + + if (timestamp >= BlockChain.getInstance().getChatReferenceTimestamp()) { + boolean hasChatReference = byteBuffer.get() != 0; + + if (hasChatReference) { + chatReference = new byte[CHAT_REFERENCE_LENGTH]; + byteBuffer.get(chatReference); + } + } + byte[] signature = new byte[SIGNATURE_LENGTH]; byteBuffer.get(signature); @@ -131,16 +131,6 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write((byte) 0); } - if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { - // Include chat reference if it's not null - if (chatTransactionData.getChatReference() != null) { - bytes.write((byte) 1); - bytes.write(chatTransactionData.getChatReference()); - } else { - bytes.write((byte) 0); - } - } - bytes.write(Ints.toByteArray(chatTransactionData.getData().length)); bytes.write(chatTransactionData.getData()); @@ -151,6 +141,16 @@ public class ChatTransactionTransformer extends TransactionTransformer { bytes.write(Longs.toByteArray(chatTransactionData.getFee())); + if (transactionData.getTimestamp() >= BlockChain.getInstance().getChatReferenceTimestamp()) { + // Include chat reference if it's not null + if (chatTransactionData.getChatReference() != null) { + bytes.write((byte) 1); + bytes.write(chatTransactionData.getChatReference()); + } else { + bytes.write((byte) 0); + } + } + if (chatTransactionData.getSignature() != null) bytes.write(chatTransactionData.getSignature()); From 23a5c5f9b495bc74c8af2c42f402d328bc6190e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 22 Oct 2022 12:50:28 +0100 Subject: [PATCH 05/97] Fixed bug in original commit - we need to save the chat reference to the db. --- .../hsqldb/transaction/HSQLDBChatTransactionRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java index 0dd3c0e3..79e798a9 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBChatTransactionRepository.java @@ -46,7 +46,7 @@ public class HSQLDBChatTransactionRepository extends HSQLDBTransactionRepository saveHelper.bind("signature", chatTransactionData.getSignature()).bind("nonce", chatTransactionData.getNonce()) .bind("sender", chatTransactionData.getSender()).bind("recipient", chatTransactionData.getRecipient()) .bind("is_text", chatTransactionData.getIsText()).bind("is_encrypted", chatTransactionData.getIsEncrypted()) - .bind("data", chatTransactionData.getData()); + .bind("data", chatTransactionData.getData()).bind("chat_reference", chatTransactionData.getChatReference()); try { saveHelper.execute(this.repository); From 09014d07e0fc1b18cf37827698f3064e568f16dc Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 23 Oct 2022 19:29:31 +0100 Subject: [PATCH 06/97] Fixed issues retrieving chatReference from the db. --- .../java/org/qortal/data/chat/ChatMessage.java | 11 +++++++++-- .../repository/hsqldb/HSQLDBChatRepository.java | 16 +++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/data/chat/ChatMessage.java b/src/main/java/org/qortal/data/chat/ChatMessage.java index 26df1da4..5d16bb7c 100644 --- a/src/main/java/org/qortal/data/chat/ChatMessage.java +++ b/src/main/java/org/qortal/data/chat/ChatMessage.java @@ -27,6 +27,8 @@ public class ChatMessage { private String recipientName; + private byte[] chatReference; + private byte[] data; private boolean isText; @@ -42,8 +44,8 @@ public class ChatMessage { // For repository use public ChatMessage(long timestamp, int txGroupId, byte[] reference, byte[] senderPublicKey, String sender, - String senderName, String recipient, String recipientName, byte[] data, boolean isText, - boolean isEncrypted, byte[] signature) { + String senderName, String recipient, String recipientName, byte[] chatReference, byte[] data, + boolean isText, boolean isEncrypted, byte[] signature) { this.timestamp = timestamp; this.txGroupId = txGroupId; this.reference = reference; @@ -52,6 +54,7 @@ public class ChatMessage { this.senderName = senderName; this.recipient = recipient; this.recipientName = recipientName; + this.chatReference = chatReference; this.data = data; this.isText = isText; this.isEncrypted = isEncrypted; @@ -90,6 +93,10 @@ public class ChatMessage { return this.recipientName; } + public byte[] getChatReference() { + return this.chatReference; + } + public byte[] getData() { return this.data; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index d4c9d7e0..178a4c24 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -35,7 +35,7 @@ public class HSQLDBChatRepository implements ChatRepository { sql.append("SELECT created_when, tx_group_id, Transactions.reference, creator, " + "sender, SenderNames.name, recipient, RecipientNames.name, " - + "data, is_text, is_encrypted, signature " + + "chat_reference, data, is_text, is_encrypted, signature " + "FROM ChatTransactions " + "JOIN Transactions USING (signature) " + "LEFT OUTER JOIN Names AS SenderNames ON SenderNames.owner = sender " @@ -108,13 +108,14 @@ public class HSQLDBChatRepository implements ChatRepository { String senderName = resultSet.getString(6); String recipient = resultSet.getString(7); String recipientName = resultSet.getString(8); - byte[] data = resultSet.getBytes(9); - boolean isText = resultSet.getBoolean(10); - boolean isEncrypted = resultSet.getBoolean(11); - byte[] signature = resultSet.getBytes(12); + byte[] chatReference = resultSet.getBytes(9); + byte[] data = resultSet.getBytes(10); + boolean isText = resultSet.getBoolean(11); + boolean isEncrypted = resultSet.getBoolean(12); + byte[] signature = resultSet.getBytes(13); ChatMessage chatMessage = new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, data, isText, isEncrypted, signature); chatMessages.add(chatMessage); } while (resultSet.next()); @@ -146,13 +147,14 @@ public class HSQLDBChatRepository implements ChatRepository { byte[] senderPublicKey = chatTransactionData.getSenderPublicKey(); String sender = chatTransactionData.getSender(); String recipient = chatTransactionData.getRecipient(); + byte[] chatReference = chatTransactionData.getChatReference(); byte[] data = chatTransactionData.getData(); boolean isText = chatTransactionData.getIsText(); boolean isEncrypted = chatTransactionData.getIsEncrypted(); byte[] signature = chatTransactionData.getSignature(); return new ChatMessage(timestamp, groupId, reference, senderPublicKey, sender, - senderName, recipient, recipientName, data, isText, isEncrypted, signature); + senderName, recipient, recipientName, chatReference, data, isText, isEncrypted, signature); } catch (SQLException e) { throw new DataException("Unable to fetch convert chat transaction from repository", e); } From 9d74f0eec0b4a8f208c8e8713d6bb2a07c74e1fa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 24 Oct 2022 19:21:29 +0100 Subject: [PATCH 07/97] Added haschatreference, with possible values of true, false, or null, to allow optional filtering by the presence or absense of a chat reference. --- .../java/org/qortal/api/resource/ChatResource.java | 2 ++ .../qortal/api/websocket/ChatMessagesWebSocket.java | 2 ++ .../java/org/qortal/repository/ChatRepository.java | 4 ++-- .../repository/hsqldb/HSQLDBChatRepository.java | 11 +++++++++-- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ChatResource.java b/src/main/java/org/qortal/api/resource/ChatResource.java index 8c0f94c3..2601e938 100644 --- a/src/main/java/org/qortal/api/resource/ChatResource.java +++ b/src/main/java/org/qortal/api/resource/ChatResource.java @@ -71,6 +71,7 @@ public class ChatResource { @QueryParam("involving") List involvingAddresses, @QueryParam("reference") String reference, @QueryParam("chatreference") String chatReference, + @QueryParam("haschatreference") Boolean hasChatReference, @Parameter(ref = "limit") @QueryParam("limit") Integer limit, @Parameter(ref = "offset") @QueryParam("offset") Integer offset, @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse) { @@ -104,6 +105,7 @@ public class ChatResource { txGroupId, referenceBytes, chatReferenceBytes, + hasChatReference, involvingAddresses, limit, offset, reverse); } catch (DataException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index dbe36d9f..76ed936c 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -48,6 +48,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, null, null, null); sendMessages(session, chatMessages); @@ -76,6 +77,7 @@ public class ChatMessagesWebSocket extends ApiWebSocket { null, null, null, + null, involvingAddresses, null, null, null); diff --git a/src/main/java/org/qortal/repository/ChatRepository.java b/src/main/java/org/qortal/repository/ChatRepository.java index ebdc22e4..c4541907 100644 --- a/src/main/java/org/qortal/repository/ChatRepository.java +++ b/src/main/java/org/qortal/repository/ChatRepository.java @@ -14,8 +14,8 @@ public interface ChatRepository { * Expects EITHER non-null txGroupID OR non-null sender and recipient addresses. */ public List getMessagesMatchingCriteria(Long before, Long after, - Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, List involving, - Integer limit, Integer offset, Boolean reverse) throws DataException; + Integer txGroupId, byte[] reference, byte[] chatReferenceBytes, Boolean hasChatReference, + List involving, Integer limit, Integer offset, Boolean reverse) throws DataException; public ChatMessage toChatMessage(ChatTransactionData chatTransactionData) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java index 178a4c24..08226d53 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBChatRepository.java @@ -24,8 +24,8 @@ public class HSQLDBChatRepository implements ChatRepository { @Override public List getMessagesMatchingCriteria(Long before, Long after, Integer txGroupId, byte[] referenceBytes, - byte[] chatReferenceBytes, List involving, Integer limit, Integer offset, Boolean reverse) - throws DataException { + byte[] chatReferenceBytes, Boolean hasChatReference, List involving, + Integer limit, Integer offset, Boolean reverse) throws DataException { // Check args meet expectations if ((txGroupId != null && involving != null && !involving.isEmpty()) || (txGroupId == null && (involving == null || involving.size() != 2))) @@ -67,6 +67,13 @@ public class HSQLDBChatRepository implements ChatRepository { bindParams.add(chatReferenceBytes); } + if (hasChatReference != null && hasChatReference == true) { + whereClauses.add("chat_reference IS NOT NULL"); + } + else if (hasChatReference != null && hasChatReference == false) { + whereClauses.add("chat_reference IS NULL"); + } + if (txGroupId != null) { whereClauses.add("tx_group_id = " + txGroupId); // int safe to use literally whereClauses.add("recipient IS NULL"); From aead9cfcbfe23747d60ce1a59d05be9354ac22fa Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 1 Nov 2022 08:55:57 +0000 Subject: [PATCH 08/97] Proof of concept: speed up QORT buying When users buy QORT ("Alice"-side), most of the API time is spent computing mempow for the MESSAGE sent to Bob's AT. This is the final stage startResponse() and after Alice's P2SH is already broadcast. To speed this up, the MESSAGE part is moved into its own thread allowing startResponse() to return sooner, improving the user experience. Caveats: If MESSAGE importAsUnconfirmed() somehow fails the the buy won't complete and Alice will have to wait for P2SH refund. If Alice shuts down her node while MESSAGE mempow is being computed then it's possible the shutdown will be blocked until mempow is complete. Currently only implemented in LitecoinACCTv3TradeBot as this is only proof-of-concept. Tested with multiple buys in the same block. --- .../tradebot/LitecoinACCTv3TradeBot.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java index a31a1a28..a4ae921e 100644 --- a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv3TradeBot.java @@ -19,6 +19,7 @@ import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; @@ -317,20 +318,27 @@ public class LitecoinACCTv3TradeBot implements AcctTradeBot { boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); if (!isMessageAlreadySent) { - PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); + // Do this in a new thread so caller doesn't have to wait for computeNonce() + // In the unlikely event that the transaction doesn't validate then the buy won't happen and eventually Alice's AT will be refunded + new Thread(() -> { + try (final Repository threadsRepository = RepositoryManager.getRepository()) { + PrivateKeyAccount sender = new PrivateKeyAccount(threadsRepository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(threadsRepository, sender, Group.NO_GROUP, messageRecipient, messageData, false, false); - messageTransaction.computeNonce(); - messageTransaction.sign(sender); + messageTransaction.computeNonce(); + messageTransaction.sign(sender); - // reset repository state to prevent deadlock - repository.discardChanges(); - ValidationResult result = messageTransaction.importAsUnconfirmed(); + // reset repository state to prevent deadlock + threadsRepository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); - if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); - return ResponseResult.NETWORK_ISSUE; - } + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, result.name())); + } + } catch (DataException e) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to Bob's trade-bot %s: %s", messageRecipient, e.getMessage())); + } + }, "TradeBot response").start(); } TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress)); From 9c68f1038ab899e69831345f10916e9ea6732115 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 5 Nov 2022 14:02:04 +0000 Subject: [PATCH 09/97] Bump AT version to 1.4.0 --- lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar | Bin 0 -> 161850 bytes lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom | 9 +++++++++ lib/org/ciyam/AT/maven-metadata-local.xml | 5 +++-- pom.xml | 2 +- 4 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar create mode 100644 lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.jar new file mode 100644 index 0000000000000000000000000000000000000000..c2c3d3556575dd7793cbd08ba3013003e8eaf621 GIT binary patch literal 161850 zcma&N1yq~a+P~dWph$3s;_mLy;#%CD;O?%)g1fuBQ{3I%-5rXzNP&Lo%$)O{ng91n z*2-F0VJFXi!mZc++w#)yAkp8vfq{7=p<}E9r?t^7|K`mb1@II6?i(3VWkGsLSusW# zL0L&LQ6*&t8L?ZL@v+a+^b9iy()2Ww<5OQ1ndVuy4;&bzWauPiW}M6F6|ZFIq$iZF zr05i3hN)$y#+8^BSs_a8X(q-cXTD2~EFv&S$Us}L?H}wOK)jI$?*I7yR>o_xnb^uxcX$ zpx(TR1OIHlKl%G7`SS|k8V|m!x83fE9WcQ3yebLzzRbSx7=!r#wp zcyhhP@$#0>@$&rjHRU-q{84HmETuN3sc~NIvg*1E3y}3b1EY5wp&H}p8+zB+2h|V|2RW4i zO)h`d*9VK1n!Zfu#&}9;wRH{+pLlL==1)vCHXLekIYV^eytnSEH1=s(mI=GP2bK!3 zoyBx|MUjdTUob^gZ!USE&;+S@ynxZ^#>rErEiJUUoXskIR@O(boEtQ5helMot^vkj zdo`xv#$!&7jVk2S{@CRrW=`?CzTqX2q z?~#PA*=v7{Wy>cG&j_RBaf!u3hUXE=dB_yMV1`TBi9*B^#n=f|JAj*POs&J2t`ZB#B#{4p<|&L3e|fMcG)qP=F(r@f`SuD;RPRnzHxcGwzqc^wc%c#Xy`u=kobYFlH6nk#16fFZ`NfEwJQ@lBl@ zJI_Udzr@n1>L{Rgx^%*UBL!w48Us2NBOIey9ihXQcLCpQL2OB6)dg-mk!fZ7qvgCn zeiiS53#tW1{ZT4E- zY7&$dBdp>llU?M0%XV$_#_`Gde6B!#-E6NSbR{_AWF#18bzu(2>Ba!Vqg8f+`!Gkr?L0s{4%-m z^MWFrTh8&`4-(YA$}?*Y$$ht03?tN2J>T6|uO#wQmU%$jCKF?lf@BM{hc$$($n@&PE^bDvzX-lVB8ii-J)bfwi?L+1 zZ;qHM|4;z4!lqd(hQvJ{LBnQIzCDg%JnzbNhk*RG^e{Hr?DAS=+FoTt+_X;3JCY&p ztLG@88RCODGz}NtRhP(=R{ghp&Xw;yv>Bdz){1q>Lyh z#()u5CgN&+RbAoFoxz?r-c}=LRKs{#Fwvoy#6Fb%L9};;r0%4|;%LlL?heDpf~ZT! z0F2Q_DF*}ve1HN!OlX>9o;lABHP4X7P1G)rC}!Pv;U^@mtMX$d=1K#xxIj>t6d`|! zf?^Lsb(D$y+}F_6^jo@@hyHS0j}zFCulT>-{Z1SI6-C|6Jfz1V-nyc#J-vqq7}ZJc!MN?UMX-XsBbOc8rU7IKfFP$XD(uN2@1jR6<%RvW6BHNQaI)^{(y**&o za!~-+5&E#UdwQ!EWqo^$=O8XO{OkQ`To4>)lCDS2YBe zF|?1#RdOAtRh$Rx$?ec+IDIZ(M_z*Jck~V245p3UZ1mp*0MM`)k{pi-LYx>fP#Pk- z_9$lLU#t`1X+h+g0~u!o%KRY0zOTC_t@eBUx zcM)+&b`Y6ahUntDW_^`0qC?j8OSN1)Auo`b$9WP1t&)vAtVKmrFumhg^}1K5aJ|&_ z-tGO?8IE|zVsa^K0*p_?KM}nx1Mh~M8z{f8V!_VMj|`_g+-WTo6Fb_bNcJIvgiR1g z?-qo@#D#w`M+BFgc8i3FP*F-qN)JaIa2bzm<2=Q}oaKhfH^etbkLdl~3A*^9Oz6N) zu=DQC8<9_||@$4tk z8@Zj;;oYdnwq{d9sQX*Dm?2@%dbj}e6M3I@;bRn1GoHhWy%rCgQSpWQCa_^bpMAbo zKg9xC@tnMa6b1h(KPD!%=*E5dP(lC4QBGl-;P|$iVps{v4Hss;j#XL7-c!%}O~)3O zCbW|?0m;7hn|+N;V%}|_&`|1-{=Iv@Cqklxj7aRXB-!4NmR~Ws%WAbYPC>qP&NR;= zXwU68myp#hhu;;J81CEVC8ZtdPwD4!Ze6I-fIa{W4BYepU(v{#ICT`?PwQkDn|Xtc zvfeYC>KWoeKj;%hI+qb6?B@Ee&rEPdzLLMl53oh*7HTF=i9kq*f`4A5VCvZCVxb&>-X%NL;gsIlYKn ziW?x8kojQH{4xq2(vh=hki;rt5!uR{tQ)`y`j16M2j?TGMAX4aa5~^P`c*VYQ-PLZi(gh3Kr-48;;j za4Z_?qv4Uewtu%Zr(eron!vW^0uH!D|FpF~%T{@y1JIg`{m;@hQQ2AvRRP_Hfe2ng zDj~@zwG=kbfUyXa%BaB{unRbtzdLEw*3UFfUg_X^sy>zGo|wODAzW~}Fn|eB`Zl?q z>^S8<*)rwm;`{pagx`(IL3%7+M#w55j@)kr^As0#PqX`3XA9owkmo0*qSVPJ|1Oz(hiGK ziR}t`?GV0h`g1_M%r{oZgiIilt-3*z0x$9;o{xSUc~+CN&4uHT{7+Ux0aXMkE)VVp zgCv>-twh;pVH|+BG4XerzSR{`y1q%PEQ#6`s^pQ}V&(;xqu!p}Y4W!S$B@1k{ovLs z>4UWPjmyyRk6MP)*&2GaKGY4tRT&DLqIfK&14S(AV$TouUw7=6@*C5g*AjzT%j~9V zX+?U#rGJ6cq=T}+j15<-n>kgMp|!{aEuEs-+N{i4p$M;#>NnKHW?JpXG=cmOk|H2? z7zSS?Nw^A)v4aKhF(syz7TD}zGpg_!W|!v%Vh6<{R=PCn78X%SF$95*6eo&R0l z1`Wg=L~iQc@j6zGT>TGXS;H8|VG%y$2Tm3s0 zC*km*&!Z}QqRqV7b4iRdqsiS|7w$)}pY`KNAX08fE)7@5D$Jxjl6(NOABq<+G?5@l zMM(J1YPbGw^xuC}N0ESy{@{NNuq;e}b!>{@VnY!9wP?e|Tq8F}B^E+g-cztKXRsev zSW%jax;=pn)O5nqSiZJm;);1~n(8L$b@l!sKf<&zve9B?Wjy0?GWAm1_xbq;em5)M z1W9BY&U&8-=KVEMKrmGrRVo#0fnM{h3L}ncSY^|5#u`B~e_JJl+7F8fliT`vAk*n< zs=m5ai{c0IfGQYSwh4KTsFsq`+#Q=V4J9%i8S{=RMq==9z960YkYS)e35ogvcnx2ug>8<5%Qmql+XjQ| zNJOu0cYoox?}jZEsfe$iC{b7sB$hNd`!A-$T7nj&OQ@#hKRpjwge8VyT;0b@DsEd# zRoL%@$|DS#G$(mcxciUPyo9SDL6{F2qYrN$4`n{_dgN;j})Ce>&SQ(%_Xw2?{a~ zB;K20zObrO=Xp-|s4D}6GR)NVb;IIiMZ&Oz*RF~WD88QVZ4X` zyM^f;1Mrx@7IyN#S{TP)fz?}4#&%8s*?Y~anHf}6_*QBzNyv|k9sx^$1cp6OxgkMS z0+#!((K)GqyCVO4YeMz_6dB_4n^y!>ZIrJzDsd3C#+T>mE1ayhKh~-l_1^LcE{H*& zaoFrdMIx*>*iH3%gU(s?o}Re-6idG+5#CyK8+WZPjwZjPowWuZFbKDz{DAD!9!uUO zA|QZW*b#qA3|~3DCzwpTbw1TyPw;>euqQ>WOuDT=X4bX}r_y!$Ncd1Q>g>#!l=VZ{ zJcX0sQyH^SlsodA{UOUTY1Kmw%Ag`zEGmyG%W2jl63$IN(`=;Q)h4@KAeNFfJF8O0rIiwgJM(AXXl zc=FYzK6Q74CC=W7=poy5b7UDM4?*l1wjV(Rf&8GAcPJeKFh+o5ynP07&12rRMRdpSzWmMk5_4;&$OJ72|s*UGnO(cS2mNZRrIpWOv7I7&t)3xbTfYps17rcXDc~0XBV71 zbU0uYCC;dVok`KTysG((A$f48lW&`p8)P>A;;dWtLvCug&N9q2i6xD;wHTdQMt_Cb z`ebH$F-R|l8$^rOd#hG(glFbIh^!Ged+(K;OoO6piB?2%T-X)pXFiUdGVnpz^qYCi zFfJA#0IPk>k4@2wMrTN8T?x-MTaeW_wn##QwDw`3)21ty*J81h$p7bTA+(DMZ^ORB z5M4n*{8x0cCLG;)yc4}#%Sr9NcE9%$xW|HweODSG2OoyLV*KZlTSu#1y?k)a zErRwC!hlS%?0Ug515fbq9RAEgma?U3Q1 z{WJe7b~WGHfxU$t%&z}CZ((NpEAuP;tF_WmSN*6dKtoibq9uTdELuqnsn{2H6RW@N zR}AE}N>11?ah2M}coC+6Mk9=vO2hvCzSK1s6kWiQK7P^RIMs5LHRAL0_7VPzdpnE% zc5ak7^y*dfJ9l(IgdxdeQ_HTI;t(3X5}v9ANwBq&nh0wly(0ge$F88QSbZwLV zeXb?^E z@hXf4Ox9aiI}VZJu{m}%D&gzn+Ofp${gwz2Ea`!ZdsWJxoB}97y7qJ#AavfW3L6d- z*2l9D_6udntE7EJbl2*Lj#^iu52X~+V%OBpZEvN}uBQv3Q!&W^Jk`oVC^Otz#!ON} zQ6x(^QwshLgiMg_XIY-lU( z9hzp6ja-q4Pw=}7pt)sMru95CC`peuhF%74WaZuvKXWWv&EymiuJAYNqlr<}a^IT| z7!UdNr5 zFcj)RFyd3_Pb`A%rc?f6^g{-ZqnH8wLi%800NQGfo5;yYei`5bb&e|2Qkh-sC;!6U zN9I9pVd7l74hsmei)qdC$M=7a>#c)fEt6nNo%kQ(Iy>{9miliX)^XyJ2;skOt7`R@ zI>(I`m!#+a2Z&u~r)$lo8|rP3Y(El z#I87fk{SV6V3-mG1QEAdtrmIp)TE3OUXfp&7$uW;q11D?C~D0el4nU{+&F^SKxH?ei^2 zTU!-yO3)OGcZ7?`+gW50>!w<`UAInhtAtQ=n&2hv&5>KfY1m5vmKp;Ym+=u477 zbJaNu1d=3n@(pY>O=h*OSgW~d^AI4EH!&wtWGrL5tZr=RscY$41EQUt2(nJF`;q(h zMMKO9xr0gKk!(UigIjh1W)cGJ9>9T`&toaB8GUJESNV_S=E(C$9h2YU$e)e)Sv8>KJTpXD-io3y*nzGI4LjMhpm#t#vA5ppY5MXk>_szfWZ zv-U7?Ga`D%IA_(sZ9eXFbl>R=E`WX z+*Afi;0hy*&XolmTqX&@({g`hw&PiIkgiI)tar1_%!?Bu8cWRdd={IBuaLUByV%fU z@mA|cgp{Ak%Bwmj z_-oeq&71a>D^>(_-{4c6e@Vbb)N9Lq6YCJhI3MX_a39p{ByIc2Vz<(1m)5!mf>52i zRhGXTWl}bpY<%8cc3Ho#GnKZs70=4o*)lwMlxm@f;Y!>HX$&tCI23`dQ@baXi-Bg%d8%%W|k-7Lj;QMPkXui&cy`D4^(Rp@Jv0O79uU_}w82+;! zsb~wv4Spp!3W&4$fA|Fp^a~6U>!?iRX=kP*JNc?soYvk7HiAWOG*;RfMzV z=TsZl(U7xkH1mw5zVMhJr4scLgNZymfvbcaoIW1mNMOJY_adaj68>8qzh5Az!>azt zV%eTCm)G@;PY7>?My(Y18lvtK4`gVGDRd}~4B2-EY zO8=h>k(K*j=wMBDO#qqSG6TMcjTl*zNPw|kTN;L}9yu&VNL~p_*Z-z_&7iU0x+b=x z=&qJK-DC{Futt*|0%~icK3Z+cE+r%F z-1nD+=Th{v9aY}uO{MWgNt!BY>p9NJf9 ziI!5Bhf%-IPQpD9+!{_x;|pygmJ0WuKve_{JR41p4|EE7h2_l#+K2haih&*=K1YGE zem)qW znlEH=UD7y(Y)p07Y#?Tv2>r$3T%!n=MAc5}$_Uu~7AJ%Flsv$Ak$v{4x4=5tmT<4Q z!Vet2C@a1!%2?w!8c#)3Gy^9tEM0$SHU4W>R#^Z$^9{@w7{S^N;(x}(|3tUUe}%+I zl}jZ&6?A?^*p-?B1VzoKu_VQq0%ioW24!SFi8lzas8W?GXNK;6^dp_)7x3@)9(*6L z9&la+QYYuH(^+3dCy%){n(hbWLw{sujtZ4s^~LQgi1NkkQ%2f&UGJ{&;}dr3b+KYM*N-Ajif?Gu z!C(;f>ah<@FL#(iBj^h-+QHd6T%mF0s53rTog91+5^TlO7~4E(pi^5>;e#2U5YeCzr|7(;= zA_0mHx`k4eQn=_&sXGbA5O1^Db~sn32yv7sI_&1+W|0%Dl0_E%%zzg&H=BbkrD#wT zr^R+TSCT@MA(Z*4g@-xuEgZp+aLiHOr_&8NCwDQa1M1|Z^n`X1^k{ZNX&h(YH*TT#jNvGTHVE!$WHjEv{NgZ(lBc?D{Dh(@kC!rJuXw&F1 z#E>oQ(9FZ5cGYPjlC0&lqSTeJ>4UjOtW9M3c5-)oJO`lO+8=c1WZtT+o2tjapFw&r z{y`Tl&H2q{Kbn_x%v+9b>?aE^>HPvj`|rc5>Ge10uIL@)gAmx65s>IiSD7x|NPvLq zDJxSgn^4RG4aO;Y@(rDLmIcCsqm^QrJ4ZjtDy3yyCmc;XXxe)ySiX#!W2w2Ek-T|@ zcGjYHXxF4)lag1%?3>oIC#B7ZOve@U_tCwhjEH%?gz6(-m6?|N9N1~-I=jR=)#Fp> z4C8$lfeP_}mwe(LY7p3iq5a*l-wt7k8m(i+K48+Ri0@nP6J1C2^xIy~A(odt(^Ff6 zRhEqc>|3=aGmPFhm89q`GE;(p2Di=vy0jyQCnS*{GxlH1m~SaPJR^ps`w4=bo4vkF zc8uFH`R2MUT|a!>djq;eZ4l9kNur=Y#c1(K9z(-3e%4waF`XSm9L<3yRO$6sv}>aZ^P^9I_x3bjz;A?5k$%CYu~v( zoERE9TI2l!zYnnn(FU1o^>vmVZBn*qkshv4&sb?#eq1%td)J{gfw8WnPy_wzW1I?= z9AP!gN{4mkuKiY8bwkta$YJ{&8sdc}no}Rr>Y4jQ^58O2K;s}9qOV(&Rz8pBA{yE< zzw`h((5>&IwiY4e4;XRZ^Mxs%s>QLkJjLBFw~TeX68rm>v<;>N!gMlu1jIA{ebbIlFI*k$9YGHCOMk ze}#u$MBdBb!R4wN#y4JWx?XbMuX~zyzVbXm=p#lu2clRPJ8Fx=?BPm{D z%8LCP{3OREZ+mkX2~+cqwPvr`)(wtp-HFrpX5cZzibEM2TXB5_6}i3DPSTCdh;eWA zBe7^-=9-K~*ay(0aVCs6l)_RdYR*#{@660Bd#A=e^c6bqJkrkIBCrV>hR{i#cy^A3 zAHY;Mz$&5K&Fb}bvzY+KcRBtGtR4Lutl_V#;JHSF!J6NnVC@kM*5dvXtnvH?YvaX# zfwioKe}T0Ozn_1CH9Pix2gCzE2_ye&h5H9-8`N=Tgk1RsHogM-2?=+38Wu0!pIj<$ zMkDohWG69o>p!HnKg_>S+y88)dcCastnrD{=bB@{+H))qWRZi_fBfkLh+RV1#3eI^ z40>YQZ2$+Sy0``*-Gike%BQC`;q0D|aSLC@?$$(@^|x~8+(T}t;3mvq<(;$zEeEg! z@p8Kad3mUma;xGiFCwl((Hwv`Xt?Fgtq!$>W+tDe^zn+9p{sV&c8ioKtCXCuGKKd~ zqK+-(Xc|6|>q&?r06M(i)87%~4kG#WT73Tm0g@e%F!MgHW&%u?z723f%oo%vPs3|X9c&<^DkkWVZhAZ=Pv(0hWAwjE1Ka{OEEjQQ^;v7r^#*F7g& zj=Z*PC)-~iZ!o@;tAEWgECe9Xqa}aVHetGWGC*goArLOzgVJ@#6x7 z(>~y;3%%mMLi+V6S zNf}Djz707vQv2$CgwyPkEuu2-22k>a(BU1muJ@myRDWhodPw${akQB0*T^7!gbLHz zrc{UI%&k4=iyfv}*rztfwEtL)bI2Ci80dbNo*Uo8PnIxvO-QGwu(u_Dv;|KWnNcX_ z>OE@kMa@>C3w}Q4am51fj_f6>cq~C0;+Mh$zF1WWP|}&*USVTQgcwU&PuLz~@gF+d zp!sGgssB?0#>V=u224k3P9B+`vw$#x2nq@c?!gZNikk3G5>UVVqwq7KTU($d9zAUQ zI?>0U=h60JLpX%bZ(pf~HoEo8g;?(QGd4QXH#+W*sxLpi;p=gMdzW%H#D|Gp2M7m9 z1EO(~a2x=%uoV1JgW?S}J15$+!HPh%!(wniU*kwFb zWx+$iqg%XbavEqY z2ys?`r+d6+v9!~J;5x*3Yc8KwXX)DhMK-_4X^j_EVo@bWPvI9P9e^UHNxmB-%K2rR zx{nnk10w0e>~pk&F{#ZMAkUa<)7y_`(| zEK_t{Um-e&p0V!~gOQsBEeP#B-qNRADx`3XEg)0?nsHaSF+R;aN30SUy8c))><`*vte&*zhSumJs zd;qA@pB{PP7fpcK&HX4ZtDxfk3BJR4b<{5as?zFN{l!_M+cAnehlk^%yxY#NqzhSfO1HoQY%0-eub zv~qu155>(ZhkpicZwglmJ)X%L6vfInWQO<8r602fOyqzuKJ`4_ZUuwyo<0}Q3kE;U z9FEimkm7v9S)QXZNx9$}c;jopxS`RnjIQ`V=P}U#(WtfYh3gGMv^_Fuf@F2wSOHR; ztsx)Bcitxslysb>*4cWFsxGYWwb{9a^)knvz~%wBg44?d5uPmBtGrZ>_)q+TG}dv6 zf))l9bEPJE%yTIz}>2&uoCQM;2_TN`W_^`eH?e9p>LbfFxXAh$QXr*voaU+UmSjoQvdo2v`#YqSho5S*kMvfU8h#ROIr2S2A2)7vD2 zRmG_;U{x`}{)g%Wj&Y?wRK@m78F|YG1YlM1PXN%E#cBVumX#VU)XukSe^CjwN#^E; zDSo9DXC}I_qNyBHYb`fR%zJM^6TPpfp@jng;r$dX6)7VPi+L3lYM&ZWIY8#F2(}8f zZhBq2)>Mj|?!@x0SSs$MG=u12j^Gd39eHdPdCcp?fl9_OA^STKH=d03c- zwYc5fc9}u-$-_)-d9?>u>zDHwzm6&rtuoVFFLd^j5;@H?6x#Khf5~}Zi)jv3PR<~T zEIa($L|@6Og2hjsvL~Y&F>isDdy8^ccq@)S#tmZc!tu^EIs25o}tVVF3o4n zd-_WKo}mnmBXv1@6ENMENujd_?tI~nK;dbK-JK!w0qngkqhLZILMek)oW*1^WS3Q8 zaRd2~`THD;m8?nfb?^V9a=IP5`PK#Y0yl8JBKl`>{-3;%^Us`>sQl;TdoIaFi)NmN z3PZF&LfUVaQXj=HY_&i>j%Mb8&8qlY+&7!AnWzu66rB(7ccP)8gQmYl#r}o#6y`|IV5A-4X$sDN+<1-G)R1TCC*8FzJz7D&?aADqDz4JD}ccli< zlD9)FOk}xq3Zni1vYV_6jAwVxC&mRxhdvW*9ht{#B|0hafK*2IZyJ>(&jyLB3tK*8 zoocj^HMaCwUVq|B-A4?NTfu?81(7$VHR$ZL@3&ECU87<@l<{t$x z8kHz^sGRhFO6ZwZ!&IzhsmRdoXF0!n%0)k$a7g{&fg5k_%5Z)GtAjfIMfR##79h-T zI8xhBZtDuL{&1YffUss^rk=wq@iRpdRNB9y+_JARdP4AX-Kur{d&xJ84Vz&h9hK&9 z%NTh?mYnpZabG`R$k(SwF=XL{9ZBFRIKqXauhuxt*~sU~5SP1zO0AD(Pj>~eL( zkj!d+ott)H&~=v!M4x)_)S%yVx>Q^ehlq9X5UqL2eDpgE;_SE_%fdKPT%(kiY%!7> zcC}`qR3O(3@i$ohvQh-;iw|RGG9@VzuJ5LgG#fK z;b+5L#;#`IA&Zpqzv^{=oLLQ^iuAd8v$=yB2_C=@Y7fSQqY#Z@2$2{w37Nv3a8Kx_ z1(c?exY4rem%?DXSOnzLV4@@Uwn}Z4I8Z!xY^e@;2+*0N6 z^#oVJAtD~Cq+M_S-kFY-T(ADG0x6b14gX&PDOqz39Zc$+10R&I`Y#FLVVcT90lDf7 zDE^^*nKsc*z_#L*4U%?TU;pZlKX=3b^Kc0{I1SAH7|%$(Ot&?0@qOlbLDYXc_YogZqLgF+{VWa5g?pvH`YkVq+N#&!IxrjfbWcreFl0dt%~z)w*y$EgEzoQOX-j!rbm z$4|*7As_79ghJ`0gOYx89QKmXT%8Z_Uw6aRlJwTgjK?V$Oe-9}NF~<~U;u zFx8bfa|(AedZXO{6NGVK7O|=?E?M0qRNplDr4&&ldbZ?}J<|QcAiw_fXN^2+#YHm` z_m*m2+k>h!XNr*RPN!18jRslzwsq!koD+09s1a*Wa-`Uk+HouEQjPvL@hr5*%n*|U zfdRMpjeLPa&qImlGXf{9WjD3QnAvefuY&rxxI21zkjX>Y@mUz7H_;&#*g1Q+~a- zr-pBZKQp6heuB0D1USI~LncuI@5ntc8z6y*~` z8ib$%@rrOqK@R%~d2OhWkTr5^vDPs>q8hv3b@f3s; zHr$OMP9=>%?~I@W*}5LIsi&m7+7oAe zNEH+5iYY*x>Xho_w?LXJi&T%L0uwBdj!S2_Z#9vIj#{c@PoKC|OcyNwiRBO6bl|hc z{mx6mwTGV^BRba9vUcc~e}_^sl7+^CEi;8CxN!ialGvzu&aqsbe80)r!iGdTl@xVJ zeOPJHFc{OiZzD3L5f46z^7eA+WjJ009R>$VcbQ;i3diByr-biWHCyaef%uaC*;u~_ zw=t62IlzbCT%u{rB^g933*p!{Bivalbc97iL8m*PbkQp2E539QGRC3WDgYyCwE*at zrzJ6)H~HO|!BH0Lz0JR((x7M9_}0D)rwxY2@c;4$9x4akgq(8b7>W%w`VB450CATG zvZBiTy!n7s+Do=+_sXn&hk@411}v|W-m7Q8``K%vdow9RJmbfDIe=q4jPPKgCEJQo ztFHdKFDVkdq{=&DEsE+>mXGslHPN+dNw4NN5L&%- zBgST<(UaQahWZ4*CDMKcxy~q@)Nvs4{ZL_1H<^#M)@?Vip^OqfpdXY%Nj5dc<8_JD z3N}CuW5HQC)kIFordlzuM4G*>a?Y33WW7HVTM1($GM3GzU%?C(V?*%bACeCDb|zhR zu#Lg~uaXXqzX}rXKScQ0;8RW;x~f$ADg|G#=5=Fs6TupMI`SlRFDi3@Z68bhzcl!d z+vFb~-aRV}+BQbko2|&Wba5Z0-|G0jyk4VqQ)Cs!c4x9(7_dirHfQ&~L;s8}lYouN zh<{R|27nJb^z<2}Wk}AHl75UjSH_DR1lCUI&$k=)FwHp?EF*b}_kP+IXCQWznL4yd z{~7fWNl@51^GlC~1od;;Sb)H^eap#XX4p5c-3~al#7z0s(dz< zqb?*dO!MGeo2a*ogrOqb4fwVXDwk?}{Kch7M!uQXdurNvY)QwYP~Se;X$w#HAKf=M)umm(<+lciJGpr@Z9E-^(9x)I$pNiuTSZ9`94EleVCe+*tLPX zugMzp=9AbVzM+7l2m_3$RXHtKf)3hWva)aLhK!5X<^5=Re@iyB%~-Hiy6CYFt<}Q% zN|sQ2P;A``H0r&BJ!J@*H3?`C7Mz8l%NYt`$_S8z1q~*D1jw$~f4bI9Ex)lL!#{XZ zWUcNhVIq@l!HVT9XD#^2x7|@jC@IS4C$B)%cZmGSWccnyUZt(~QJxn~wm{;A<(Ka( zr}p(Ims58p1IDbpcBqu_l4)jyI%|ct5ICx>UK5rxtDdTKe~Ma^2<0rPSxqH<(!@%m zie10T??!2>x>)Nic_|`*o|!M$dn88K6DX`d66+Zj) z5Cqf4JK#nbxzs@R;SXy&ZLSFUCu#dCIh8Q$OT;>fXjV3b6)tEm4pVgcc}G>wI#G_m znHeTskN~X3O&JbrnW6zjdrJ@<(8nZ6RB?Xg!>C}?A7y1wQGX4kw8i{_G3Xx1_Tq;q zdMq2_fUvYjN4MVXU}e2Sp&H_xxCss9ZnYhVux!RZa}sqmkU>@3rK(R#v!T5GhibD} zC#|Is++l5lpQQinw3JFh(+Dx_6t!WY{M;IlQPKy^QRyKAa zZ84JJ3gYiIcIOX|+H46(el7HUz-<*r(UszOG>o=NzMGS$Zm-MtU>RD{-q|7^nH`Qs*YTP73cVnoISx6=N0R9Jj? z9$n@@Osk=!YMfTX)ad6gL(=Qi{f@I=GlhEDCcf>^L(?A|LJ4~gy7Y^=+lsrTdlic$ zO-5K=&tl$NIe+A|s5AQs0G)94N+&j7!t?wv)viWvIw$SKI%4V6bfGhEixMJ8OOs(@ z!@p8YolPA<-aI1Ml5Q~;M~$I0T5)`?bo|93Y_#>=xQwbojyAZLMw(@6sH#&xRXVH0 z*|U+2pI&!1T43veJQzEZNz9_noGh4&=+R`dX!#6Y?M+ul42^Y3-IG9HL zIeK|U1l`Nsbgp5rpelS`GKsG?j=Udcn5dndG6JXJbPJfk>rFmmgMWvAJbyjdO_Jn9 zhEPyrr4LmO={!fB#oa11omP zwaPH*(EwT1Sk)COoT^dWL-p!Hgmn7ow5nyULh`Y5Cu?Nw4x6!tg(3R9;>Sza>_!JB zmv^D&O5eoN^XS~kMC;nyX!$^92M$9nd&wqc+dnW&pgV|+3W?}V%4l%8*t;Q&r9#cg zO)x2s(V|MGun_K$(}_fQtVu+W^dlmE{19z0n?tIx6;#F=K^Fc07(1)Dy0;};CqV;? z;2PZBU4py2ySuvtUAViuCV}7<+}+*XHMoSk$lmAlJ?VRU_m4*&;9>r&=Bygy8-6V! z30H__lpkE}=-L^^BOlEGHMoP+Fv=w03pcMctd%sq9mzN?inQU6_3xXpj&kZIahM z$*;RD~iOtXBIxSTbH}FE--hsz$hyy zXoY%-qnq*C@G)U8`h_7y)^4FGAyctXZH{4u7j#PkPK<6;Ox>KTRcE)G$jY#g(;H-N z71YAH8FnnURz+U)d2sSF6Lb)InKLnSDK}ucPSp{=m>&j5$`n{Hw@V{$v69*i33c>Z zOQoe$*+WgSYAqMazp&YUyp^kA2vGlc%V4_ccavQ&-}z2(O$PZXxvuesNV@J}y#KD) zL!-M&4%w0U5tnRfS#45*ck1O3BPtQX8l#YBY)7J@uB5pN!5;G`aTOJr*+~XYQ9_}K z)6>d>0r?QXq$>CpkI9M4wYtZ|@z-bLS@hy>t!3%yX{(1;_x2PBd51@0!1zM#)39zP z-SYiUBNKW@D`Fhwt3x&x68^dggYZQ`y5-~Wgj>u-I}#*=goKxXT-aK2B!v|i0o!Y~ ztWCN48ykE5{X+UB1?Rpch*Rgfx2DOxqT@23g&wNIaTf*T*qdl_5^>vcF^-SM3K(f@ z+jU4vXgM|CLu?^8iR1D~Y>fx32KM!SIt~U|kHpF};WrU9#7+fy?f0SSp!2Qs0|Y%*%!55Ce9TyE{!HdC9EJ^Tqh1bE&bHWjcZ{^5L+(pQ2!Fohi>@&4) z1YY0}^+p4yMP3S6!@Ah>6v+(`$wP+o0M@GAy{CYSFG8Uq`x4pOo&1u{KnI$J>- zIgOf=fH*uQ*eK6YSr{!emyeb{!p9fB*(i?2klI0j6_Pn*u90{_#2>rC&H@h-tzAdP zxDAvxeobE+|60h+9)cEfL4qgbq`TLcq2fKs8qGjn%pwv@;oeJD zRb0hm&&BTy<%bzNI;nNNutes5=_JwRNj9i;cOrfV?2!D)Jx8b2wXOU4V~W-{81>5* z%GB&y$H93yF|OqtOYB)8;SO8JokUCwr^lK6jTXlD+myoTAycp^@%Iyz`p+NdlS_K@ zZ2c{AS_H_xO@)+FI^~ir_6X5EXsGc*=4^pM#^%Y?9*Kq_UyVui?D_~a^I37cpgzn) zY)X1aoZVev4*c-(3nUB|HliRAT=FkXq|Z6g6Iv41SPJh)Ck#!cx&)jWh=u0Q(4Qif ziua2VoB48jFi5=XCAal`7D+p3koA+H*}9SzMK(Fd?@XYACn63#HN$08uKarS^oRI- zE6#Gi33Q0v|7n8%cgsjrI(vmRfMi_|#3T}uRm0QZ@|9nw=>-PDFi}j&i;HB(cHSOQ zHxFOhUA2|b?Jz4&?foh#dPEcC%dl|WeQvmIbv)zRJN@-^ea+>oYz4s(c!#hX3*8wE z!HdR1Z7x=}QU!$K=k5*zielk1-LV3OV$4CTh9~uA6*j32`ap*yl8mndAK@nS)S`Fb z?-6Cwmdl94CmFs5sz4~P#tV{%K()F*yrHK_lK)e!e@ zRztq(E33ht<4z)sa%L$}<^{xR2zX^R2tFJWCh3#a%M{F8Bn_dA&taT3IBu-V?Z^27 zo&9;?4KPkTliqdofji5WX3zIiK7P8XAM+`(KS8VYI!<8_7UE&)g<(CW34N}J_o{d3@0 z0?_(!mIPn7%%Lf)VwjNAQ?+%tqL9^W833wd(j1hzgc%4~1S9mTBy%6;zD%PF4-}4{ za|Z%gG4qncW$S+o571xFspzE~hb??U^H4OOL>33H=@i^p{v5DEZ< zzf6gDx9(D^k~2m0G}Z9ri#pfp4?HKeW~Fc5;z+^TvZyWcQSy*kJnW}K!I z;K2ou8}oZi*nsJHynXR9Ft~u7C)0q(7s!t$W3OoSDOYXSO!yU^dt z$35oJiIENl0{pxW*3YrV zrSh^M51bz<$O9K5sIOA!W;}4I!edD5&S03CZfwC$*NBq3;#kE7>)Dx*TP2qBL+P71 zCB>#sD2x*ezW%uC2k8y*A!mP#BFqPC9SRipO@-i4^|E9wP04SuN;03XX%YBWzdaPW znU`+V&Nz*h2ryru_SLNTCe?t12{epa3kZH%&S2*FVHko>jYorg56fSK!B6CuBMp6Z zm>?%pSU}|)nAJN)CJ%=VAVMEcl!Kw*PAuEPJ^g}wsV#=o&S%Q7*Yv0yz0r3ssYq55>6yqStO*{fVYGX>0B$!o7zeyJ`|03SQOdw`~r0YK+Fw;n#Y-C-PzOce~k2=bp zzh&nAI6d|(w>W|Md>XAVw$g)zG9Eje!Eu{Wy~jPqv8UtneA1WwR;#a1p7`7X#eSQx zU(_C~Lc9{C=ZIX)-VppK>&2p> z990nhr|^TTHh-U^@{+kRoMW_W_3}{p6-^hDwWLXw&W|ip=#z7q>`$hsK(lDBADUH< zNur_H8`n5P*0^_bn43IH*AmwH%QS1qB5OpBwBAmB#-|rg`l^v1tqms68^|u<#ObSy z>jID72kSGM1xT9XOf3z~1|D4|soj7s`S*yAPO_n~|HMm)>%NiGakkp7-uUpeo96HlSZ45sw;7pvgCEuHj?Z zs6C;vu+8+Sksz942%F+ObPLxnsq<3qTH;*oXyzQv&()Xi^E5kj!^7Keh;+vJsIVNn zDLB4N_f&twAcb=%_?>tgBcwsCUNI2gx&0F=fN=-Lw2xUR>@Wpq0rLGl5im5qF4<bUlIwG~V9b5JWb7*WLy!z! z<>M9MG3O7mY>Y6@T^F;!TnkN_#c-!xw3YG^cMVxx^_a~tlyCr(XwJv)Uu$tZRikLK z&CzJ5U{igc6!%5bp@60bH9-33H{K>L1`bj^?y=b;LQfI6#E)u=RS)~w3uouYN#(xh$+;NN` z*IkdeBq>U$h*4?n{o#N&mtY&C+S(U)Ndq(JT0+TtS97X|L6p)3>lW*qojc@;J0;{5 zCe{)NJ1&v&D3YtcJGpPn_V{{(sZ?6vD%g_oYXN%=BHJI!s@CG zv$0^QsI6Gw@>;_m((6%ZtQ@k|-p9#5W_1qcr${X_K?j_&W0h{94%rwfKl9icH>R&# zO3lpBC@N+3)9hgSlP#ZQWNesxhKJHlUuLdwcyXBvwSOjkR*X(Px8_Wp(}t82dK&7S&~&Tm)!_>($c8a@+#@AYouA%=eNL`4NQK+{qCv9=b5eV-52r}f+Tqlg zK@r*w5lie#2h-0G_-yRq+)tlCYf9PDbD2D0w&wHMbB*%|tu5sTL$J?7b7-~@d?eO1 zjyV_f1Q@yERKB4&2j&yZ%|LV*GLRf5E)0A?F-900(tf2PA3yXX^xdR%+~TSxhDglz z5=Ji8CM>Tw>|`_RPtZ=W9dp~5gW|JLMh$+ZTr>%_Omv|v_8Mbm0&fPvC4~HhVf&dq z65`j59CH+kGj#!1gJi8? zjmxc<&h5Zra64Ur8lm6&Nuxn`KK29ILxRax>0!1lO%%WcmxTQDCez1@KgMYS7EEvS zL7j0H)EWQ3H^l$q-~6S;wW=Ce)H5`LNdZR&%zx;3-@<2^`9k`;fDJsgs%7y6I zxEjJ@!xnO$v?-Br>YCUh?~QMTs@w~Gqm_Ih!}mU2fa7M8Nb_zmHj5PF5WCy_&6}B# z0L}^a38=6Qa`%O1%jGlX0nUEBw1!c;-klMPNYk}1R9upGjWaV(l$ASIbC!unRs$+} z;l`q-;kC_<%3xC*H12g&f}D#ixmZ=b^i8w^wSxO|oBey~P~eyKD)rdzL%hx3{cLc$+ygATx#DxCyn5 z$qF?xB^u|Ww+!QLqnm8fjMyLS@u`2IdxahCJ9eMD6sSphaI9u6qxMKJ(>wV*nc5(e zqJFf*l^|8wXH_m(w!R0vxPrMN)kOOQd9tVmF4UQtv0t(w9aYdj zDOwoqLJWV-79KE6Sk7U_vf(DYOmO-fcjM~+0A_lw3a*P^0j@5y;RK%c2?Srsc1oa% ztWLqN5noKl&|uT=wu=G}7)~uHB>Sp(9r=ORka6m1FViDi#CI!=^Vi^U@pPpO2wAypZEqW$Njn==wPO$z!n z#s7y-ljZOJl&LJKh|G`h3&@R@B>e7O7`eZQv>OCo2o@e+H=&Z7QjowGcC~opz{HW$ z*;DWh3-@y< z#mr8G#o8z^aT=sd+pz!-ZAH3RfOUlnWU}F~_o9{ntJqwkJx;&vD6KsgVfyYfjVO{c zCM`+Ste@Ihm5tSj6cTF=byfFIA|2QDj77C$LzuKoR63(8C|{QMcC0)y3Cfg!qy9S~ zI}(k=Pb@lB1C!cz(l!;2YYHAC7>P?N?RwidqIVqWP=5H=PDCNw(@IGMb(%DBJEd~6 zxp6Oi@o5-M-C6Wh3fy(uHx@LT7KditNNn|`>v`J>Mr??#?8sEbbS24so~Ao*w2*_^qpi{S4^NW%W&+NUoNB z6)#2Zs)R|M6L$eWq{p98=FM+$=Hp_PPDuris-U<1KZ*f=@g~`}^Faw3j5-_Rd!H)j z2aK}Y5YtNyCHSSMu-d`aV?JPIBeAUM1#CPa9(m0(GT8HlMsf9*#kNR-NJrJTm_WsY ze5;BUyDkRpi@>qMBjnN+mYc)lgh>6`1tyft}nKd{j#lw zS>R^%_G0U^ZZ<>A#KL9NdMNh6fJ}cJk(vb5Odf@uLM`S@Ey`%XZicKEV#!x!;19jK zFI7$d#OQIO_4x`}<5Qqd<`o_po=__h|0P0CEP9h2qZi!aKELhAY_MX;CaO$+HlgyY z@#IVv1LK4GdqX@#p5ZT3Vg2wmqCT@btREacD*g2BBt4)WjlIPm0R08oeG40l+KMa82Scyx>glx!bW#O137x%K#btvHSkJRts{q6Wj%4>xBS)W7L0cUB#`e zj7{~e{@OKzsKLT+hQ@ZU_~5^kj*+n=GM#)VL1TQC8Yn127rJba9rHo3=vb_f$Vhpx zk&n4DgDI173MhC(sG+cU?ZJ$znppYJq1FzknFAbandZEo)>hwU==XJE&#Dhnf22+) zt&_5X9o9C}6?OJ=+S%7>ZvfyX@U<3tJM-+76E zL2EBf&{bIu_1k!4ITX51Ddt8vt+N564(%Oj<;rSscj9Y!4w`SQQ7qPY_8Lu8uOBBP zp`IC_6F&Eza_XNCLxsd+ihg)VpptfmRw|atcwcQ3z`tKYHB!Hn=^l$bO4r%VEg5AT zWURb?{yAGf?<~2JL1}b784Rj_JbJUwcU`>zwuZBivc#l}C`8pl;ky`3S&lFNEVXmE zA@U4K8K9rUmy^TLww6`EB2G`9^ds^t(q>A#Htx`cUDGMEibq@`k%|xG7hi);4QVy$ zf&++QixW3+LnM_PIR@ZKwDlT269zhYTsW-v}8>W4*DQiaEc8JEKM>G_ip=4ybR`J6l+|xgRy*QH>dQ_ zj}gl=WjLN*N3-121DZcKjXNycEYPo=`=kzCM%jLxbg~Nip|tRoK}fb?N0HN#kr--u z=ecQXDb81mz#Q3^xh3Unx zoL>giY5J${$URDmkzPX6_zF!LpRU=(J>1emSY+TB*gFD(=-c>@-d`$1x!FB2X*Kl3)J-BxANF9IKMO|RCKGhe zmE1z`aBz+4OoKsi^EVK7;nwncCstn&;}-hdgw@N-z+8U4}+l;60IaSvRyntopf|Z zcid(LeXF#)&7-5xIpV}A*UVN9ph_M?G^<SS|}>G zgy6%!i&<_BGp3uEWt)SECFrCsvG!A2r%cGrdPGcQLi95Q*i;Gw$fgD?f0)9Lb*9At z(z>g@GcUN)YKn4SR{ePE`!_!_|Dt=R^|Uq7(_%LDZ+@gISM4!~A8Aw&U;mpQ$%+Ny zM;?Lrk^X+cVjuT~@w&ulx*ei+V#SODUTy9lK{j^^xLmdB-|}b)cO_beS9!GZTgq2? z^eURpSERq?(YH0kAbGR^NFHtfTOQr_hddf&bN@$qH0dAm=$Zdp9v$~v9?kSy9&G`# zxd+9Wy)UHI`?xm`X|tIGvbm$%DE^l`y6pcUkJh*z@CAKAukz@!|0a*-`XA)c%^-O+ z`+t*1FZWK)3H+~0Hsil2l7G|4itB4glKhjxP|p1qKcY}lD)i|WB~(gnyn2wi;QCn8 zNHe#=FRC*x7`FoO)uIM%9AjZptG?$I9mT<}pgb zB(i+9oYxC!VYXImHBcL_U%hnHsccyJQd4NT*rHLrz)j1Wn=Nq@3UvxfXtuUVr4kGV_%7ls=Ha|OTabvkFA+|0R9A8VF{ThY{wtJ4oY1g(RRKzNLa$Q1Oen5Aa0qU^S*pknDJ1dEVMWO9TOpp2pGoW6m?<0 z+Ix_=7NYLc1heM?bNG^)QZS(tl%!Ir+=n5hW&w7AO67-DIJL74pLNAfJn$@blY@$G z%PgA%E7}t67=KuD7D$C9HHGm4 zXW!Ew7RgT-6v9&GaH% zrH@d-rf~7U$vU_2su0e)2($dDlIa=sDVsCD?BEVyqAsz4`U-=@*`q9<>t7gET^+Sg zq=J}KiK24e37Sdd7Cerre<;G67DZbz6vjkwP*U;hP#@ zQbA^=xdHbebyC!N()^N1b@Vg!cR5$gPgOesKfTpdE#8f>YYm?!`l@^(nBIj-+clzG zp+j{SCpgNBCZ3_N=DCU6tFy#S{se!fn3P^4P-=-HYs(OkVQbfu2nR{DTzU%yKra2} zRERgKE8N-)-nCN8wZeI1aM{bad9^p0{q|v!oNuc@G4jdYBR%za*orYN;u-uAO$2U= zNk%>dyNj4Bpd_jbE&}K*rG_C$T+=&nn1`Gq6md*!qX4(ELDXY5JV^Vi{mnCB8^Uwc zyn8Zu3*vp`M8CK!+0obdAkBtvwgR&#r~77AMGS(8rUOw(tfFK0-#4|FVZGB%p2rRK zh>tr3isrxn`onVt-*U78d@YmzBv<$spG?sjq;Nyr8!Y6 z)yWJIiOSM7CyA5uE|*97$uX=bqFkHcIl4GNQ2SP4lUDTy6NB<6Cn3qkNqFfWIk*Yj z@^SiNa~!q$?xhHe6(XSm+g#t5{9CZp--cjR?l17$|?`6*HKSjz^hW6Fg{i@9kL^gtQ^0%bV76#^!?OjrXY>#U`a^Ww9A}pvS-N z+rEcv;d5xBXZ#6wOtruO7*ERq#3UA7)83OqVFE-j#Fsk|2pGba^HW4L@@=*HVERIE zh<@$BOzH(*-4PdqQG`FrbBF3z8MyJ)@n<1w;B8J&jA41?WgbN6Kkdq<9-05`gj!MnonoG9E80L|TA0BVSP5t`d}4B9zB0mu%6D|RAl#!7X8g=|$&*pS zPxlZ$5y~1W_v=u1@<}VOL80g_mZMoyD^p7=On-q#Fs&Oz|wZRaoD9`X>b;Vd;DfltHGmV1)YL=CiEUK1HwH5i=}t) z226sf^I67{GJ#h(v#KJB^_hI_WliOmR5+O6VcS+CKQ+~t-05Vi6-;jh=Vqh!9Cq)$ z%E=M9k~3rK!pry$r5-M8HiOEGgjy$a&mbeIUMkzqQ_=S&G#6}dX%lcP7;Ot*o&(So zh72l>_!g}X3(5;sdN|q4T28~zQkn=r!93@^ZFwG5li$7W%bUe1f5=3hXwM%;{KkzO zz2ZjJHaa4m`YO&rxDlSeaF2d};T}u=jeGnN^p2JKFWe($9Yz>{1ZZsqEw$zZ?ga7c z2rcF4LR%&?WA5asvDp*rorq)P!)IZk^2$L5L$h)wL$lq~ouM!PCXZ?sL?INTe`l1O zVlv!SJErO`CbtNomyxGuWsu?qi{GO6Uy{d#b)^TkhD0JaU#<4=bce|5#W|egeG6T1`9Ddjgi zZH>2zDZ7)QBYH`U^yyvRl(%o~Sc|0^2}>@^#0;S`jM2FgYoJ-*G}d(B1*145rV zPjM6yL^uJa7>4PElBJgtr~u)mPXxTXnBhx1Qv4o8+M~M;Q0{$HrhuObs!UoYF3T*% z#4e3}Uly)D396+6GTo=pmTE2J-8_GoAx>tx0&Gqk$CO;a07GF1O#2*5Xsc;G-nuah zYh^+X<_tp!9J?x!Na|5o&dSPRcotL0g30WNBGO@OMPH9u8POC}nd%zkbAhlRnLLkK zT#F(%3J{PW?6p&9VXYvJcsr<+Qw;Gwpw7ntS~V@|q>p}x-t5AnvO9i`Q)*PPWqQ}d zBfh}FW4c)k9HMv^bF;P-h+c7&p%`Sgc~qy*@kx6!DF(H;+s;t)&Y|m~FpAs&5ULtQ z>Om`r2X`3_FBDt zxtLOZm|o=FF1^UXG=Es1|DC-Qcu88jk*`n*shGHEd&CU5^1vnsWDMXT_hYV2@%bCY zKcS}rJ=&q3ph+j!|FBJ9_?KT0G^qF}io#-#=#?o1a21VdXA}Rbr!2Wy#&|0HmfZ;=v zMgUO-^Va&PZ2R*8Qwj#-rI1E$9LQ>Vd`*~iX-ec0ID_B*C_%37u^`&)awVZbX~23o z$mXfDPTuGE{*7PCiK7=<)1djlH)P4W+TdQSeJAiUrrHvS&ieQAE_}+6w1>{RkVd9U z07DeR=LVuh`CE1y2d}Xh?r)fORuevJu-=lNT$}<#n_C*bIZl;YHLApuH{3)i!BvdL z%qRL$diqJ|zf-rEB||_}71w;A;O>n_87#50Dn3Vd(1L5%M$2! zHNw}NI>@x7KTdxn@Lu{Bv+HV|KYt5DPp`r4mphZzC*&$rI2_CeZltI$ADVu0CD4t7 z>yFE@u2ME4;^IkW>z8qXmv5o+mYRR40-I*WsZ&xOt2Ga~One6?rc!-!Q!qv|@hMYd z^@As3qnB~BolGs}pi7jwPkZKgvHR+I3!4Y~L9}{X0P~92L+Az>kB(kSfi~!$csFn# zw9u&a#53RAZyKf&;MES0inlTl^W1>l&Z8FB>MqQ(L30e*GW7}_LMg5OCFogRP4|K3 zB}ffQ&=R(d@Y7t!xkUzXK3JB_v?_o`7IW(2gUE+w2dIHA-Izw(r>IJFWORpK&xn_M z+Cj;BQW4KJl$lu!TZ2IQDExu`&R+d0W;At3Dl4Shd2k78$+ljl?p&!9sW>SCn;apI z@^wn_#Y?;j#`2A(UFk`JYA2ne29jf&sZ7r#ShJxu!hNe~?Gw&znBBgF4q|&_m$Axi0^2@(9D< z?d!i%=E6ZKb7>`|pp?11o4le_36=Tqz{@*KUtAb(hR(hIpkS6$Z#% zrmRL)-taR-FNL6JHUUYO3rl@7v>lx;I^`liu(4S1+s6tuC)q42OVp4V4U8&=KIT#C zBAP=J1hRYueg~2ffYRek3Wy^N#G_=T>B_T^^ga0jY;@Y@l=5Z46^nG5Wm%P+^RytjnxRiV#{FKeM_i#h1 za=JlzF_d?WNPiQ*O9ii(bb%?=(;vh2w3OT6ccA0n4C-5We{Sgh<%<;3chdiBDd!wJ zWHZHw0(?Q{g0q)e)bi*uUlf3i(hT7fqvjJUkt^+_gvAd(B`rtQB5^T3hQ{^l)7Muv z=m0yH3t=*h7a_44H3o&vz3}|He(aNdGd9j7*W-d(|DC9-9N=o)K-QJjq2K2Jh{}zR zkxYmdpHB{l{4NtAsmLJXA^JVlX;4boFSWq@){jB?d!J2Zp})<`PO@Y7-;5+nu1P*Q zw)%*aj7Z;ghT{X`k^{XTw@FV;KF@wcxXRzpIHPIi-_I_J&&B(U(8Z9=ywZ=n+cDexV9@Wjs`bJsYvVhXOynxbOQ8AIZD8smf029xxy@K_L{>K`H%my893EJF?gQQKTmp>rH=L+sYr;BWL^DDg!Jo^dk-{D?=Dd6AN zVx`ycFq!T??xv@%;I+wsbbj4TIEdZN`x(=Sy#+uDxGBK5XqvvuY{p?Shu2_atJCAc z%T`Mc&jTwLHnNqL1aq994q=a_BN8HLJ!r1(9GT1d&{XlcM875Qw9u{~3H+tgPQj}L zUSqJFtIKEL5+ma47Brio;~28h4QpNq9d!XFVeoRf0^yKCE=Y)T0VZu>u#Y0z`SoEl zaVNR%5YsU<9P?~&(8Fk4*M46RyQz4kI)rn_-|{I)D#JqnJvoJ`B|y?(XS zIGM|Yf+#bu5WjKcylO%01!l!=7YhxJn{Sbi7ZSt3lpx8G9A1bLJtDfPZ_O@VFUlNx zhe@&vd2uo$0cr-OYwTyVB2LcioiZpXd85ZN_FzbMB^#+enricf)-qwlF+SOr=`|^t zMG340zxVY$VE413P+bEEJzWfOHf`gdl=xNolCtd+2MhZ&`Y*_vpsxt!leSbn_{{Gz zM}JG=1wDQ9wy-!4Tgf@!Kl3z=OQ*tsB=OFsQZkt_uv5Ad4!|!|s^q}B@p%rbEtI8p z=ViHNr^62Eu+u`v6mD;M?Gy2q8}x11FC`lj@OZgFyadaa9CB_d-qd>g@)3coA-SAg z^qB$}xsKoj-k~}WNa?`$s_e814O|CAfW z+0k0O5*}7T#g_2@S8RWWC$s)x_Yy>2}p zb#mxZmzpX3q~2<_3Cc`rGxA?mhpsCD#fvX<+>SQhW^wV3wt7RIqp(1!FFV`8-{O4T z@{0^+#bwyef?^z}!$2GQcKZ}KCqV02Tc_f8Ji{}_Rpx~8Rb(0!!1RqTEWpT)dYXXu z!~0ZA;ZN{fS`a3m-)c1Bbpy)D3gseW`YLlyB&ZZx((t@C1F+_5TZA4sLkjeD)>g}z zjAY$X#_e|**age)au7*==Pt@Ko@(7iW59Hg&qlVM^D@f@Wt;hDC%$B^qt5;Kbh^0b zll?n)u?3X7xE3kIL@k|@Ct&!RyU6gGyBMASckbe0cy5QmN8`o%e4%>-I{MC$s9xLD z*WASieUvFs?&7JaHt+xp@n~+kEgakGBPzPG)%?8E1%@V|Q2g)YWSpOwl9HoCX%$(x z)}Z8M+5HtZ=9YU0V=XKJ@z_q~2z~vOeISySs73vfGGP=?X@0Vk<{)zSHz${@_Pj@{ zED8^Cs2KVhE@CdxFPbalH0a+9Zm8z>m?>pnu!RYJB7ay=HppSu>bg$o>wz1_kJo^x z8f~dg0cCaA7-X?a0qbk9bbWrp7VQr`<+_>)?*gV$b%`i0lPNCA4LU%9i!{rupuok= z4~5!WEzyb>ue$uq9*Yz3C*_5^?u4v>>uv67z zMl^k<#n??RM>Hp4CmCdjX=~HsZv+;wg z0Y#<&+DGN#vv0E2#bUuh3K!3BdQB~eUgN>EJM!HN0+j^oF!;qy`ny8)yI(#(#3X(Q z2jOUO8st=t3ngA9fij5Lot(GOdY|jBm_v5?IJ593nWKUZ@Am*qWQ0l;B|fmECQ?=! zZhu%edzR)|RR%4QU<^ecm&7Jyj_CM<7vC5Vo+QXcY9?Cflh|%6g7$sAI_eL_ z1F&C1)Z?h=Yw$7~Q~El&9mIMk--!zz5tb_wgR#YseZbjZx$mwUIV2-)#lOew&4yNQ z>X@Y;nk^P;4Ata>jk7Tn_l$Rf8tT@-U%+32c@AgTqCD+QDL11LnQTB4%_W(3dTjh- z^t^4(v`h**V*kvl`BN8U)m-ycuDuKzm9C@(zeh7-79&nWV89=qByz*L3GiCTPDcrU zQ6oq8f+QKB6`#qHSa?#Vk;~{k=;wOw43GCMch?j^p&_#u=c}}Zvk%O$Mh+YRz{gMx}jtw2G=AJnCK zD%?Hb#v!N`h{2L$i9m;pW}#cn{TUW%jo})#S3MB$><5oRkb{_v+{q1g8rkW`0r-<-=Uy| zuTW5QJT%0T5@{HcQlv=PE|*2xg;Ulg3O`X~-6yt(yl$bbYS1w=?U+nLa%M}7_B_k* z+{B9sQWK>gc7)h_1g+O*G5VrN*x-V|4kZ31F=_@lf{y0&ZY_jjSG^d>k@|dL! zwLU%9c!HN9ZR}cCbOCn!&=37)Vy((fcV8}^WVSln*w}?5cp@9|Nu7m!WB-2rCuW(F ztup5?cw_pj#GvUf04g%{N&r`j4|$JAwN{aDRxc*`ef!NAFOs`X$A9(#cv z6?+_*HqW)F7`>bZtRDhbmC|au32e9sb;P?0fY>F__X#N4REgLV_+7&q7pB}wt1 zgcw0vO;>TiC}-7Np%)r@4Ey=pCupQ6FoW4|5(@Zr$C=(tk0!e#K9AQsRK8?()JC96 z!+NSV->!{(E46Ie&-1geB8!PSQPcLG!Sx;Oql~C<>+zh{98bv=MudkOF~+V&E6U@* zxtx%;4+cWjI1CT&mD~2wGp1RIA0E?uc>-Y>6n7MawW_B6v@7=u7#^n<$B2-}Abg?8 zs@qdZZw${azd#>|vJ&am9ehn68x}&C8zkY>=VLjb8qN5lJNP|WgGI3 z?J$t!()tvxBtB|WVw$sw|8Yc(x$6=nUSghwdI?L@BdH_=HF3~BPcV{2hMQqh%_>jo zQ6@zw%j`j;;r`;zng=e=T#OFdFik|Wgx{^3zM0OTR`hwPzk^x6Qog4sl;$J+_Yt|Y zBBqi1HXC5=`?b=VZSIQu&4Y`i1Ip{5MQ`CLO=?xrLV^So3axI8SGoD3#_Wbqxxvo@3MR(dJ57 zzc|7KcDbmlzI5~1Hv|q*L`PDk2$I&q7-f3wRr0w5w0a2b_W>2=Gp$dvWW(dh_ky+) zVZ-plCO%Px{n^F+`5~4~;8o>Yz$jA7_iPj=j0=$W5?8w7n5vaY22CTH6mf``(ygL{Ugx5wyL5ZG^NeDu?uD&M#W@)OFah*rEaD&@ygwBu0%p zOUozc?eY&8(?)CWF^7xxLUT?p=}`*G>z{H=7e=A}4`X*3RoAwy2|K}pySux4aCbs* zcXxLSwt~C66WlEjY~k+i65QRtm3{U(w`$jS>((FIY7J>Xn`@3S`{@071!`Iv5J~@L zHIMY7NL;(uLbK~BgIIPy63zY>tBJERf$by;Vl_KJtR`c~|H^9G@@%{1&{~3U^2$nB z_6<1?whBGrzgbP?zgW#2dJwCrY#1f04Uh2`t9b`vH9vt^&FRAbmDP-k^?lDYJktoi z;+V4+8OV&bMT@B^0J7Fb@Dr$_#assGIjDA8uCtB7<0&^OMhrdh-fLD%uE`Dr6(YBW zMOpn}HJ2=dNp_|=vTf=#cwRER>dN86?2?{5S~?@pPaB?4U49@CQ%-*Yf#8}|=<`=v z0=F0^#wVteg7SI>P`{-aMV0+r>R4jJ#CKyOizwnH=1p}9q;RXq6!2+#Xh56B)8T(@ z8kcjRq1FuGQq7le6zw*PhXV_>H5Av$*ZOH|F{S1Qnt3ea_>BepSd4=*C}_F&o%IXv z#)sC2&*5f}62K}`GAAm_M2 z#5lA{>W_0w*9cu*fKjgqKdC?6h5sAjPkM04CA*YK5F8*5ZhcM&mqs9|YKQTxG^%YP zH?2-ixv=8WW7|mIeSKJ$=yvlH> z3%n5i51(-Ihfe_IRhj*#kr$L#U{;x*f-apQ<9bq${9feT>~U$_X~GB`TH!ldH8_>}a)`BaGAu*#1NlX6~TtEXU{)W`5-HXYqo zkzfof-tV@VMtCjJcp&DOzeRfLmkVM-2~$})f9b+oppPL22j9~cS;GZYu(rS_*om?Q zZ~!4Ft||z-j17KxI%dk+KyQ}+QN;gy_5Zi0gY>tjQ}M^sshedM{mavl8Yn&lI(v5Uj4;;o z;lEZN$$lQ2y=fIJJM5T4MlM;-@)SI4yI*}CZ*3Fw_XUaID90o>WK%Jguwn+;5DE^w;Zq70lVk(2B5P#j3q=v$Kzs5wtLhK))A+^Th}H5h-O^NUpkt|V=2R~F+PR8TxP68V zt|8R16&^14KgUvOQ&i=Dj-~K@NaT}_DrE-V^WXs;b+KMahPQt}os#*OA`ScGk=@Ce zREsue!*?MNr~~B^DClm4eS{+$O~L5dW^Az|h2?6ws3r;|T@zj|kz z?EV3DB>w~I*#4iO&hS@0$>q>=We}(X_zTnl#c%%sb&@Rxxgc;Quu%|X4e56@u`Mf@ zEz%x(cVDJa3|$v`9Bx+=3t(w`V0J#M;YjezF7J9JE$36WqT}iX$901?zD}oy@hJqX ztIyU{%#p4SHux~VO35uPMQ7bSPCy4zet$N;@eqG+d{5rlQRAvxd;V;EFA*ix1< z8{c~sJ$s!#v7dO~1QzN}XIoBQ#XC9|(fQM<*^q9(e~rN+q@b5#TCVJpWI|5;5UKL_)tBsDE7=Ivug-gy?+Blz3IBN&kG+B`>-hi_rr(u z&mTVE{If{JSRzt`R-;Vi`KJyo8{aKPCtkyQ0 zLB!YPm-xpG=ZRMHaZui&^Zq*D{hYDn$K2p7sgE%Fs+TDb>5=9t z4;_*IahKJwe#AT9+waLRi26HAnK5HV9%=*q_pjc+UZ6n=sv43K?iUE`#P~a%=3LKX zvA)T6GpP=C`#V3;y>Sa8L9vQrm~A_Tu!#K5XOY`xXddO$5&2>YE4#&@YF0{1Z-DFB z3z)KAx=`@}L~Ziv_N^>QrQyKh74_hl;Newnf~6IeejT5tLAHq;?Wfg97hxLQjB%u` zsQJ8O$|>9}-p-~RqSfY{KT1i{q7fpIF&@8U0-5q-(q=~4gj2P5Qgn?6+xLyR3P6c}LMlNkksYWS8FC&|w z{xhsvY0X%1gbAm5_a1Gg`OLKmr+zoaYM2oQV-2sYNs~}_QoA#We`1+K*qA1HeIKk^ zxkHFNQ{0jgqi=3&Rzf!ud!mE)k>iG%ac&WSq9cHopSLX!$H>@J!s20~u)fY-jLH-I@ZA zRXpa4xrR>gA>}dj8Z4_vwJ>z5dqz0dDC^xIxg5xRXxpva-3BX^yX#dvfW=%Rqt%-d z80&S146G|8R<*-C6 zE>dQ%uuY{GrSkR(OKy-Wt%|%V4?SnAH1*f$mx93%DH=rLH|cP$mSp1!^KQVOQx`Ps*aKJ{-S0nbc;pX9vK}X zW*aIs+I`ZTzD1)|O3b<2Dj6MGW*bV(KepjBIwZ^%Xcr5NI^@r{%AZ$g+oF93J{UA( ze>=fUS{px;GWA}{Vr`Q(b0@Qy3;0&uoIkR%uzxJ-FgM3VTsQ~e&1>HH=aax3SlO6%{l zDUDfauA^MHlABagTN*soCOW38)+Vy94AoTCL$j~^5q{sNty;cgZ0sw~!6KWYK5)ZP zsY!HRzM`UQ?b%vPGG7+fScZ!iqS}7G>%*?X?s)c-BO|%GQdA0qL3_Uj!b({YEM+^0 znKYnoPcnO&%_8#ki$L@CDSNY9#6;!VwliG|o%MF5MBCo_@6)4u65_Fll7W@u)8bvc zP7=*^4hKexobr_wQNX;-rQP-T5z=Fu*_@a655c0L4V(+Wlee_)rNNm|#>o{Fk9pEL z{39mC*l`hGeMy|Abvd=)p>z~6N{lIM+GkFx1!;-x!Kj;0KCZ88l^yFG>+_69ah2V- z1kkOYVI(+0yj}yw#aj`NBGU~rn#-3~j`y{KX=yf3?;O{WIZSqp{JV&>!oMc&GR)1D zgxTA%St8orCHQMZG1Ke|!o-Y~y0Ll}+JCRE6x_AG{LaZ0+M1QXrqMFL(v%iBi+(lT zU?auc)ZrRY&0z9Nq;h%c_c6?4P=G+%nyBIF;jQXY86D^Ia?`ZQ9yfea`>(8FpW0I@HANc=n=? z0j*m<6}46QWt=5}{2KY$ONI;&DTMVlHwn#v2iPY)$j8UkeKh zT>B>V)M`+rNa}siN2*{Qr(Tt^O?dU`UUk<~lWGgIU3kDp9+hyW_D=0=@4xI6b!~7)Z7atnH1P3Y z$SX_^N0FqPk&QY`y9}R6%+$10%AS0v!Y2&Ifnik*csuKh=FMG3KRLI(W@w%_D;rB4 z`h=#se+)PG^x!sMvh~GJO+dkT!(1?TE?X=M(6;!BduE1Y;lX%kwfLO`;n@+-obN~N z_eCK4g^6N4EOE6BJCy!3bKt#&$2US7L2of*L(Hkgk9R)+8$H(SFO+(vwIAKS8h>9D z)`|==7GmQc%!e&s*1*ALBD_g6n6OLMO{}Xk$2uJx2GKNb<{T=>9QKlS$(>BrZ8>Uo zY8Lfeu^=g=G4Z&>4A=dzrk~JQ@$6y;S{b!WUp=LIW<_;=WohFSE*N!PkfV*Me8JtW zGiP2!&CMIrRMSmgSk2l$RGGisqtV)fg;%6Jav0CzgxXugcwjLB_sm#uw~~lNj$1dR zkRU}*B%I5n*?!at?#{i@95Rr^>d_*3rCy{v6>_$CteBmyO~F{9mJHWB{Z+$w;sRQ* zSD&pvuQ<+D6ZaX$TMa0==#1-~ox$rQb-AzzcOstls$wi?%w&0DdUq|HpoRwTVs(Ob zf)Vv=*spX;;X*U&SHE9hNs5kN*2HClu(B3GZvKuvQ$3whAW(IeSzknx#CmR8=l7y> zn*GEDs~#^G2mx+jF|H^k2rDuNNMp>)nqk5b?Y0`jf_40v|or^~$!ubPkwX zq>Sx1Z*jd72sMtVIn|9jf1^g=Q~b7US&aLv52T15H9|?278~ zA*+_tWaG5=MsAE{!G|2(_vRkdp)$5iIpSJH@SlTLW4EXuU7MoEyW1K z<+oCWSM2H&uAi>ix#daBiNcMdG9f(RN#xghwwAtybImXN;P0=R=16Gm)dh`??;*?n zbC;fJzPYtXOOmY58U4}qF^7wv4|+@|=f^v_4re@LN$xIvFBUWaJ0-4ix-TcZ2XLcr zxM99Bhyue|PwRO^FD_m-$FNW!w#wNlVGVV3;`1%^PwJ(yF#~_BOWrVYb*2|>AC)hj z@d|D!u3f3w3A@M%qqOednP!_F{RQXyFGOSFKX&Dis=Vi_4vzhL1a`a z0UoPK7^yt)s*UU^7eB7V8{Ay7#g$VEe=JHEr8;MwI&e>|I=qJ8%KI#gRUU0W18vnf zqoP(gb}@mb1e*i>B^IGVepjNYKnw|qaVSZ7+A$9X-=AK*kG6V}DV^W-0o>AUZ7*wn ztYbT;hnJ8R!>|c@#60?ZnV0$|GOkz9fWX>D%7oLGge>?HjVh5rzEcLUGzBwvMch_r zwaV=)*1jdR*f`~1!$XJYT4`BS^%4C=7vPIS*-nlLyL-(Y$#yc!aVD#!?sPOss`A#H zNvc=JPR*_ZvTy7hYN1tebFN8qQK}f$oLMIs%!T&KvbX6;FB^!e$5IhpiQ!Mfv@E@$ zNYWmB`I0E6A0!I-06T)egU8#~u)aZ-P2Qv1#zeX0g z#**dgJh>5eQ##-AX8iUJ2Yw^&seKKzxteh!udha~d)I#>mgD=Svu6@pgZ%-MX=Kyf z_(P9UKm-IMW{cRSQh+(M4rPs;L2DOmKoVFv1S3|9$fiud9GE%`pRz$zmsWr)*fAt0 zv<^d!qCr9z2iO9v4dx=LLza;8rdI$SL^g#(L03Jb9jZsPP<5ADfGyYx?bmnzf zIvDheBmy`hs58InRg6SWNFk;#L++~WLWD#RDRr(KS81KZfe75Pvq(^wkSV{!Om?f! z17@r50ouBo!<~md)frYMJPY3MiTq9Qj#6p&K;>h$N<4DO{arpi;!047vO9($i7<`RVk1YM?7|6# z?0%oBsq6={TV7o~z}KbTXlgR~TEp7uJ|Wms=ID4tKxd7$CHee}Dj}&sW5(rUm113I z+#z5+6NjA?H1rZdSGxGpE-SY6CH!N~l5?he%3BZQSN2w_Jgm`q4H+EXErqN>yOE=@{gj1Xh0u$k6 zi(Y~&v;giq{K+XJ7kV#@p7}tV7eb1F(=af`iIiACg0UM=qXqhY%eQF_n*t_#F=mfw z@7m3UDN)=o-%>7~fo`as-j2dt6RNGUxB&~@_)5S5PdQyq;ccqrlXA{vJz{smG8k&Y zC*rogM)nz8N3M!ZUl}t+JwLOl4{}7`a2TF*b5-gBlw=0PwnUVR3n!LLhm$Sz{3_u~ zS7h`f0aGYK1-5e92wK{_%gqwzBr&-sYxbVM6#guQmY80(hs3fKZ$F=K7ws$j=y2Khu7YneovZ?K=&#!_ zxOm3HC9I~{T-zJDBP~Q+!?6k1kS%D%=$9z)5h^}0l9tg^Ifo+>P4BKRSg2vfBi+Q_&us zQgDS6=D(3}0Od+UF!y+VPGI1pmC(a?W_4R#sNDj9r) z2U3+5Av)cEcXW@LZc*u(hHzJSI%wk4XZ`>uyBp}@{?q0;mDEQL=cGW_ah!_CJuU_- zJ_buPuK^EJCPnLwLrYl`_r99_Oy6>~bGpU9e*0}n&+*-++JqWiuL%C?Nb(`^hfJCg z>HBv<$UAO$nYn zIc%0lk1w`Yk33h8UV{mCg~1&#$=|(GXFFUvrj+PjsY-t$|==Zhul--2C^s9gt;ceEf*!bktNZ#>Ux;6 zNLj@!+}2UCDy*g51=6pHvlN`_{``J)xK^X~RE1H(w-V`*skQBBRr_MT>mIb%sf%T5 zpfAGXn>X+}t?CzuN+bfwo`_`TS>pGIukdhX_yFcl5LF1Cimgd5LF>asilo4$S37|A z2CrW?P{OVShnx7DIx#~cM0R732Q2AR9xd2NK}`?4nhfgc@1$Zn_&M|m<_aqwXmg2N zO4!C?#eFxV-}7kABw*zQlNPx0gfZy*guxsr8h9aQGZt}sy>G54%mp>;?3~mxM(>&^ zi`XIc^2P~gQ~aVE&R-zf6)iZTICbau@?P$sAMz&+akGBr(}_N2kZs2FV9qR-l^oEB zKI)2mUN87k%VM;pT$+KdglPCmE&NxLthe*I9_d8VpNv3w36(GG zC%2Qq^V)lJ&83eMP@eD5Xz3$MR2n1abIpO$!U^jwx3lsy507QswfFH0L{&@=La}3k zfDDnT`H(zH%V*f;)c(pR3tyMf@s(}Db;1DN{F(*!v)P%OhSV%~&6g2+-qzVQlol%3 z`}BVITNi#`i_8wJ0AIxx(C=FvGc^Fe_f8CUFS+$`#^Wndc?`j@KQ$PVq+zEeU%vUl z4yrG#Br$3Tc=6>AfiFbo(UPGt_{4AB(D>m!hBF>=MAZuT!!Z(@m(=(yQ3X#BzVYVB zo$FUIviv9k#)=-(?0!GYk2Jq135ItmD|#%m`&B5+Qt?XjqXp!MZE4PUe90+PaE$j7 z0Amu{5}$b}%`Q@KjepXF&O5e(;cGjuMtLBHB}(s?^oq{T;g0c-00D_UHn{!^&bNWs-_^_f z#WoYDeXMYE4n3c2v?m0~Eb!> ziTTF!3PEFdK=~Euz$k2m{RGp%d_s3LXZI+7y?iL$1oBh zTV{b%hAlzF4Dk2Jh2IT{x>K1~6_`2Uk&7J2izZ~}J6A#ll%9r|}LOI9v*b@(&S)|8WU0wKN#-l|_?9`4^qVNCeP`U*0e)YScn>9XP4gF0(amU{ z@v%-SxT(umn{Qthq1z1TR;32UgK9FyITpIi9q6J1hzwx(!WjxpdHEEGGOBmTEGJZ=rJNRIp^y_4ooTU>UBLhRw9~fsV@BPI0?;icG#-meP$Yw1)Xer{974K>yG_6 zhi_RkcAp~mWYa3dXbtHK=PJaPhhL@{G2^R@+X{V8WDcd>5^gA0sC;{S&d~4Td1GG3 zel44}c`kySHz7-r3#%zPulw+O~%{{N>`nlWiecFX{KBOL#qF&d`FO_$u z3nLHCaQ*?+$ag(pqXRhH7*tyx_(25@5XIAm6OdG!a1ZDL03;fj^X|3_%=V9lsms@a=_EvZ6KWA$9f;uoqw`_En{%4r8^g`zI;=U+%c9s=j-etq z<_Z<|-MI$&x^e}Lz#KZ0)*-&@5^Mk(pTZ%yOC_LAC?(3IHA)})MP+jUOc4E{qw5}` zgYJSMz#iI1b#nwvAN`@Cs{~Sj<|4EU2!;f`%5?ED;2x%3eN!)>8M<9~lQ2LXu3co4 z0*ndSBfHBM%o`F4!VYDTq(*gf32Xrl^mIa9qqYefu*X?9v6ONGH)yaBD>_Wd6Qw(s?51wUx4@CoL>hkx1s-5;xFtBxj#?w7n)W2gxs{Da1zS_CBDMV?98%#_RRZi3NM}!D*P_+ zEQ{CujBkSNe*E{jzyCX0*Jr*3=5Lx9>KqN5=>TdY+N$TvsPGntG0TGi(7561mCht~c ze7H4*TrBAFlTDEL)#$JoYE?o#D_ZTM)2bUQ3S0BoJlSXzJ$msedS^F&lK_fVd~Rr7 z@VLkL_I1&%>KT3A9G~aUou;8H+0mh}%V=fC`qq31elyM0wDEeQxS0!wdWPsMoQ&Ab z6@y1qhD0NX24c6Ohc{nll*B-wc~gs~)1ymrhBfL_;E3xerW%pMOyxK8FMz9C_OxO? z#Fb12&EkyuN?2(c;mH|vw0cROvDphlOHhZknrr#|YjnXLZd`v$VPV&o* zktM0~tDx(fM9kW~#e!#*H#wN!PgJ}#w z+QqtcI%gM&-8wtmG#icD+O>z^1lEJ)8ViQqn89*W1P{8 z@i7DOAbX5Zh_Pi$6C&^KBa-)N$P>jAtfXE%|lds*6f0 z?_C7t9_sE3U&5epp3c}S%8=+53K>{S8>E{r7d_c*|6!X-xqF)8qZKNyZUIwX%=+?I z4+G}hcEC-%1OxNIk_=1i`Id7Xf|jHrcet@0Cc=QD(ZD@L;Ma>xEw50u@QO}h3M6LC zLT(znTJ(J`5oDGowr*cS~oI8f2lPbwlqF6g_fE zy%W=x-UJBBi-w3ihXQeYRJVvL;}{9puZ>!Ux29`E)Fg2}jne`<$`0}DD$^^2#GG^x zIz`N@t0SwN{U&TMitjK#CHTH^9h}V^2+Q0@9)IM>{r+i$8+}qg@1CD$J)ddmb;ugu zZ@*H7&mSj%Rp6KI%x=X8%6;hs*PCI?L&F3@Bobox9EIGVdzWjP6^HQ@eXD#kL0WOz2QbWcl+W1W_CA8&Rx zEu#S<+Re=lZLvcfJ;F%h7HS9JtW6Q&fxoB=QV{ZoPQ_{|yA>f8TYp{|&!-vrtCOXo?&*dUEGK^N6-*iPN5k+FxiA#?JP1IukiT zEoRcrY3o;NtNDw+1*%Py+ThbcR-`k?iY!kGym0sErYvOJ2U(GO9YrTRKZfcsn6Whn zP~O7Ff`nq17O2IISb)f%ciobJqzXEv z%q)_R@yExduJOZ%)$5R?gKerC=+*RdZB6Opnn|I!9iYB4yvaz;VEQ(3fOQ6GthF66 zf*6W%Xs3fNj#Q2}M@%k%xVIzxwDr6Yt~1o{+OEl&n#aP@kK@c!@RS7N13zbbnUjswcXVb+(ys@QmPbw9aVAdQKYm(q2U1C>W5xK8nz6KJxnG| zJ**5q1i_>2GbTG95Qe%iW}-Akc5YEleSNh}a;5`NX!T_t6RPk7h_I^VvU2B7IY8rw zDCAbzY2{qU3IV(W*$NFbrR61bf5s0R*#PG7?r)&+Ln-IZz0X=8j-(pnd{!io zGqHk01fxnp9tq*z8Z>v4Fc)}y6B&>{)Ank<{WZ z68#klovOa5toML4c6dW>lX+$leTG60_8TIgM`#(MT(DHIQ($64@Tf+#axy&TRE(Gg zl6-JkRtdJXc!kTykeCg?+LG29Z?oHMSoMz}y7WX~TrVdmw@ z9S3@KSJ`5vNh5I3>QmWJfWBzggE$(@7vD%~kEA3kSt3nj>>p4jT6N}~O!s)}l9@0g zyq9Q2+cycI0UhyVeRhkBSbibrZ6-L>o}BLGtL_bcW|8GT(U>*FX+rcJJdvT5sARJB zN0!FD*9RSwR$z-N@fD6nXCg*C0cT`q0GCV*oKebq&w|YmB}G#GACoDRfd*iScX#;K4IEL)(|BsuJX<{l&`W58cF#yHU64XORue~FNCBk z^Xf7sTMm91-2jVv12#W;viR`T=f^W>ev$f!))x&uWbxBYJYP^tv?$RNXkrn{A;~J+ndmM8 zUB%J=<5kS|pH%b}rJ1kjx>SXhG>VKekTh5*jY5S&abV(NvN#bTC?gkLE0&?`%amE< z;xF{(C)|HD(~XDS$opT~P!U-1X^jg@&Cc@z`#UlBL>~$-slyquvsxl>0OUY=AU=>! zG~6&?OAkB3un@9Ab)`0lBcNMsD}*n3DBLAFzOSo3t3Xa5M=*62>udd49>t_?4S{uu z9)sq3HofZLjJkuo=2A-b*s*d6ZlbNe&;)Pvo+;h*J%d&>y3RSZ<#M$t2Tjnf*%}M@ zifhZYm=z5fGEZ_6Pf|p?Py;2u=GaeHs$7X|j9LDH4Z$cP=$ly`!NFvPYtyq{*5i+@ z>ZtGQ2BJ``&H@b#W2`hZP^>kq;+Q`K?ME=Nf97Hk%vIDH`sV@WiCCjtMQVz>q(yZjeM>`M%$NR0ghY+!_R~Nzn7;x8bJxfLIh|&`O297aWBxPAWmaEVO@a%ovX!$~4)l zez1-A)awo(nuD$UEtr(KoLWOkrGkdUh8njikvW?-uDHTH^kQq=z6*ViuY*BDvvyu*@n8(QD2e;p?=Yk??6|GRXLSA zy750SIG{Pw;Wb*cQ8mIN7d^Gf~(nN)t- zCG@lEP}NSOm~4k-uB+0F4(E5`NGWUF2!9FBmszJVTRkk>0@abN*0QfH*4h+S!oy{0 zI^^a!s5RzuHu4*sVg;X8a2u&x2J;(AlNi3E{+fM^QzZ`L{Rz}Z22nKd!y0f~FsOeh z8rW5Nar2##+bH1DgUl;}qF$m{>UFGzICO>wt+6aKUqT!_Nyt9Du|G>j*-xt!!=s#e zHaRzgX;BuA1sXXW5P(lzZtG-(l0u^115tx@spqPUAWi7sd$#3_B$TJlXVq6?~$%AuI3J$lA5-W{V}#L zd&o{85A)}E$e%vGJsv#tNY>o5-xJFf#2|OM!%uOGu=H&50CW)r#qE&MIAFOf6_{;j zhUxC|5j=1&I@-E!fTllZdK*~QdQ#iY2e`-hQgd6y;#SzdpmvnjOVMu^q9%I@6zq55 zy~1vM8bo-wM=W$Koz&{Y2!QfpGJ9W2*8MzEWdQ5G<_kHKnumsJww7?uynh7!)0-5v z_rotB`*DG1CzB9Ck-7E7KStlY*4#fJgNOt^(A(j^3ru4D_uc-l-+?;&1hB&5Yxtl+=1&)z{>5)Anh5aRgdJR>(9p#shEfNeJWxu`_2Desxu;+)j5-jM0Zpbxw|}i z5&q3I8LUmlFV~K1VVL$O)rc#pw`?BOF&*pGm={`u%UBHLsuA)Jiw?#e4acR0vfj(E z2sW_8fx2~*TQ4NjuJScw3$QiR10#X+ zsdyXms5%x~-jA8>c@ja zY_kZKEl(g5%G*dA*~{R@=2S6Q=KAr$1xM|>ZG08rQo+sAl(1Di5uN%B$7@##w*C2+ z@$iQMu>wCEMg_RW-Dg7{8@AHYFuA{0VaEN&a*&WfLb%bkC)T47XnP!;{IrdBHz^RA zc-f)lXE#=&bgRW~1(-5?=R&(4}~ zIJHpoX?{u|>2S=Y-6M<Fk3@rHk8b5tud}O%WQAT}b8Q2_M(G=Kl|#hl*5){1x<0O8NBP zzmqurUQ>KktwC{|*nWs@Lp_}Ai5c<%sI>=vpWhCK>T_X)7fR$>i^UQSe_6G+&nbBOA$3hu+&rfN4}0pl4hky|g?pYUrhUu{x&s ztBTUDZ0Bkq!gS?^exNGe1Sha##^A_WOo>Q=VwsJQ%U%5;TR|TP8`M!_AI^`XJY6&F zAgq$N-rWA;6}6#SPl8>IgblV4z ztq;;^(Z!;%j`nz`!wk2xe_8X!oE|vuDkgyChrMp~?)s@L7ArULZ-t*y-{%9#4F;sc zOlIVu-5nKx643XT9l@yxTc&&l-2V94ZYMg>84uq`oXQyMmcQ3^Ns{AOY`X_f%=;$279Ne?m|Y(#v(+# zjG5LyB6z9Ux?sEGHxKdyN-3Kq>Tzt&^q+Ycu+m0AvQ zZ$aqbdoZLoe`daVC~lrrUEnvJ#yF+)Fcd7{zf&u#y+Yz@ek)a^c*N48{c3klB%(|3 zjwcEl<#&Su?-U)OHL)2pnTvy6X8k^i_u9i}tV{rUZPChX15Os$T=z~yW>}u2+=Dv2Qprii54X8V&csa5iY6ewU z0y4y%)E|xyNj9^%3C0tjN25z`=JmQC@s2)Br6CH4a<7yV=8Jyr3bABBjkG8L$8aIE z_5eL3T@iG@6Y>uu9Vq+thF+mn(Fi`*q)5>mIYM~HZ&DVX-B7|b2>Jac{}|$C8>`YO z)AB^Et^^aWAc?y0W0(al$;tHn*ki5P^_fV|@~~L0)tEi7Zb@h0{ePbY9-blfAp+et zr2a1pFw1{pJO8y66?I>7(-rGdQ;aLit<1$hR-_9hmXUsxDnm9V#c2}NWB>*JByN^Z z^%8AnboTry96X%}GEZSgJ)JNyuPDzEqqGa<8YxmI^631@zM=xP_Z0fzI`XW_2?=5!u~Re)pPke zdu$|P)jd(y-O6bU2D>8j7}mdT6jWvNtWgZl4%ng(>gXO(g*Ew1s->uT__n3X&C(Di zZ>V0R;k3If!f!;}$YzwhVlCD}+={-`_CA=c-^yfzLpuZRDzoGU14?88)!CwnA2 zhZ{%qgrd?hoW|%-?=qox8?dC{o+l;x7R{2Ji>P5XdN3ljuz{-JJD635fqB=RunclW ziFxJ&A(^tej^PVdtZ&n%+%8$N*%c$QUaDy~Y6*0Da|}@76RvQ|E#0vet9E5ZLEwNh zg7wmxw#2iXcEy3Dh(_VC!fAF|?+7uI?L2g}xjEG>wqEN~M*^n$)8cJ6b_X_W3X$&(U}u+X0&+=#){l!*EDXS~6{$qQ`BN%1(?TdXCPC;hV+ z_zuY#Oj6@lEnj!xV{aISDqG_dR}1I}-XWJf^^6g>c(H^Kjn<4I zq(TAHO=;Qv_iQ!t9R&ICK}N;h)wiN2Jt%c2@3~ms3a=#ud|b!=D7ZV(L!xsO8#gw4LuV&$8Fg zvi}v(qEM63OPmY^!U_TOKw2O!&=}wfz!wz=5COvjyCfKJy{YQzb$X3zk8N#onQ%fl zi}vI;z<2bHF!MXWc#ZWmahP zp~Ij1IeRwyzxXDkvsRFFwRy8g(y$^P>x523nnafwt-yw&Z}vR<;B@7~e2PRj5TdJJ zsldnxm?o2w>o(MwlKznGOz+R1m?Xa|Fc_F!WkF@R%_x8I zTazNg$17d;1Qm}U+;Y9;GGG&N07!{ty0L9Mzu1&$+gW&Irdrk}L>i zTQe2^J1Cg`N~7gF`V%SVmy=;{A|zxofdJ{@ZvO{t%iM;hI~=IW4kY~p+1^47GQ+yi z8JXD7`hyY)?w7(st){fwh>23zTY%mx+#5I(EFoX{k4>zH%1$vK z^NcE#icju_ztcR4vSZfv1wqV{1Bxn~^r&WPmk$&iP9n^qso=#eEQzLd_R_8`M>`2j zNHWpjU2ZeQoNMjeUWO6fjhMSK^+u*-iuBs$bMR*~nLTfHfR>(`416`h&A3pTmh=?a zZiX=NoR#{`bNrHQ4WQMsW7p6q3#u2C_?q-Uu z;*AkP!lsDbn`3RhL8tKo;OCg%Y5MrHD@hxpLe3K}rs7&QE&^*#ej1yfuX=}yt+5>2 z&q3A`YqKuJX{o4XG`?0(|gQ8cGCLJD~R-0i0FBZ z)cF8kd98U8|NRR7{!+)j@0(I4=Bd|`-p@8H&BRk@ma@Jds0B)qC)lP)lj}K;UoyyV zK4SXzvitUc`AS4}Z1VZ`vHAjWeFvaku}4;-ubV!f!;F6pP%7k#NSHb!CVJ&%b98{1 z{|Eknk+CNB|9I>Bk7uK+Jo6QqpR154kr)yZ5_FTkqRE@gS29U0HLXzgpm9U@ZADPW zWq?UoB^G!-iE)$|z#)41_(naj)@6V~k>>3*ezrb#whpqKK$ToV1O6aFIagy`Hu!pY zXZUOQdmKz`8$ciW6%9QM`GU^G+%Xt%rm^`-YT5Sibj6jRW!^>33~k?sN||b3lh^no{w=5yY@#Eu^5vGbAKC25kKkAAk;jCR z@<`za!w6XcBesV_3v5vV=4gT{*kW2>n6y{OpggS$Z%?2M1l<*$a19l%65U zK${9l6ft#j1Thp-^>w1z@8amWOmy1WOD-Ys`lt$nlt|%A*agT_;*@$w>@*oa&<262 z4{|Nv&KjO|#5wI!I^7e&Ssyd%K-=P|=YuCBQo^AqO%!Tm0GZ6b9KYtL4;l>+CMuem$>puaeq2SiLat*h54wrV7LRp>0Ywaabk z>P~1xE7eSTZjkLaqB$5c5REocDyKz+Ub4Cl;LDU!QJ-7bZYn#imIOI+>I5fgsysq} zfd%2)_t8d5sWT1*_;Ie8%HvVS$ja0pc;!Zs1a|EBRHx=NTL#_TIrW!VIQK&}9TT|_ z{>&lUI3shfu#-{MRYWIT>BAOT@(}+4mt#rIQ|*tsGOGjvn&8KDho9Zr@{JJv*-e)I z6hFS9qqoB;ZoKLV6!ZXn5PPFo(RDwlmBGG0OG|r_I!;|WN}V!ZM}WmsVy#}OQMHVd z6q$Z~F4vr$A-hZqURO&QJy1mmj%EZeyiYJ3-&Jdr4#M-i0gg`(0yQ?da8C~_Nj7|! zp^{GW{tSpksPu}*5}9wV+lS05yZN$p2;)9f8R}rj`YQV--q+rK~8I9 zpjaV;iR7)P-3_u*Zmju$y$v8z+j1_k+D_Xv-`t=mo&}^vw;f@>iQc+(`TP=E%o^9| z96DLrUM_#W?!Cda;B^a%XJAoj{4MT>Rgmmxs&T zQp30i|FYw*hDw&%0k_hP`7{-cUbIFk`in|h@BWXP1oaGTlldlIe6HmiP)hQHpA9Ld=lp?eAama!c$*P&551SaVLG>a(rU z$W8q=h611L7GUyyfHpE$sFjqUNA^`X@D9SZtdT$)8FW$ojj!O)0MCgcncP%1v_YC| z${Zwl7Yd~z?ll$C(~qV*nZ~&El0*uqMa{2^>vC!9x4)gW=YHGBxQ-B4^$8AEFPJ%g z$G2=xb+6sTRD4)%;A50h=VqxNF@k#x(z3zv`a8cIB3Ie=Sf;};buB?Xfh?d`pmc))ZfSwt z^!6>8PRdnyXcPN=@y>R54OMxTE<7e$t&hyJA!etB&I=0>zm$XyodCWRQhpYNL7!Y& zsn*5!BT=uH3E})1(z(tt>OcE0lC>MxLqOZ?L*QLt_>Z-@vZ0+l&@tQ4K)~9{-a*&O z!Tw(rd4l|3oPO@OX4{?CsN0YEuw=wv&Bez-LckzK?B-Z{>yf4FbP%P|=1L$@~{tTEb8anC+JM^yo3O??8;BvwnZJ=j`}4lQM4jUnzN zseqfe5wEuI$JmX}x(AkNK8~Vm53L*Z%{%UV)39xs??89~l|@$zjf(cLU}+LyUplCB z9mj9GNT8=0Zded2iuNOf*Gye=eoR>&lM2cCYH}YarqaBHYU}v1Ufy8! z1Md+!N<$0gjls`M0F7Wi8pgYW4%x-&eYMb($t`jKJsQM-Ic~tzUj*I|dt>HJB(F}NGD9`$5SV+lwujT+l zA_%C3_}}JYjQ`b^GyiKsT&Ja~s;nIVb*)(1h(sQ%oL}ua5Y{RNv<$woKfn z0P-z@{tX*ZAp$r2w__ov1_%P>szK|+hW$m$f#mba1F(8lj%L)|$?*{(Vd6R$1QA1= z6D_eB%)bobFoMa48KfVK|>T$o+~mH@<%I^F3HSsljUxukU>2E{q+_Vi_58HmGs&Q5|C^+NqkMh@B!deiXQN?86A3bO_O6gioe@!$wkeF z3$L{ zOEiJ!P)Q!U{Gk!QW@0YU!P6RBB9l6$dt z`SoGn!OX!z^&RS-z29MOSsKlR@-XK?Ob`jG;}EteIaqItfi)P-!J}IXAXk+7D~96i z7CS41>cxNYD^c1n5YKX-Mr3Z}IbKfzcTi#AV(@M%Z6V8yvaXpq+H@`2(jtAq9{*c` z&DbOIcdWkVqWioi`0&|JaXU3OMIZTv=%qQOWVJ}=i83ApGCwCn*&F=`sji|`>>0*) zv+MYCP<2G%7XRBb*5?0|`LO)m82fLYvH5+41ZmJ_sQJx4^7?oU5yW6pE0;cxd-Pl@ zGxt)AH{jbU=*%=%Kr%kAVY{_>Hkj#e&)CV1gU+_wsm*U)AIk}{ynQ$!skW>@9&C7h zkY)vj@yvB*xL9__M_$^Wz&>aHNM zCce{f+Q)!827%lO?hT4&A4Q^{kjO5xo9x@p=;e(kW&8p^a6?6UzaaIPm%Nj@3?NTE z$ZM=w$rAtN8SCh?&zHp7=YUJzT3iK6>vC5tU&r-U(K82}e^jjs~ulqA;> zMxSI?=iC}?0s6jsvvgSqSH-r6gEO>A!!f^Qdx5ER{%l7DK|F^+#*kMRV<>jz8wzzPBz{LrUfC?9-U|MR~W4@sYICBnNJL zXE@ty!>zG*Clkap-H{^Dz1NyCE+Zu)Tzb&U+w)6D;1N10tsqa&Z`0WOK4c;RY(x}c zc*qL+T8>jT5;#J41Qjs>V;2#g)VkWRP5Q50!!m+h_)dw$eAa@Uc<4_n2_qa62HO~w zCuz~l0hlXk<)-7&*Hp`LJNt+1xD*@x6>xJ48(-i-33X9WltUd)aQ~TqH*U<+1c5<{ z@_z;?!{1r>Z)lNQBew()s}MDq6dZJ(w&8;d-A-B`r_DZ9b3 za9A&I+`VQQb$TWI6tTt53eWqMgP|$w&5ss&i2~pfYCvN$tjyeKA$Vd#8p`_I-IPeB zz+33mhCPYE6#f$+TfeYxy>dCR^UTS7!ssU7WjuSKL!pOmu~y3#Ez{Bedq3wFR}ziP zfeiig)lnQa=H)t2@Vuke-YQNmh)*fd)KeF!iLX9cDVnM7Ip$)k_Q*H#u62Xaty|Kf zd*vh1oWS&5=AJ_tGK|V_yT0WD$E%CR`v9VL0ePaeE3cYE^dP5V&0bnYISGw zFK=zLue(xIX}b5|6dWemKyPgfJ>nXVHy$1$>~<+XW*TZ+;4tk=+-W1~uf!j92{wpV zFG>wSFmH3>E>KGeitoNbF0e2A>6&@M8ZRt8k{R@b?4%g&M|WW)wO`{m=1VL?P|jk8 z@%4Nz%eNJ}z8%?eS{AUX!sg1>Dt!+1BaSmlrzUlrb*jJ_U08v|oK2mf5q{fu>*MB5 zK!|hz()#;)UO5+LoB8z7Vi_qP?FeWfd~IZOAu65tnEj2yisCWlk0G+LZ0pq8Zi;51 z=1)?4x=6e5Q9h881n4=d{=a58llen0nXdbDI2_1D2CgZ`vZ5b-e;S)W6%r)B%~>eM z)KG_4TT0fMm7Bhmn&LRq=Y0`6#4#$7&tV@|I0Mm*&a*JOFf0_@@#qyM7b>QI9091RGK~`X&9PY4oWOqZZySQ8;@@A1?5Ut}mMVzq2|i83@!P1i+-9KyqEqJ!8h7tU z1D_IMM>P|hJXWI%^(prg#49G@xOWm|w#x^ivFwsyF(R!c2x~1YbbObY}l@ zB#NG|uFUf-4SeRn*~$qN;gISo+=VRVV3E}y&RB9yeMvD2+#_EtUN+z=*(pDi!`1H* zrDUoK=~{3)G^Wolgb(x=T5=iO_RxL)c0BUN;LkQwd&bLN3RPlNDUkQAN;#ON;WGeL zbX6dnB^yV}F3|}wV$ElF~<+yw`%>j?gVya4AAA*GV zK7s+77%co%hKwc9Ma*V&ozhEq98Oy@&f9zO1`C7F#1H;e>_)^FF^M&%? zUGx5jX&yAtG%wL01!$V5cb^-G{(qb1u`QgBdjc7Z2kAFl#t#QoRhu8nbtwJ$h`{NC z3?RJ5KUZh#$_a>{B{FeA=%T|ibIo2R_jFmQ2Chv5J>R1n%pLoXz6h`xtM!hgw$H%9 z1U9(5MdMz@k5q`4l419i=G(ijmgGC^`Y*|A7DC;XYazI=Z8se8vK{sF_{l7z2i^n{ zJEb*%ZntlLCQ-|E)i~JaaRxN-GX64H$oAe%@}n&xSLnz%n&-{$p_4_CuVS!BN7h4k zGCL6rtuShl{NfoA5}6A9Ip{Z*KOga>1+h>lrxM3Vf|u64`*(Omr0_Q#c&~hH{^3-yGenYLOxXqeR(||9l!`--5kDv=qDBUV{cR;1c)Y;d~1M3BWd+$j0)IL^sP_8*K6b2 z5bw*k!YYM$^8L9+^%s-B_q`rouBxRLh$hXuqR|DSNvVNo(k|jk*2;51x4ojM1`AGR zn&)yfQa4$$BzN#LdJmGP$Sxjo}Vw|Tt$~&r#`8UIp>Y(E3$n3He=kddg;nIYlRI_ zRL&;pc<;)&7Z5`0;X+K#bl4$q)$Yq?QsKQ(ipPgj~6Ii-_WfTRs< zVtt#oZQOB-Xg~A#dB&ykH1Tz1)V5%ftE*O1Cm8!$`RjK1c=Vx7R8yE09y0N@KNQ!| zQq*f3KzZm+sfPUYp@2~LbLdvX?K?CO&5!y^Yjw>kHnwy}9H~@2+jY9nD7ljeS!;gD z=Rnm{twasyeVQ_sMw?i-fKbDMX&+NH-+lpxOT&Vr)i$r=MTi?lTp`wJ?9LQpm7jUt zGq4AHyG$j2vmY-I@KcIvJIBWmG3xS1HzFdXD)Z%hKVvz4KFtZ|#4A5*pJrCos+nD; z2^;46Du{YTpe&}fNh;!zu^y&yuCG3_6!>bumQe0n{kB#$9BHjGVN!iylPREF*KIH)U|1&Ez!-jkOOuabT)(%Mj39?uSb2#v60(0dvtMa6hq9eG z@?NbafEF$L+fU5gEO^d}*lbpUuITWW^Adv$#abJfr8}wN)Lxhg5_1glQ7!?ec#pc< zbZExEN>wxKpkSYvYAEOgz4c#ztR63UrBPo1BlR0FKNI|!pZ~UX{L5*K;lB)tHGWUq z-oE&Qn|b>x3RO~=`G~hbip%+cAXvZ`0rC#V@VV<}V(CW6dl6}Q9-Y(q(yw}OxAJ&J z*WwtaM-3V3mg|x(PS2Z0F1mQXe@yOAqa$#c_r@pcMH>`Fn?PHjR~FI6!otO6&HlK{ zffGdUBj)Im+pj=e7%zI9ym%Fk&t%Ej=rK8sBk}!b8<*TqX`Yyb%(I_yg{D@wRg5O& ztfXkEbB%<`;;U)B8oOyiwe^KsVW>+j#$`7la&cg*#&nWH@YUH@vtx z`;-(i5UVe?>WE~wd1w%uW0iy|5kDyzc=_ew(P@;LJ=U8GUm|n2D(jIIjexU~Rsl@c zQ~d{`vRJGuh0_(-jpnsM17%+Yh+}MmW~hNOvx$Y0AftokN1=dU6jz#x$P_SRyIGWJ z`Mq@j81`PejPxIQB&hdPHF+8ru#RrpR~efYFs`FKlM#q1nbYec6Eo5L)(lD79a?3cCv@HLg zQcKl94`s%ZbmK3hv5eW~+i$3FCLEV*Vf8JvWp**~5-oRc40}~7J*C9=1T`7`wmo{! zw?4U~w0JomTs1qKg1)f-ah%Sx7yj-kHxNe;(OLAw1&WTBriT}2dN%ZeJNl?r zyD{9OxSru0n6)d<>dQdfY(*TUR+HydptqW02XG0 zSKSCbQf@)yrD`9i1m8aX^JO>l%Hui!ye_r>sk8R?MNwFlMwCO+GUjm5K|2TI`zkA| z4i%q+>nxEg5H2C&koA8nPXOw}&+A1q@LE5jTA5EpE^g{AVnZQS4kD zt@%a6Qs&#+^De2kDOF1GfVWRGBuoeffWTA_X!OaVHyb0rU)&mjj>byY1DD~bDKbeP zK2#U_u?_?B>f~8IyUcSJ2DmmbAFTOpclrcLLwlrXgi>O zWFqre^{DN`HK8`OA3%N1Im+XFeBBL{w}k`cZ6)2mmq@isgx>odQge=zQN=$QpcAMo zYLR!{phyFxb_9PAlQ&|!R@+9Rg?$q#w|n-6uokBga(fv4apE4-o49SD;@8 z|8O&(UBC|y?ss6<2|+fg%>Z=d^y(T7JJ3rWBW)xTAF>ApjWe)zBi=-z#X>IU#xxUD zB6dyEYTE*u~e)wENKvArq4O7=A(0XhKRp$ zCFs@EchPzTE;3SU4M}LVicKa$Ss}2~8Z6r1)Q7o{@Nq1y_FtO`6B?GcrOi^(yljd% z#>{i=u1FVSVyOE>r+obwMo@HoLDaY}lJjP8Jafqx0&6C=2vvFrHr`{P;vDpbeQKUO z_tiMwF55CM=DZ`gn2ITfxLl7QaMAu7=K0qlk@`Hp=+a}<+Pqs%E+q7Ku!`_u&1=lt zbB5BwJC=_9Iph9ec&S9)c)5;@`LSZT2twW6HmLHm?s;v(!VbQodlb+WPJpR9e#%Jn z^(9z}F24gst-}dFYYg2?kUM@4T1;q85X7DQ+J|R9{K8!xbPhh8>19;uHK3=NOvx?T z?R+kKdK8b{f??;?e`{!36fpHQM;$3XX^T*N4G&GN*~UjVXQto3*{2y-0yJ+rl?Um2A|(Rq zU)C=hvv5MemUibEN9>zt^;zF8a6e2|8{>sgCn89s7-|efMr!%|Y^hDyLH`mdY!e*{!>3`wRo5JIV?Ciio707=k9P zcHda|QJ?9t_e08uzu^@x%TS-}jK@{e@F9&$9#<`s^m#j+7u;udO4WIbgO-a^l0*{S zC7zPX0b(9SQWJ`iLwYN9v0=wA0X1XWKhJ!+rZ?7T*JOc&o9!Q^(lv|;Q+*0{^kZB1)-J=9$)4z|MH&sL_A9Xj zpUXqtjjW+RhSqI;g!yOUnw$54Bm+tr#Qu|%;otUX8A=nsrG#&RFH}@zpA?aVyqL^D z_`ib9KY|KU@dsp;%9IbXygpzrSPvy=+ z4k;9#Sh6DNEaUuree?Fz>#5}(y&GwtDc`YPuQ+TzJQ8h?DSSxwRm!}1aJrH0=F!xa z=iMg3hnfy+tVJ5Hg>b1^3m$OAnW#VF4p@>8pziErWN~I;5IWMB6lTf#E^qT15pH^R zWfwR$UYlgUGywJB%ATgJA54*ulc&S3S0YaX`8^@YqSgUfGj7#{GO42_ZBnB@dEca-&kAupNzOd{11rRqpdWBAeytL?7Y`3?95SXixVF?!#)(i! zM#5{G>EcTr^s#XhbCCMdHKDes>`K$NB5xwWQWw;#xZ@~g_rIm zl?a6W0z5Y7AZibj&L$k6{-l*5n&+W(hED{G6#*^Zu(zA25U|!y9e6%iBsjtPL+6m! zAOl*1gv3iBcbBEs%-}-yV(sRXJFrLb7u{;v)Vhsi68E7dQxhEgUid zS3hzTdH}VYHnk+J0J9aml#d`&i;OeOe3>tk!o@h}-b8%$PlV0+_W5-Pi08K!jpq7l z`!hf~9palGhxCr(#A3u;@DB21b`XKmBJu1(COuPom46v7Joj+fTHpM8>PV;6PXY7df7B%W?Qs3~YtKSMOPT!NnuNWbj{m17LFMk^aVw033k}hO z;f-WB-STgm1S{j_IIBI7CNcEw1J`eN>$T&5(oMjZJ?^Zdy#rn&js4{?x zqcX1}a$tt<^kOE2W>m2p(||IIp50Lru8=q9E?h+gPCEExmECGLe)TJ>m45@w?G}m9 z!)%kV=r>K3gZzzW!d|95bR5rz?WRf@{{77A*g!r&z&J+WQCR>|l>b#88nhg<&lOyV ztW zz-s@139B^=S&s+YhU~I-r9Cyk_wg0FYV`{8z(S`d}bvXQoT69ft0i157 ze|I&(F8LzICH*3?2f?-ixG8%>W3=`-{cXM-dl;r+W)t(=@L7@~xZxz7qPhCPUah)O zuz_S*^sxDJd-9eOK?4_%H!hB_&(D!2#VyA8_|UA4kHs>zMSYDR;A|9haV0pgc-s#` z8{_D%Oo_HuscMkKQJ=mzQtyWoaEoY7?0$|^ev#B7?{sVyO; z!(&-;KgfycQ?bBK@Sw?wA?1X6)ues z7j@XR8Jvdxy6poBlQsVMbTLcoW-fUIOIH|a^>XCW^`k`W=E~*m9C;zvpNFkfKg>AD zQLok+Ehk>4_-gFL2qSq!(xQRCVXE^1G<)b+klG_ppscoFAYE%ZO1=&xP7@<(_ArC# z(yt9-LxO>=PNVroNzKu$qR>(o-#50Xom=%u!)XvX`MG3&6^YpwhSNuo1S9q!?~#`c zoS1gq521I+r~u?-MP+T+YO}&nbnls1)+Dhw&9_SlwSF68E7Bmsym~w3`?<6lT(Z;J zI=MmCN8Gc}YJQKRD1Ini&r=wv3ogSbqlU6sR5UHDq{vRpjiuEEH*i&q5lSXTWT;q8 z%5kcqh^0+&^bBClZMfG!?rS;k59btvx~W31T+o$HYn2{KK>_>OBz2&iDV(Oqti%HG z{1bvgU=vJCZg*5=0!=t~f(}DbrMJ`9`m)2te;-@nfk+*4|)YWg?-{7`SHJ@Qmx?yM!8dZHsleHs^#R zciA$`EjH47wBMmEoJjgh zzofyTBO|8cPTRrYL-2ioXjnGb7FU^`u3b0JcnK9{}`;qF*2Z4z)^X}n-}Hpm7wy@JIHIB=BFWSy3` zzJ8ycb@!e;O}+TAzAR8p_$g}I9`LKoIb5O}o~EncoZx9g;8SMSnpto%=#Dh%6^Uen z)bydV`4fbgi1Ze=h%`c&e^8JI$&3uvGYan(=dJ#7M-puD1};HZI;X~f$on6tobS6u zYrl1e=fLzv@MmiKN4JdW-?g--g1G{ME|S-VA$(84*E|MV2YOKED}GfyBYYCT^k*#I z$1_JQT|}pp^#O;%l9G{<&FLe8m3~t+}eB*e6up9uuG@3y>WzEYuK{jRM9bwZv;Ki z$k1%9kr@HFiK`@5{0qN|v^h@I9HTTZEijlXQL+HN{keUS$T?9Tf~k4n1ij9b4QY5b zz0mMSn&mT!K(X$MbYW?U`!cAMB}cw?`0Kl`()p!BQ_1iGg_68eb6&w%?pMPm&V5U#3JPuKrlqF(@2ep&tDl zR{~MtXeGW{)tl`SUq{)KpeanmiV@)a%_OOU8J!_h(P^cvP9K1fRe-Y>vP`nyyJ<+3 zEF92Nl&avR`wi$d({vvcyBl09Pg-Z)>^8gs=@e-89KvB zJB1JT)o3T?umHuc@artN+aXwd$jEj~FxHh9*z?a^enEE#up>BKAO!~+>zD0FL>`{+ z*k$t?@{amq8Jd66qd8hG+D>2=5s?3t`U?;ed4Ogo9yyE)PcR};R3x#BSR^q=8CjMR zJ}feH`JM0U&qG^#te`AAkC5zJsMlF@!2(vaxD7pJx1e|j|L|KJp(8&Hf!c%-F&JWi zzTmv*8d+|9%~s_u_`sLh)^&%IR-qMb7cczLUfu8*qPQu`++C}pbG-dy!SB13!a#LA zsQ)qIQn~-1OKR5VXALsI+ko(Y-Ui0MryQ{wGKg|;L+_v+(@qIt*~(TF#XvDGk{mfg zEL2V5rTpyY4e2C8t=~JKYW-2$Ds)$susmppxGx{P$aW?1W!tBvyfPRW4|o|5(zCic z-#^%(Qi|o)@_3hkWjm3BGL zt!gxBtc}h6MMqX={5L{(k0)%ZDqpFUtZR+4ca}@l6gmk{2#x!h(!F&Ov{ar83ZXzI z#%e+jF7_=B0#?Sz<1}EkR@pKsN8e81R?nJ69-ObIetTmq>2cvz?GO zNp2h3yW=rdyc(Ud&!(&0$!{(|U=!p9=5k;RSzTgF<%Cho7_#s33Df920+CoTh6lT9 zrAw}LpN?G*qSss!K5KARJ4$%)VS#C@bWAthwGw__+Dzf4Z0cb!uUx+c>5PD}#MttOAH4zz9A{cD7C>r|-)Fb2zt0WpPGAEbg7o4b@ zgY(HbJckmlsdv?IS3bV#?&1TmowBg1&T5X&u0z)+J5i*cwz;Yr;dWuG3kyYrg9rp> ztg~s)%C(Kc>%LY^58WdX#0Gh3nw+Qt)w!*!i$aZG@{(-OP75QG}%X-K@3$glCCU}*yf!{G?ays27G*zu~U}+sr>Bq$EaDtfFs%h z*y8w&^ZVaaj|_hYqJpJ0q6nN9OXtmUk_5=7G&E6qCTbQ)dU~WLNB$cqcAF1CzAqfY z$;}G8&3SoS2UCeUAH3r0$JfPgawSmml*l{}A`KbbY5t-#58JonlMmrJ;52F~v6P?LccX%g)Ta%8yBW=zIz!a2KkH+fJ>zS2$`1nZv?C7_jSpJu})Oo*oD8v_dV#R^G zdAT5Yo;13mSHgsFx@`6z#y(#%^yy>->E)OsjHC(9TC)h?qf5$owBe%X+D+CmZ-h89 z*(B~zaz-ACErJh z`6MD%=eKVu6PgG?HLl;&OOObl(opSj3|xDM(%l9}K_mHsUCoV=xq2A$!#ZjECqoj~ zrUL$j2*U;IjU(?hM$WPEf-g)g!RmKP(Nms}fs8@zlvfC{EwIOhnle@n59D%Wv%)Z} z#W4d~XmUM2WhwTURl&f^g}1 ziDBf^w1LineOldQ?fPHfzy`+W+vH*OfG>Bs6VP!*L-s3})tftvdABmN=+zKIkOwic zMbSMWk0^-x!*?E}won+_+f0MxaaM*+!#IUM!b$|6u)*)6n^_9(rj;zkzr)^*Z01B< z@bP}>s~}29nAu1xv0zBsL1r{B{QiWTQZA-l8F`BTn#v{cz1PFV;u5C3$u?Iy1X-e| z{KQVl zpjQ8F`1?N_k+-BYW3#~0NdnkL{vXgyJ_ku_V`D?Re?162BfF$Q=>dbhO%FvX>hl2^ zHB<(CdLNYZbW8hfE-G@LdZC#>zD+Czi^?AG~66l?Z|kQWb8;m)lHvi6DM)5C^JI#Ucw6kz27>Hj?=A+ z$-IJ}4-a4yOh5>T`KUFCNQ;1-EqPabkjLA8?CLlghv9s@^T+H}wtOvY8F&<;z@zvd z)#TqtAq;#bTidz*`$Qu6BtYrm0ygU%mz2R^wV`~M)Tgny75xdQAfWl7H;V8B70jl>XfzoxRFVqt%3hY_ib=G-$)by5BZ7Tj( zxiC8{`yvH*;|ey<)gUNTgXI;%WVEm~IiU z10+wm+>d=wseM*D-Dz92@TMPe+FHZ%NH+u$sazS( z6e?L}@O5O8*^rI2giTLN>?c02J)cmCgsXaos3kItF^rXV_jK_FcTd7vAE>#KccHOY z2O+%DcLE@AL^wps^hJbu@4;gtR7fx16DdLP1rj{Ac+>`hrl5Rd;<_5GdMiHAR3j!;kZ6 z7T=Vo!Df96pI_>UmEZrEl`ein9=8RShgrb#5dY7A@xT9lME~B@@>KX2!T>al3bO2z z!vaK1Sg_bvkkH&u)a4L{r664XqoCG(3>GMH*uNPDb>+3~+>al<;tenSdx5J43!1IZ z>E}G$&(Bj+-##c`aljx($tU;iGF_A9%k{s|p{ zit1bdFfDs#^k(9r=l)gVN^y;t3o@Bs^f#w=DfqUd8t^%43HYA9pc|FL!z%rV5XfM_ zo$iMVMgwVsVhX8kj~`r!()q*fi5FAvk(Ao!j+Q`UrigiDm0ZoJ=eo$&T=c=JoLv4s zG#B#HpoDs;$Dwnal1mQ1Wfa|Et%^0`hLVd3Mv?UX=;?lh^%DjtteU-n+DNB4>Oqsy z+9OVCbObkz;zBM1v=#UqH<@cyH2%^4+JGV#BfWuM`*yk-vu$GS6xBM}K@LaT3B^M> z%_GC)(0?`kP{|}X<`To<7g`v|qCV#+wEfo2qw}5`8^xAsneo$v zU0u-@{)POLP&}{4fDN%uZfE$Q^(8(TO*Sd9bDmAfoSGVcB2T9)YwtHE@M*H6Mv;4> zGi6&Z0%J_49n2rb*vyjVvANz@RO(@LJ8Wy+!TUAQJ!If_rs2}q7u0LQzLPBHz-9v9 zY-aX)=DV-Y1)3%!N@wpd25&T4cb6h7^8ykwixd(g$Da6Smzdc>t+hGg<>4J3{M|V= z5{NVc!7s36cSLG1p{co!>NitcIgJA#<7Q!EPha=w*ST02{*2cab_IM$V7%G@v(Eok zKQjHhf$u55DFbAOzMU^xPAj2FMnghSm<`SUqP)@_#1j;VtBDyO|D1W=I_4y{M0186 zqq6(1mn%a84t&UfzT|};Bnr`$+~z!_+jre$TyUOMZSs17xI}&?xmL{>gegx^9g>R6 z)c{+g(ecB7=LeVXFmTahjeMkc_Q1K#Xbd%&u4+5yBMko)Nhtf$+>}O26vBEm`b4B zrdv*hM5ok^4ee}O^ZDDee@aN&foD4WFaQ9SF zp;6)vD`Vft(y&3#j-=ZGq~ACx61ldBa-NPZi7q{T*la#akSp3_FhixZYFdBx zMY;MJCPUG1W-r?T^O4{_V`vI3YI8-`8VX8vv9W>!jD$antQb%uo+3?XTN|r*-5g6- z+Yi<#{^xc|97Ro$3owKvfhq5Q6gU4;yAu5!K?;_?Bgl*9WKoW?>Px!!qi<`Cm@b)# z5rnUT_@D#>DDI=f5slV}Nz#2}x5{qRrFAlz^y(6r?!#rYAU`cPf0d4ZH+aL{(knZv6!k>jl)C0zkqY46 zq+Y$}V}{TgyPA9o87OF&Q-?j)OA30)Pv6v^Rve~Nz4NG9=V3?M7V-K)WRQ=+*4{EC zATxk18wW$HM`yW-y6qMx5|eGyt`e3qz#woP&$_BOWHGxeFGcRT11<*W+mU3mB){ig z{&A*A^|MmN!nTOd%{*2@j_1+%IHOO0d)TGgAb&%?2~*q@52tlrr~WgfKSIhKd#E`dr$fKHOp3#aT{ioG=%lho5xSrQng&0MFNdvVn5fmK z7^WFmKa%stMo)50_|$5;D*YZo$S@A=WS0=)IG$h?KuV_e90a#b;E}lrabu$5w-Hk2 zOxgz;8S(q=x9j`k^e*`IvM@&~XZCXPiHl*oN|%h1k0_)(Y=F zB2YksqJ^pf0Q&iU5j$U)JcPmZ6-!jRWXtF3R3+oRaxOqZ5^@)psZ9f{rx{kjV#mZ9 zGB6~x@ftSII8i9wM8=VKUcPp)%6em;sNyU2yBFjI+pa}8qQnev>(O08Al5VjI@0np zgaIT+83Y^8@g;I^4&kMdu;kx#M>B@#V+VD(P{WM7kEg%=v(oDKarsID4A?zj&LjBG z-{`*^n)1Kd-)P)(R&Di_veTuaA8Cs_@FWH#^dj&}h!c_C;+X=!_c1hvA3Z)GdZO*J&IxNYoRZkHZ>HR6b-lg3fOQjc4HHCQqObLO*I6S#t4qV2B`v7ni=P~9Bv_y3*xi8F8ZW~3N8W1)H50He z4;)+$3!a}aY7c|~%5x7wst+zmkgb*VzSZWO=NcPLE%nZNn=w{flzR=weHa4A{_gbL z#!`W&-a+l(VD-Mm%90si{gN{_;58VA@fBTk?{!^gLzbq3^wNCk@-x=FUdcC&;^ZF9 zr*1)NXcV7zV;?k9bkbC6 zezJWFJO&?O@)&|9I3CQ5?$}D!HToua&Aw;d(;pt<2<=>>+uK+jQX1lp(rA;FW24+r zvZ*iuC_`-24H{1T97yqRxXjyM_hn1$wV_Eg%1oDSuL&`(KCIpKDsJpAbLbu zuyuAvBu_QHBR(u*@Qy!p`dZvuVOLO{&U#%UqjSMTDPf+f+Rd7t*8mW>I?P(&K-rHL zkk8sbVydUv0&!)qfp?vi#nFZnSzV;OhZcW*{DIP>31YRhQzIuRNu!O!rd|XiaFekAF zzQchvKIueh_*A>lVvYI9No$QnQA$XPNPc7i^xoLWZ4{YwpOj(dY;YL_cLOe-=w*QR z2oi}}t90f^9z5?{a=Mp>6{R|8WN3wC4QWBW8@oX;vw~2GlbFa>nDU*nzt&o}=}cVb zkenKOutlO>igcD6@~zUZjbhtICK^lH0u4upos&@J0B`ymCUYlLQ*c(cX{>e1s}AJR z)N75F90}0R_JKW#d(F$O)8F>BAv?}uIgq<}dcd7sf0dRD-*Gs~JGD^G8ZPT}DI7pc zjA05(u~k;1lW^#;A+52j%^AqCeW?nG1Vt`$>;h`vqI2+^>NvW%9$11GE3$LTaN^h< zg$g|#h`5D0OeOooJCzh%gkc8WmH9^~n(ltNytr+WL?pvy`kXId?`2MOXkfsiLtWOiJK< zK-Rhaxt3i#j`1#VZ@LyaK%=P?(1V~`FSNUG`9+xNbX#InV?(`5#HFm*aELLy7S@mmOGJ4@aJ$@_jH+i++I2hv0(#3Hwm zQ5r4RjLGYvq788DMzZ(vusFcy^$QF55@VLphi^LJ9CB@#bdD^dd)*F}Fx)Qsw+&>t z4c)wFCi`pN*Jwq}2kA%`;?EvsAGUHbwaCK@$fDI9?pChJxJNnaZaW;fY; z$JROcM3ofYFY6KZ5`Q2YU=fs0x7>d_e#-79-x+)j-Wkt(9QA8*TLz5a|6}YeqvC4X zbm2g78gJa)-95OwyF(zjdxE>WTjTB?+}+(ZxO?F6&V27#GtaE=ob#ji+P`-1)m2?} z)qP(AMqRGNjuY$1gya2{9X&lNcoK!Lj~wkGb(0Xvq}XEGT*Tt|6-9*lgAvOe13Oar zK%}d)$UlIpz~7fft|^uTag-Dy2>_KyC!T~yktEljL{FAvQ$ZC|TLn~94yU4RFQFno zqvb83QRryWIWDZ8ffDG|v$6(SdPh=&o1-FK!TwS0XBPRO9?c-*7Wx)cqo6o$n7o-g z$ViOV67q_wyc?fN`iqP+2C|Gj6jKvKB@hnZ5tA)g3fmPP8cvQrCR;c~8}}|y5xXIw z1QqIg7)2wdCY6PERaT5$1Vgm^=PMOjHNrdOf8WC@Sy!6kKKC%W&lTo>;C59@8?#Tp z0~`DQETGsZ%Y8N=fc({SuHUgoHCMGQYjaiC!2Y0;iWjQ&B|>4uzSI&|_kjTN77em; zFz|c+fr5Lz5dIE3NLLbCmtOdAVUxSQov)MJkH4S)9vv}$DK8<<+Yth1<^vXy$f^a! zk#zz4j&Y;?ckHP~$7l$cO`8_oCLYQ=J%6; z%K-`aOU(k-%kqyzhSR3z`1V7rkJ#q^0*}!x=v=>>O@87mXPdG1=kR2s@fGA6r<*rU zAlp2>Ix}rJ@_GH3K3)E+x9LzCZ~Khlw$^bO%10+2@bxH^c1=BJy?^xzmhFT213JvO)|l8>9Nfb2C7iGP@3;bN3}hE9zZ-y>GmeHRcFBK&&^Ju z#l4@?8VpcpTNjoV(N4t%VL5)Jkf+z26STz z0#>@E*TFc4hl(-q;lE)2*#n|5tl*h3+>4J`M9MG~*!zVlb^J0O*CTq-BiW$H2ylOd zb5W1`CB!8Y*c8d97B%Cuo0Ie*E`Gy;4SN_G3DHcF22bpia1kbG+)G6@Ak$yH5`QyE zqso^IxGnh2+}EFKB)lt5X3N~~B3XXSI2I=T@-~u|S_V2&U{UPK4ErOEO(uFb6*?s~ z53jw?qqI;P8Vrn0@Emax&(njCT_KX?U}iO3X0DNuJMf zf`Edy8)Y|)DP31z{PO1DlV@Lc6w|ec8D4B!)idjOY6o*iZ?lbmzVkO~X*k%9G2gkR z+`6Tl>n|~Re6{52EActbfw82EI`x}`%i|!yJaY>T@c;pa+0W;Hc%QA$m57l2zrnHp z?S1yyQTv~84Bg~E;8-G1UmB-xttRsP#I<=POYBXHLLZgTn`s96XX15+WkIF3YeH-0 z<@j@zps(*c_%<_vS=?YHz}AF5g1053haV`HD4U2qtDv`)G=+r`QK|8kxJK~U?y3`9 zwN=TUF;So#(o+IW>2FkZ@UlyEK^SorOXw9y*8m>kg-_6SA^?uC;zSBn{^ymX!iz5) zLQg|mXt@#pk^XW`ca^vHQj+s3=|hKwoxBD}k7IoA`1mU^#^h2!|cwJCD5* zgXI`N6n~#>ug^Idh$iEo1N^w$=1%G0uv|Tok72QtVGygVL~YQIuEkECjLZK#)Z80K`5GifLE~%(?Qk55uYqpso7t7uCd7iUb?qP8R?BG0$Yqqu zjU2aL_(q?a%3N!^BfufMjKQJAe%;+GV$BSkEa$xUDoZnR>E)nf#{M}ZaDHFoX}K{> z<6>!@uMIYV)^|qM8+-j7*M&oi?))!3zrI>91f^C}b7vTEqTvPgP6hj$v@W+G= zvgRc2^E_pZTY6Ftnj{P0SqOFXb8Y$P8V({RGzA$r zQhWIi!XhR6O-}!$XYp4IjP`%ReT4rz82h(_lG-OROAYxGjO_V0rWM~fVmo;OHOhG7BpAy7fcN(zM)~MM_!Er9WwKgajGv9O zPK|fIJ-kkJeQ_t;52c9Mj7m$f*g(FaNaMR%B1S&-t z!m&pW;pMOd65tf=2pCHioo+>ZjXQ;}&-l^WWaJvrsBObLX9Sa2$)826%_-Z-jdOP5 zs&n=*5?`W|e(V-#GkhDyy!`+O5voipvQ2sIb#Ug{EYWf0EXI~FjzzPrVqsxls*z3e zaBwLcM&?8Qi{dkB1&GV58OXJcl1)KfY2&SvD|Xu5G7m+dAEJMBK3_R>(!*^vWdKbv z))4C|@ts=1R7zwu=pE{Kh2WOV!se zANK31cs8y)Mf~-Zr6)3{o2_Eq!2Bf^W%zC@p0jdM^0e}?9RuGhVB#oUATgI(cZ2Z9 zFQbR}i%?#PhntuZ)~MMxYX`1LULz~!E59w`J|Lvk6pryRLVUsG%(f=Tp#w@hAhMw% zuJRAM$Rv+I_GPqn^r&^Z_Lk#!Kb*F;`iIa>3J2A~xKc?&Z85YQr44nLzplRbU_(bV zzq?atZ1V?Z)N6yH!>g|v!zq{5Ye@%GsDFb^yf6MNh&9>)V7}+an$VG~H7HY3DO1vc z-u#O2HUya1_{AC{e}hDXKa!0u>gbDfobz5uas)T`gPcrSW*0Wf>^*(=KQ?Y7b(dCT zT-O*?SuK-fqeiPTZ;4Fb&mBQ-()xLSoG!j}0z#UwMvH^<8Cu9Qnda&1$8tfeF@KWJ zQOn0wZj{#(b_M!oxsM0w&a-XsM{Chpvl`p)ln04FD2^p?VS+i2=fy*Pf3+oMW#qt) zL;M8y+7X+G1tXz6#A^#^&Rx`fY$zQoVI# zRt`*Lf#9$QMG!6|(merlb zsiMHgYmUu4s48L^4Tm@wO`>N8TfYM7v@MbJj`{H)I8Cv{R;k5iwX$+D=byFEY$lxH znMbg7@>3VtH=%C+-v^->Qw(O$}5k_e`+`Q3l5BgPz*vPcY7fPj2EKdBh!%) z$oOQLS`69eVFA?ime2({dq}M;4m;3KpVmN0SdslwJgkonldX^Pf&yEcV3|aA#u&KF z`^L+rq1n=yZ zF#MiCtd>+&5(9<5vvn0Ml3iG4;Z!`}Q+i%Ir^t`ZL`M7R02JT%sZdHwqp0;!H4R;N zvJmA`&*KTvPMmf6NbHZrKpBvAF zY^0gRgBG8@+r`xn;4jr@b)R*g?ii+-qZcl2ZEsGS!l^c7G?I^epgQ;h*>AZ8H$!n4 z3!^1V#w_?iPb3yOr77w;?kS%W{g-$xhsZ?G^p4SxJCe1+FbNrIQP>pf)EURfm*Z|c zJ{QBxx!9%l>A!&Ae<^bGXNe%KK1Xu@b0o?CS-=rV?4YL2x<0v8reH!pL z5>eS1D7nH6GU&E+*D$B1B6A`?q^x$cPp3Cgz0+xsG5pIXbx+oE`s!1TV}3Egz2!Vx zeHo+hWr>TqCg@X)bMWgxaVRd@#IF+fpd^)PpOS<1=HVP@D=)Wi;(c-l~u{C8YVBv;W!a0Jsb8pO)P z>sOp96aal|Qy0 z0+oU%D>TF*lWvwn&S?KKbpzhIRT`nA8k_sk9Rt;4IE&Atr-ZJG)`;ED%g&4if>Klb zM*1VZZGK(UCFMUvI8y%-;Y=axP<)DTg8wDLLHiWpFyhI#SDFl@XjvMYan#fei&c2{ zw@GB`f2&?#G@nj3(tUS3&m`t@ff#9?R|>pqA{@9iIdyM&qa!12$F-#OryF9iP_ ztp8$$@SkUuEKOKl;1c$Spj%2>7QV}ddtQmCQi>QkY7bTTD7XW9f17(`2^Zbh3I<+h z?25%qIh(nnl+KK7%Y@C4I;b$*0=a57smoXCwY%S!95!>Htqczac)D`>x75qdOP|a3 z7;J){7rmrk7Fh=1IX5e}`CAl(s{#NgN6bA!LKcQE{WMc~xPGU1oK;X=flTU3ISnJS|Fvz*u~P~=xQb?#S2$*26t z0JyTuzbRziT7$%p?33@bsK03nM0z|eKG+dX{>+sX36r38KT)}Jj<17D%4tVlfj5TJVX8bfTIC)jn>cDMA&Pz2jOi#g!_3b-jNeS8 zsUJH%!3mhl*po6DGBhZTvzp^Y3yiZytlR*U`%g&RnRuEBD?F%*e@3vbsOad2TI8-o z{t4svq?y!$^}PUXT3pq`c1W%vT$`?!nP{d+sP5z7sr(Ls(ClAA2a8Hu2qQfh3x4MnLRMlHN{A3tv1HIG-l4m-Y) z0ib&%m1YeWc64U5kY+bLT){Ba!2CtgO%<`i6xSrIsk>>xop?{hpV0CkXKuMuomxgV zMbE)$yjz~Z=-fg~2`r}82zr^(XElq1GyN>js6g<5w zl`P_eO=`!@i95LXM33tD_qvUPDQ>-il~`yutgq@3J@7u?#6B&EJ869VbK<2EnAK-O za!Mt4j%Wai5~<8(sAhgMCLq;ye}mWzaimNTBXwdOAmSXz*eF}iCe%8jQAo1vy&}ce zvTjiJgzhV+M>Ax^N3He-)s=L`i{3RvI4F7aT(TSUgeLgHDsm0tmej`~#6hLjB{25_%pRV_HF5=2fP#*OO`sfbe1_-hzY!J>Y3w_U_dB7}cQxfws3TLN5 z1QwO3JJc|VMTLt5Q7@JqQ^`6cg!g^kmQHgIyp42vedRtdpy8<&Zb~11uOx9rucJZN z%QRhG7B1X$UD)Zf*CW8OBCZWgBjaLCbsJTM9*Xf_^(OVK5OB70{e3-}0cga*VX41C z@Z`C_;Q{uAQd#m)f9-e^SDLv-U(6r!{1fFL40j1GFt&egC__`2L$v3Q;??glGMKGI zu=X^<@oH+$#8brWXast{dr#b%&qBFiHI`=p{B7mIQ6@0UK<_<3G8QD@3Hvs3l zzP%%wJMJOl5Hn*?2kjTpDG9Jcwxl?NL(XeFmnG>+nWZeIrWXOV3yIeF$P9vf9 z@hRX98)3`Jkh?uL6zofAwZ#Br;%t>swxbSyOA|?>EK1O`=AE&7`xz2F-0L<*nH_vD zN`liIsU7AqGPGoh#6H~Xj8F&+;&`b`Yidn>WdF783$_NyN3<;jh6KAzyln*L0>%p& z3A;Q%?BhK{^!4rb2?q36)g5IB<>eQTbo&6ra|{avMy z(ljU15Dl2ycZlpUd#4v_lVds0GDP&3YvI>S94>zDkjgv1{X<}0c>FCDT%|Yi>_k-s$+t|RNQu8`?&0(vW%u2D?%KAoxI6mx%WBMKSSq4drjo>vKcq@D8iw63V9vOQ~ z2Vd&Sqwae;<{JTp<|0Wh^0-b}5(hZK!QN0;M_SJO)Vp*6z=wEDWtGA21LusxLn{mZ z5__$R=({!3QiTsE4}|U^=}lZ-7%ge*q?#IT?jNkCUJ6SFVQ(CCq^?f_QYpx z+V>j0>TVcetJM<5wdjc-#l{zPX(x>)VGhMZl<-hbQO0@aP|IrkvQWtRNUe<`Y6DXp zvGO|{6Ln$264xP-B8(A#^r1H4npqPH%Zfn$Xim}>$>$e+rs+t3N{sW2enAIq+qg$f zwDbIsbcrSgc41-9lEv|H{)|B#v?l$-&~7^^RHk{2jM2g*z!u^}lC8t2i&(Fri&(wA zw9`-`p)WcA4y&rbIb8Uoft5$)wkZBa5@NRysuF!olq1}kDLtcy^^SY;DeL|Tc7XyPw`h*c%nJz!4r z({mJYVUca&!Gt+buMmxJ!A=${A-qLG#pety>gn5qS9#rhe<@c18XLCcXbAFV94WK5 zWT@Sv{qL~|y63QZSl#_6dQ@D?cqZ=7-9r$z!rV~kh;7?z(LO2Rp7Tch*X6x5G(m-l zre5c8IpRRuGwR)s-Ta&h-+WiYgkSgIi?A*F-8f+75W3`slHG;i$vm;GfAj`K&QI&U z*X4ljJb^j+f)@9huJ>7IC$H{zUH`peO}a4l$oUD_7ynO8lmC7G`p?*;`VChjElBN3 z)o+SQuYQTBXoOZU!SmA~#rX7>?m1`PCYjX@b64rBg(q|4yW%JRLFm1D!BKY$9U)^g z&a^Zyiwo|f?6gnLybtshkQ^7|Lc7JHuiJ;eV#WPe2nK=HFbi3>$M51M?54U#6W`v5 z&!%fQ0y%$|N|kzymERdq?-Jn>vz2BMx?M;I&vE4SKsTXhp199?;&z~?W()O(-0q$# zp#qZOlD+lLnNL2w>@)}6Ee7NK}^4bqytA2Mg@k zJ#PXMpRXmUXaEv1pYnq62BHk9^sam7s>UO6mZxD%%?8}Ogd{I6;TI5A0V>cWs$N1R zc(O+6JRP}ZFVfC|&ao0bJv;6e6B=r68AHsJ_ucYx0}e{p?c;?Ck3Fg458? z&H=HyGxz;ce^TY4)kg^lG|R_*^eRkH*(*Br*XOqwg+FHysk%-UXt16GFRWMU?O$$U z^ZEP#K%aRVS#q2Bg1zEprR6aTSr~SNh@7hR-%;!KSqB=MAl~ljqEe^2hv2qICE+X- zu7J233D#b~_5ii#y*C=E7NG{+21^Hn(944oWKjwza=xmr@(IE?%?$u;dLpzQg*{i% zPD!nVhve_N(TbObK)g^e^TRrVc3gwj&f{tYe2f<&l!0&dYtK$;&*yH5?-nCDbV24bA36g+3=hZZ-Iz3W+&|-Y=vk@v|^VR~^8+_bDY1{zY_o*^e-yOd+ zEQ*6d*%$F_syS{P=Z>oPwOYn_M1*yw-r?0hosCF3M=Z>?tOjc|rTo*_)9;6^8W<;nh1!u<4GE-=*HB3BrZu zS6P$6vUtu2Zeb@rmyO$!*aO=T3x+=CL#(vD?#X`o|Gv~zv5L%5evbOX=Th^3CSm@Y zm!y3D$?pL2t2F6ptAECoK=dQc`w|V1q-&Z;TEUXTzf)NPK7!4@Yc$tST+r>Bq*DA?DQ$k00OgjP!LJqOSI9B5?vWi~{$V5{CrPw=_vuE& zc3npn9cK&G`Q++G_M%Fy6^+?OJ#;` zMQ{dxE8>3y8r^&gv`RT?HZ-2?PY^@hhgF!Qi-#oNdqFU^}m+>KlbwykzqG zsnT`EugfjsuTiqdAN(i6rOs!?oM{iXi{C7D>Jt4-sfCB3_+7H-MTTkF@67Dz9D&So zB069b9qF_N`X;E_rGdiMkuJbZHvvW`wml$fzz zezVXr%Z8irAo?fMs4d^CmLlkf15jH=t~u-PKcqLDF2p%kpR?@$!A0!ftBmUZa1m>j zY=sq8@>3>T&X>m~aOVYuaA>1aaEsaFDGfn?M6-sn3Os%FzJm_)QidwgrC`nA_j;bZ z@XdI8f4#;01=|5f>JROLCCV2`3<#MCMZqs%-yhiA*)hUDVJg})!rDt!43UtQrC}}< z6&`Ac9s=Z9uGJo(1uts8ux}k+lepbnSKL`s<}k@QRk|e!$+U6zOs6*C*Xpk7Vs642 zFvb^hpKEutjPmPb#YCplEeiw~@kC$6i(S0>M`u|sjSbOzrPvL&MlvSi$*fit#CnNS zpaIy|IyCd0%~=AQTZT9jY)9fC_F=Vd@P6J8cMT%1IQpc~!JNujNobNw9hNRXV~#tG zpe0oSn&IZ9)E%i&wH9(~9ca??&QYmo<~71B@@W(;H3@!twMo(NjVY#(0_H}-KJ(8S zO~U989$k>X?o{a9(A6526LVxu;;W7$n~v6oS1F8}2GX@~2d|p#jESWO`BAW)x?34S z)$<$z$e8Vjf*}2Qb{3pL!|+qxtaPXcn2XhYY&C&LP_x5Wn(DhjsElz&-@775K8$EB zrqy<66C@`0q;wPY4#|NP!%Wvdtu*EsY-mJ83WLP;Kua@|szaVBRAb7x1-a%<+zL+( zNgK5?lDaGlwro_RhpvUGv^Lf-F~r)x*K0SP!qRA3JfqWurr@<0BqP$w!pmH&enF0g zq83O;P5@>o@sx5H%Z`aTghHLQg+d9sk5BC`L#<}g2@SXWOEBVz!@<|Y6Ym7R(;zO{ zlXf#thi8-^HgL?2Zn`?>2H(j$qXf6@AZ0DY6ijnYcg2)_RXv5Cj-JN|eE1#xC;YyztJ~GObc6T?SC#t^?S(DC1t4g4zh4&34we~4|64l&KUTpSReS33z zmruLkhvzkFH+lj4Vdh{lYFah+$Fsttax?Vfo zK6DI^~bn;lLTeqqIVAbKNhNtWO`vRtmDxdWwUHJqq^zu7?uY#G&azVgBZmKkDqV^YE zVR-5jTru^>)ADT@$T2u5;JVTk`ht}pUs;`Y9d7ib1;PvrK6Ir%GV;huWMM{=%!b;& z{sM!_1tt}eld1v&zq>-8o}yny_*?;w`gLQl*OPdkk9WDBkz7D@9aOXn0=kfz5tVzc z(bppXbSU_yU>*W#u8%E8nw_(}eFy|G|F1Y-;a&Z~rOyhEyi?4HpCo%(VBN2==3JHy756&bTd?Ye9pI08Hmrec*_jPZr!QR;4z5Z?04fZ#5FKO$(+83$dAv2-MbkxoCZmapNW>S4 z+C~+_62s1!Z70^Hu*Jw*(iD{yO7T~MG>P}8g&X{POOw_8!x2k>4B8QHRU3wC7;q1t zrPUR?M8(#FcF?us)aDTWt~Psu8YkEA@edENWEJk?9Fi|zcx1nPA^d0i;=d+A{%8N9 zkq6dYbz$LXol}Q`kx2@MDbVcK7h?Df5Yo>g}C+fjgXii36qeI45 zyQ3Xm_ld0a)${)LE8C0BKAE5W0|d~Ur`@mao{X1mImJK6H=tYGV~2cuOvcv@QgO zjsz~dG4Y;AI1P4p%^37|cGY;_$6?u@a4@T#uALY*w|C_jHtY5nGpD=xzIPk2^f$LY zp<{L(?sDqBB}Z&ITzfHW?hO32-z98yhuUh~^8%du@V<}u{d5(EcT(s>!U{lYy+^Tg zkBW%#STbUnS3UjY|ikCW%5s{_8IE0kFTJE`1V% zI$oD9C}d;qW~j@w4@wkcj@34f6#*Qso+FE3X>zy)^Yk-?U|k@S;`Qz!lV;VZMLLGg zAjb9m806~L48b}=Zgpkegmq(SVT+Ssp}j$=NIKtKWb)TPc*M=Sakb;JR&@JrV-bRK z!=7Rjj#Zc!>!zY-LAH!t8WRYxgBBiiBn3V|zKP3tbQ4Cq|3z6%XsXF~_8pjIUqEk! z$H5>mM2`|XO7U9bMTpv5`Vu(bHgMo_OS?F)QCYD~EGk*8tFE)DsHU-IB22`UlA_Ld zO5lK8RCs&DSXojX6QzZ$%U)Pq5vQ|27DXs1zgKAXBUzoUq|A<}Bma~iCT)y1d2!AX z{nk>$#z(}`MucDZ<7BQAw*!rr^!Gy;`=%kYuykkj!ex|mFd%S%-@+ne!?n80#n?xO z2uNLp7^qFj@yg-BOW{e{0BW5$;yfYU42_%1&>bL$*!_H5}>vVb&`zOEa z4iR3CL$mi8^>oFCiyNV}!z7b74<(5jV2#{+^Cey0_-ty@aFL^X{`Chc?)(i1g$dUL}L<(0F!cXzHg2Y@Mr-!xFgtq z(kMvhw{5IA$>@(u4mM+BUah84EpJh%>t8WcKFxUW5G_$@Jqu9%^?*dOW9M>*XmieL z*ahi}vT!j5NCtj2$mOG<-v$SBXYiP{%MgD407coNlG`lf)kX-nry7P-qnN*ALf;w) z@HI|cHDM9EI7^=)TS;gElSAg#04^L7`;C^OV+R`n>0j~j!36oi?Yumk!&Esa{|<36 zcigFy)^Y6|%t?Xv_1ILFR3KY=JA>(3)c}JRQHY>18-8{%^@}G^8wvlq*Ln9knandx zZ+L?gj!kL!98>ao5?`8B@#5nwYub)!K@-Qqw4A1rTp~@ol3D*!^;u~Qi>I!LC0UVF zZ`=5KznSu}>=pC*050Z(s-c#%xd-aT{`Oj0+mN{2E?w^zVT!P5qlR*?GHiwFq)wom zv^A!Gd-cHph0_??9oe;6+{kD@w#)^|Z}(teEreb=3d`i@qyljR0)2{L*1%5}QSjp5 zC38ED&=#CdpTQQE7;&az*z1Sjqkn5=&7ArwB=62SHosHq+dY`V*Ch)GnO$}U;% z!vad~s=}YCz4{u#KBP&3ROd4VDl?&fjAxV7T+nedqKHgnU-2e3bDLK%i4Qe1WarH@ zw_DIWY{vOmGVo7=W!`BA*xviKxyBJ(eyO=i>I4Hw^voVd0^&2RY52-@!xiJa{7vF> zH&XKc5UM07Wq%eP7M|BQ2jYxHFCO*i7M8JKQLwGNcg#x#6SCaFVIm_VQnR1lEK8af zrHW@n^^TNb#xECNIR?`UITjMNN9^;BY&-E9Z4F~GUe(M6m%Nc&O$uh#MWGzbMLq8h{aWU<%rX{a~ZmTl~0J)h?cN?^fPEq<`nK02aPfC;l8DXC=L#?A&6 zQgcLDpj-swc#lK~+e^T)quMo3qUo}QmC}fc)HRig4H)$yDAv=4>h!L$Id#(<;K9Lv zz8PJFVHce*Hn}fVlrHP4-#eSu8Df4%kiO|vQqqhWA6O#@6JpsJQdD<-7GMl3Gt!Em zlaVMS`296;QzXF)2}KCMK4UG3^|%7bIXFE^n(&r`|J_n{8!ha$QJhQJq=pe|amVia z%m5k4aBAZyLl_I);_up8l)9alWl0h+R}htyaz_mVMs)b`8tO-+q)smR`W6KRZ$D5k zGf;>}N_Js@O$pSuXP>wy%({K;>>Ny*P0^>an+EDDuwP~l+nD~26g>A3II9Usue&-SxG8C*nG;pO4w+tmF2=X!LR#b}IANg5(p!9@wm z%S8#%3#CXjs!>9lRiWLxH#_-D<6kiU?I^Zp5819IQg%&E%v-S_M8ck-wvv8o*V=EG z@M1}bKl4!LB~_=xIRZzdq_ANj_;wH}#K^WS;TxJ$6GoHe<)s%lz4k%t1rcJqrh2M# zQQOs#$_PBpN-|jCJOr1|fnyIWD!DbTd0&pAZopW)y^}VXaRAZ-BZLx=>^d=#3Y#NN{1Ha z75NUw`W^Kukc(>|Qck!s+%W#SO+s|dsOqtWC$(t?d9-QL%P$OA@-=B!5GTCwGsH10 z8W$mU87jZUNBnnCqD0(rToXZ|$7aI7KTB2>A1xCLoD0>b5z59cMxvlMCpD4wV*`cN z6_d>#X(*SNK&qeQ2Q5cN#A@0Z^346jKaEnym&grJmClfPD&g4(vXR}mcb%zj0qT&O&pmDNG=SIX#Ti=H<6TcO83A^jJ9jvKViUdMuApy9<$W@H^Sg01#bHT6ez`v@PHd{0cA_MF z)hsIgdf2%)`Am>D1n5rbA@OQq;z8N+t{r@5ZSLt@(fJu<<0D2>Sa5w%k}2bRQSKNi zGT-X4yd1ug3XI>K@zG~N5w1>R#VcEedwNFDzMX=bg6Z(F458~;AQvKdNZbU0l<$Xt z@v_7ojzbNRkFiYdkyLJc#BI6}47U4D8uRhnWz_z=2wtj=(mOU#HQ8z=+Vc0YI)4Z; ziLGXTM3N|D{Tyi-021CItaPX%`#No2Ezs?SO|R`b?eY4V7jI7FoNm zl8#7ia~1HNJ54y%Y;&A{jEk4iect%_?L5)Kn8A9d(FRx`D8oMbvGk47$;DzNM zsU6ITF^4eSc@nWRg{vlW5idOhDtziW>v}OQBV@uY4jvv#xo~3rc#5OUG{;Ad0-aeb zLRQ`vDovIee7&okSvv~|U@gJtM^UbqpU~tGX8LMXL;|(vSA--4gj5btJg!ahfUngc0aPe++y?SarCu%6< zPZ#X+i)Vz!<5ed7h~$pQTbTBzD7X8j^c-wbB zt33aWNJy#cUz^$g%k2IwiP)h~u8f)#Oyaq)u;ub?27JcfkI3S0S$r`JcbDcRusM7U)r5P%oAUdv zYxKj=kk1Hl1WA29+UXy#hF3CB1xPk8q7h3jt1^>)ZaGTvu@6mzUu0FlVK5KjS} z5LQZx8XDf2u!YSX2}F!HzV_k;EE=h)6(P=<)>(?9Cfo+lRqLIZeY}CSyDMS}qP#kv zfl9zgbpuV6QP2;wdM419s!ebJj2#99)B5aH4j|4Ul;fdz6h zk6Qhcc^`kDQ06PWpiur&T;>avUC0xlVvafp50P_YI zCwGxWyRvtkIw*Ob;g^&$U!8?6<@;*2zqo22>I+>7joY%(6;R|R>|MC_~Oo~S%P%su_@I3#(-P})^5~| zQp4tW#H8srZ0AK1eVre&en{D`0nlBlO^F3<2HQ`*JA)Vi&!2r=9T!&0N_i9GS2!9hCM zALxsd8Jv!%qwOQ?EBBl{+IQ($?s)TOz*>@9rOs7pCB(byADW$G02T9R?dSSuJJ9p* z%^g2y3r#)J#HXL%$=Z?hGKWcz!w*U}sD$&3V!*e@R{rc+a-P(L>FWyX!0=!FvZXjy zCK)#LhpC+GfSK3(N5~}>c*0E!$$_Ny7^O{E{HzT(jzV@j`i5hgt?lW|6RpJ2piFuj zJwz`2O=zqX%u$n-QW;93Kqxzya;bNA=sk(lzD%shOL3_F3*Rj70?2YPHXAnHHY`?P zMYau;p{QdVxQa?TpNy5)q?iIbKo3e5Yl3K$6-Iz`$V99kKpTC7jz#cC+oovU*7fhL zp?wP1Iz+#npv6#Bc)#@TWk{{@*8s3+$g*k3=4ZcEtDmLNoH~tAm#g}wfu6+U>N3Q` zZ3U>gf9Ki~lrT_np%4!7{+9SOrT>PI3Ga-jX`G zfvr2fZ>&y0@XOeC;&2hqHp!kjrTw-=ZBz`~z=ValJX1)L+!0rAy%)aqlnt@Rdv$uE zKgk)FXjUieNnUyQs}#1y-0edw3xP;DR}9FGgnFygIV7#2AkbDzzF9}|v7A_}CQ-A_ zU;o|8^TLK~!lIj5nQN;@o#g9WpUEco;1#?Hpi7ZCQ_BkS4XOz|A0I#54XyOhOug6Z zFvg_8!U~-Jq^MD?M_g8_%|m_HoPWgp8)Z@11bIXH!E|zpT;ip9dxFFaq`GX(Ttw6I zf;B|ixqvawgN(oA^=}AKC^9jSAbwmZjFhYc2}uAfid#--2OW}ckm7mN@R?{hKrUPo z%ABI$^InuMaCmq>R5B)znuUm3?2t^EjFOr}YlNDG5+zPo*wmX4h+RT;qpcjCNTv)T zq9zH5R{@cs#7PAX?+Z}fq)VZ=^HY;Jp`oOu1LJx|P|`9&hXKN-=7gagRhoN)#8Sv< zDZ}Y2QYa%7D^k7b9$}&5q@vQV-vT?81)e5AKx#SA@Ea%;AR`|)D=LbCN}}dNbW>zJ zp(BMtEh7rzp(g1|AXQEd9X1v=mHK>Wp(ZOysc=SesHBLp&GF~2Bffh$2+EMyEg{RG zq=XwNmEw#ka$EPhP3)i81l{2Vm?T(dr?;#~dfsr&g3U~jG_@Z41y1}1QN|2)U>&hp zHd%X+4iiv1PAX)=-}P`u|4MawmO@F(4wa=Q$nsci8w}#G=OR! zDJs`Z+nAk8VzzSz1MMxg;F|R-j&uuK&MDi2%d19@V>oqN-XG`P-)(BtqIS&e zg|tY%Wge`tB;2=v@(R197-g4IVFyN1$?;1vGi z)+^Qm&oHE8$h!&4OYPnH^|8CN1@mF&t3C2#584wGUyR-rlmHyzHp)ow1PkJ`COKRQ zIlfUMy=!1l<^h`@g-#|_%`iqWT*baiCshKZtEMnBTrnmdGEqsUp`>3#ZjkJ}zVZ73 zaK>IWUV3I64tw#>Ea541^81is1ODs_et;3(bb>=TYPp$oWvg5qjrX|H1bzM!M7X|! ze%#hy)mLWECm`3gC03wdR030TJQHQyOb5=c)C?&dw=*_07=9J#e^1D z1ie%34%Y{i5=+okOH;6qhq0@k-yr0WXxXHyOL7@>X*z_NgCCq@n_(CnC(9E82M4LQPD{Pnd9cl} z)jClWw{S*WtAUwwmC6_KEScYMCjYAwD)iw1f})$)XtW+vCQh+^fJm;m?eV;3TAfL; zwvMTjPQCSEwAu`Qc>%9{)CCv4ZAg~uvZaJy#^y9mTUMB!;BQk0%nEji!;(1e+Bgp@ zQ+-VLFbu7xET;5VS|G2yl1o9E#tlikc7rrRmHK^YnLyMDzhU`+Pk+&&51E(;iGghH zk(I#OqaoDDPRC}JGyaDYeDcAsDJEph69HNzPYOd{51v)pMr*i-TD=u6afKK1NKkak>7~}h&L4jx&RK;s`(V*4s6&5hJLfpjD6A}6?T^mNx zJcL4s!;;}SsYp(m^o+Y6na+^ckQ-dPV9gOZc%lx2z(q$2*Pd8~jHPjYHq{6(&_$u8 za=$`8uZ|bdO@|x#^Mjgl;LOd^XG0%pCVO1y@D5tAIp;tSvqlr6U+y=S#FQbHnD2wA z1r&uJDALILLFS`a^-Gsvmgg#ELIrEW`D(1mm@$ zJQ%j#n08voAOG5hEHs}D*mSybXB{{#*hke7mRk+OrhVVmC5)7|k-Gb3+-i_bZ6}A5 z-6rvsOF~gR6(B!Lat=%$<&*da@WdoerR!Cs<1wmQhCw%GwT!4IBAN`Q-{s>n(EYL$ z2EXI$fPx21={7OWAp0Ynz!Y!rc65y;MVJz38cz`4zc4(|0*zS$u_OMRTxl;bo@}=w zuG|$1j+M|DGlwHDmL(gCRB4_!MVVGT;)Xy))P^bQROZdeGGRmh{A@j7{FfvGPcdPQQXsHYCCG3Y$;xgD?|2>n~Is|LKd@7@DFpWcb$EGS8f(MwZIIUWE z3*mU21_l$NW1@CCSei6F_w!MJw5vcKHp}ysuYlCIHL%pu6D{5LKj?J}`*{KOpDu{j zKCqakzO4WJx6~~LHsst&=%3f?)Et1WM zX+bqwc_%8W8zm=6eQiq>%J)`kIb|nvZ3%Wq-7qBAw)Uaz-Q*V(uxlcod-_<67Xe|$ zjIwBt3825JcS8Z9Cpq1ywK2{^zE|NP-IR#G44iu?9AG9S&?X6|}+Yo%PEs|mdhur#Kw#}kjkuWw$mV&NI-+s8Y1 zfmG-z)fbMSC(AjrbZ%hwLe6eIYnY?+jMLJrKzfF8gUz#Vz z-`j0A;(4WcVyffz-!q+8abxK>{r?wZ?-(9=w{7uuN1cwHif!9AJGRlWZC7mDwr$%^ zCmq}DB%QwXZanww_qpfn`@O!^{I50Fm}8FL{GJUZQZMo8Z@MYu-dL}sdm|<7sv`E} zn$eurJejC?-zKO+!?cGSI)zW*2lq__XpZD-IL8w05UCY!Xin^jXU@X)ia2|Y);%rx z^UMbuz+fabZ%oYIV&iV-M=XUPFy{s^)&*NIY-=#b_O=%4zLsr&HadhGNw6;U&w3@F z?DAZ!l`3=*fV^95#*^$&d|PZ%>{Wxh?G1)FS9fDTpEj#HYffONPQz{iSLk{J;?4?A z=nI?;+io8L1ahi`@_h=rsMiX^aay>oJiT*3uJX?xSQo@Ol?JiyrEo^2_$7o;_#t`z zzc0|QlMHR*@5tS@sO6@Yx(4mVw<^|oh*lp&B^<9rxe7attwi`%xj8lU=G{tk_psDl zibO+JSc*@HKXt}^qg)R`1zhTK#@ud&;IpWmnSAAkz*&3r!xzU7V8LNw*TN5#i(Y`U ztr39GMi|t@rOy2wJTz;=C#y9u6gO*P_qIi>opM=#+;i$Wa~yYx?b`Zo#ObnT$jQ)= zV#}1dCtETr_!gs0I9jyx%i;bmQ82!I>#1+uBigaL-`S4!vrKEKyMs`UG(nu+HaNv> zLwegf!e9v8HF3E1?R{`ah=8cZiwK0%yj<9fIdLtim`aQ+4%pd3CxhYlyADW6dn=QCTz7d0!A zT@|`7>=wobnd}(!%XdGq=?H38$@}PSh_`Cw0y~?>?Q_{7@s~=3Qq>?Nn$vF*)Zhrr z8+Tc35WI`0gY24=oUIPiN>%q*Z9dPeRM-c!K&M(!>{C}`e_K4OlhY2wX)10Ls~nAN zhU!w`fpjyEcJFM#zMfzC$v+*7Xk~Tle>8mD^xa$4s-J@ud}n3jMt3^YZ(-w>r5e0< zeg-KoB)CK@Sos|7bOVY>Ym<`PN_zdIU2p_4Vve%t0zS=ngVMA`3u~l16d7m^)Ga82ezCE-#|_Yj}+Y(upDyK z?-I9CIwaje48>3`as-LFuXM7_ao@BfU3K2*rX*yx9U8>meU=6YB32x?>K_5J1$R4) zH&%7Ak~r)@v~0{wN%VSUMG>IC>U*-pPZ*f?+A2d-xIR@at+9YIyQVgq5L+2c zozXY<`J8?A`5L?*L?U0!hMQ>m@^v9CHkRYcbzw!CE`QF~1Tk$OB-G;=6u6SeF53nc zZ_t}B*#et4s_o-lamLs1m~f6hizuy2LwU9rF^8h6PxyZ}sni5IBD6tk&k^nw#TUFt zzFw!-X?|P`6!2GT^#;lE=nz#usgI5=;RW4_o;TGR;?@+RSM|1bWW77ln94O2$CO5z z=*+4Qa+28h$lSy>8fmI7=D?4&^%qeKXp*DPQ_Op3p_}nTrR@oa=HwjA;h-Y^kPNMf zrHj(skIFO(osn|g1G-yP$)ml?ut1i@e5#+Hnpv}m2FtKu&d55^9T)W6N6}laH>cL_ zfiF6LNz^$$8ak)V5BUuD)GJ#J3g7ww*&Sqg;I_KU5{ba?2aPqAU0TvhK z#{PmE9cJ<(YSXB%G`;VQ_)i+Accbw^>`*`xRb1<+V8bP|(2NY!LLx*_qMznkO+3gx;91|%1O{hS&vWZn)Rzj_gwuQs!)Ktq zs*+KxPU}->R-|K_nT@Jt-{1cp{y9+r#RmoQNc;U?;qi?BG`57O{FS+a_5lT>z2S%q z(JqY~3?`f}rWmP1m;aRmJaBBWF1sc2(zcOj@^JY@-+Kfg$vW=_N?e(uCnBS2Lu7H9 z@do97W^pz8eSEw?8emisu^M)Lc5%lTfFNH+T4gLgvN)74Q&k#_0v-!>K@$aQ!ZWaS z8I|R-$coIckQ$wjz6Xy+ZcUB471^ZyQXzM0vt9qyW}9`dlr{OH<}o}GVPku*Wa%7B zlqU;30%6TFZBHfFI^WAAb5>krj)(bdaVC0Pp-lSi0mN8~Xiw7dJTZigjap&(Ln;yZ zSHYON+@N)krm6yzo@0DDIK-j!IbEtJc+IsQekQz5AETgiNR%#;cC_-rh^E$4(gUr4Zm_?K9#UK%ndzrJ;+qr29aXP3-6E2aQVspn(%$g-ayinZ){ zzBSpX4k+6TpXRc=|LU;M$MKaIv}$mpU0oTj$Y&q){Y;LIs4KRy6qzE@+Dc^7Kfw&a z5kj&`D)7lws=Dx=s_!57X}He;RzA13qFKaV=T?H!-D$I{yeZ%aId7MaP>bUF97Y2i^FkN2W|)xBlN$|T_uv^TeasHzKFo)_RC($ZJCiTw@-coA z(`1&%%NMoaS}ji~k8dUxKB>@Geq#4W-+~de22s_+-g^{;n8FNMk-8IZ0odd2p@f@a z9GioLJbjFtqtI{u5N{{rnfauFq;;mNwyONodCj#e?Z7$n?v9Mn$!PlcLN2CkzB( zupTdahLr{6eC-?BY<)41^}Ydrr`+EL?_Yss8PD+1^{RC`8l44Ead-#B@u98`>Y_T~ zo&i@ZN$^#GGH4S}kYr^p%vACw!(n|B-|6#341#X2x?Qu@7aH9FXA({&u#lGX%5Hc2 zvH>5D6Gf8_JkEvli8<3hv}r3o94L(1J+%WsibBcw&NYOAoXa-5gT9oF?HC`4&Vbpe z$;{>s!6OY%rsl{LL0?J{E^~AyrY*T^5ZgBz!1?{G8Pbon85e>#@<|%bjDSx$ z(E@h!k$ssbiAPCdct`hbdFyxc`fihq!-*c(lym4Ar@l|x4Qa>Uhg2LrIiHkU_NaL3 zA`Dp0JZ?vN%Aah>8?%*-=^{sX`Wof-GZTyWtt&h7)DTSE6mvdEv8W*E>|RICvD}p} zEU+;c*-J~0+b?4(l1kxPr0%Uu8Rg`kSKk;rnvfB1Flv7Fd&J2CKpoaQ8`B~3>TdEq zrj6%3uc6pRLtk6un|`>+tn-B06;&oqcqV(d;)v!vfr&wc4QasMh*dSIrw$vdv%?M8 zN6T^+JV)y+fg!7f&)a8K!Re?_=SPigOSnHD)6GkAk2+);r}y(uVmegCbzu(W)2#9> zZf$=jxXNsECKfw<71dX5EvkV;2VU7=okkNbAV4vRM!!nu6*W~&%=J^R;G)&f+)njF ztpLOsXh(#;o+IQKFA7e=acG099o))Sxy7gjYN!^lY{PMADfBQ@4(KCPI_OgW0PFCj zw_1q&136*)C7luaEM3sleCb`*!~hE~OZwIjFqE;D;n@nHZ_Q=qj%0@d(qIy>4>mi| z51Z_b%>|x3C2iXw*qONoj<~JWS*qRXO(wsbNCM$bj{uYmV?L^jq;Rc@%5x*@f_jeO zrj>NdVQkqX*%~b>8Eu3Yl25{mCYk3u1s>$i!5@5N`-cr#Su8`F7R%o~Y5 zBnfF@issVF+|iS^Gv5|MoYx7S&LNzJX&cNj%BVJop)<^(Tcpz&bW)R@pj1$_Aoh+P z(@rZ@XA3%SBB?Mdw%LQS%~-H-gKbUhnoJr_@=9&mDcpxGZsmOCQo_~rwBjUxWt0++ z#4R=NPIbPO*!t#Na^Eh>B6w9|=KMphra0iwr~f?jdA$!B1VLy1>wiD<|0Asq^m(>m zPEbSA1t!U$H=~)ftEfZ-7YSEUbtOFnv>IYg%8 zay`FhOnbPFr{3QmZ&Ce6HNNE&Ail8G*@w-Lj3kJaqJ# z_q=N1OI4En0IDwb?6XTJOPku2&>JLmgOf2TxPYD)EzillG|$=xSQVZv*2~4$bv{R| z&Wm3mrXtR%d$$sV^2V~tJbecnvQgJtidtHPS65<=8}1$)>q(J#5~|}`A|nLP6kZ#2 zpM=#D<7%BxI)#4I_E5y}KY;1lQol_f)i;R9T`j*Ke&J2kN}&g~6#poax74y!t#AlB zs;sdNsHZ6^oAc-RNOTcO1|Ckf{*Y7JWwW%tUsDUcfJMq|Wscyl=-!gFf;N5{5 zx9)JAggs$zNw)R;0yd6^euBBw`xSK+g)+lE7(G$%Q$H%O6s{7E49EsF(?A~6bAfz- z_`tzP>q6qm=7yZz{N?5L%nIRoK>YY8VeErgTV@Jyz{~=rX`;5J@u}DN!}FvRfyh3) zN*A=nIyGSJ%4=+5L_oMLrU@SGFcJ6?#d-?{H#10RE6Po7OB}Qv29L@Uy*&VJG$i4W z$@&tEI7!${m?Ed72$P2#2} zj$l0Im_9VZBm^@>&LlQ^6o!M=DdNtdlsnQrHd@$5;PJ{NY8BC-MB5tw@i#yE=34B> z0;v8TL7O!HchAA`?`3KbVPa7b@FCnvH)Nj_G$w`I93ov_fF6-0U3u9A$70&{)Ap+s zjzyw;gOEF7ZP(-1E}>Caf<7}x+&^+y)?!lRnRb7!1^#pj`1-sd5JsQs`bo)Bqx3>Z~&2({%5b@@gKtvT4TGn5wgc2=6lb<^`B$ z8|UZunyeeG+Tnh6%OzNrVpegK)f4zxQ~Z=FOm3A@+E2OGV&CX;^S)Y8|?o3@sW!}S8L(oUi`iN-N!3F z$vtFa8mGFTsQ{?A!1isT%VV9B&+U6e3G+>XyU<(2erL&;b_A}qTT7X^7?Wd*rW-G| zs^(aRQ&ql~y89hG+h`vhK(PqEYbYW>wMsOesScH~L&t)+2D- zG-Vp?jDsNtJsKfqib|AO&BrY|SnqxuEGZp?^`b4lZ+CZJZNT_8!>)=-v(2dGq6gIt zq7!N;Dt3#ack%w9t|gC2POF(aLh(E?swj=r!-khkbbSk(g9Y4!!rMu#N#7wijs+`Y zSD-BdZ1Qq%c+(aJKLNo6plpo+`JbYZEb9vaB55~xQHhA?yQr6{!76)Tx(^{efyGz> zH)UERU&#ne_rMfGJo&vv-2!#u5bS@r|qeHPr^dXpF-zp~~r$p<(Qk zwIXQARnCGz=#BL_$@`9Q_}V_lCb9?|C|Oh##NqJQsrg33(UjyW`M&vR2}a0MIEnr(flOyg65= z>%VWUu6g`7HRj?L=7IILN{ieDX-oEW5gT@DgJ>de3tNe$!#W>|;Q@5aSz{glBD|x+ z)mu!~AHWT&G~8re-!x5UPdh8VrZlFHAFsmJ^ro{{>;8qR*1K)E0%{!og{roKLoQ}^ z#-Np;Ri`oCO?6)1Gr%)tydpN%b* zLQ#+-O!mh~j+4nbJawQ-`gScryYey2YP=9xW^0RhKy18ttcrlztN2|fjk$^zyG&Qu z3Pl>-!7uTXCpM|!9Hw5zKq3a4&Y%mhk-avF!+j4hI7GTjhKZ@{%pbL=T;fCw?c^2i z^|*a1)_Rt?7hjwdqcyFLPU+&;&t$`q2d<6cIQhfCTpJ@`sy)Eu!`M0g(t5#9mZM!L{4CGr=oqN98!OQ81o5=3~{ z1;(44nYYR?Y}w1I^ZrG6Us~Gse8zVixB4zUQnzst_9vlf7s9;F&H%~@3dl5IzuPzL z=(=SSPahmA#rF@$dzhcZq2OX%aXMYwmFu$ci^q%OGV0Ps_C9E=5-2*&;`EO2K~>ju z9{y*uN{BsH4sU1Lag$za2?Tk^68}?<59~kkZA@eZvw3JKI1g|=X>yHQ2?S`}O3P7d z$|sOX_?}Id#75^5oWy#IOs3A&CVm(nfIs0oB!p7Mbj^k2+|ZR3VwRq@@Mnob)H*=@ zMk{ZiPpo;0s=wtUll2xmAgq~dEOO{On3}x_yZ`*W!nU-zO+gn9CFsKW z-(nNl{;3i9C|&=B_uz|lIWI#82xR;J6#emuN){;*OPCNzdL-5aiXRDer$a)$p;_il z{AVW9hX@D{BLEP1lfb&Bft6&oNbLM^e+Aldx_^C}jm-t|j8+7(XPu`2C;j(&U41g( z9%w3Q%S@%lIYf^$AiBqh!c^+)3s);Pi0%>b7u{q1Ns8+)x(B_-;@$$cxeXMX!4DCn?nH<|?Blls~}qsj|8$l|RBnga(E*jsG;Lwu?# z36OByHDAL2J#i)lTl#SSS<+}M40gAvnDfcxcim!rQtxK{Xg2q0wIsdR=|OHaVujg2 z{H@WrWcEUnmzD()eg~^T8SK}f8LMUmQRf*~tf*?(e}O&JlhgxUPr_`zVcq}q42K*R zwzd5wPZD+l_7q(8g$?-76LuhvB!$||Xg;~=(83TxBa8}L=S-WSA*?uRg*vIhbPH7p zN22?iY+ctLSk7~zW=&Dv6aUSUS07(@N0wMWuXFrkl?2#>o$9@>6cPP&S`IBW-K^e& zsnN=~&b9d`64e^%}KW$ftkhp>9Xqu9yAX=Uk`++1b1fG|xs6UMcEAR`>SK4YNbnMze|8HZSJ`!CU3 zOwXD)jm$SFn>(0sRk{z33aodS^8HPVprOy+q&?KPT<#HCydGRDsh#Org8=;D3CFQL zCKsHf*ilPhId#xiql_Y_=!zVLtOzq%)WU%Uhf?8%li0_^kO+65pMMLFK5ul3H3U_r z8fdBbe+&6w{dak)X@A2}NBWrYoX&`D|AGxeN*}T`;{P>-R1gdeYa=Q`Yv@6`pksO9UGuWNGJdFl9<3J8YC|oZR0ie2Ik~(NTgsHT*j%z>YfX38 z3*QI+8S5d$N91BWEf6Yjj(9?WAkEhGci2v|y~_%^t%;PL^if1POW6zT!W&9nMKv7u zw_;qyJeD;_$W)%KLo>&C`Hc#S%L3ytG3Aw8Va+?kuOUfVbx~QmdA^#+wkz^X5Ncxm zx(%?lNVTA)PZd_R71y!%PuGz3;h}Kx70UbVSz|Xk#c`t%OmI7`u5@qp=$-+*CJKV) zCcSWa3W;!n!|{MKEX50TG_zylO}_|f*OvU7-!i#tf%{blraQ^h`t7shn)&g1 zaWHmC$0L|A3X7K8Xw}77^r8FlPTEgKoLEv>6&UHNk;fqq;(ET~D&;yY^K~UQ6tlX( zaO`i%T6{V*c3#oldX1s`h=*(jTIiztrOB1nzQ7SQRkN#bnxlx*CVNURu6EOndQ_I| z4Yfg8?C-&ub!2SGtVO35ya8%0ts$v2nv#VSCl;Asba^tofa5n@PHr=-6eKu}Bo7zY z{_ih0;kayZP)%9|909pCuYygTrOJGtyrgL$YZvVBh6R5i=sFV=_@IT@>O3`^v^iRP z!FMa%Qo)N8TqbD>1c_r95TD`vhLZl;v-u-bK|M65@EPkpv)bi@A>Zb83?OM+0Zz6= zftXM5b6{1ZqBi)aGGZCTeB`Hq7yOPp@Qfsa`I7hGxI2~tXFOHkez`++#Git{`i%Wi zPexUV^GJXkNaO}`XsG$Mh>CZsLBAsvOXgv1!&0b25Kuz_;xU}O6xw{cW>EU}Y4|Fq zHTl6K=az``=?@YD63U%g!uQb~X6EY`Wjvyl*Pm<>1LXb82se}mhEcf~5-AmYNyvK( zb$@~DPUN;#yoWd(+R4)Vai_hKZb{?%&J<(?NH)#m8J8g9-^V8)KzIIRr-ZDsv3|^S zuPjP|{yj>X<4R1g)SKc#R)^;-ac>KuUj^Kd`0_! zJ_B4izygzwgr4>F^)*M{XAo0_s$mYiMb5wu3a47 zOM5pq-K3t&n(wlUI#-$|a}9b@jI8L^>QfSQ3ePW_)^59);CW z;uuU$I{Wtm{L+c)uOOMVF;+IWO6r8Ajz-H98PfI=%qnynx2ToL{_0iHR0BpP;?03A zEP0gkf_U!?6)?#ehxcw0kwrt1aPVXUj6V|Aiin*uQ#8~+1ivsLF?iFZzqz^{zm(ttx~jz#F{MT*Vb(+wOc+?vLuJ}fb^+$Ys!QTpI2P!3JlqNr zo)jI>B0p|2pU&*|9-f{r3%(;Zzv4H6KN1^l>Ps|#=q^z-<3*F5O%{zl+uNR|O>_3U zen4m!HsGnvea$}b${PJLaYwT7A`1Q+OXel8buFA4r~Kf#mif!7JzvFEKl~OcX|c;b zhUzBJV=JbjTLhF6qD1Od@a8#>xvrA?Mj`{2Y7wohk_d+X?frjbJBx#|osUOrVnRS) zP2_*oH`xAttt;y)FDfAMr8dD2L5C=z{kj8ZgDiRppcI3kfir~RRtA32%aUnpif2D1 zYJK`zK)?^QPS_A2ABEEv=>>H)$Q(Bq z93Um@virHj<-lc%u?RzFHaQY5NbQtQ;NTbbGxe_qIGtLhIvX#F*6BFPSFgbQ(gRN; zmWt~LgUIWZu|@SeJiX1MfXW$)nc%7pq96;e9Owu(P>xefA(=qg0p2r7?Q#25t#}TJ z?vC=*eq$pf@PMdRENR9Xs<0nikKr|*BAgP58K%qr4PwVolH*+gqjG>tg9}BTbezxb zcW<8+y+=OY#!vZVZYmHKBEJfo^;x?B3rL?|5DBEd%`IbBL!tRepp{t^nOa_-Od`RW z#IST5H)ARQ!e^dQ?|N$QmoBr0eIItR^p#v(Luxp?W^%TTP^eM-L*8L<;%>6Xlyj2C zc8li3os;e!3hSP4JU;|7xxwj|2u%MGPBW~6x006sX~A&Zs9l_gUJ2d)o>H=5 zcep`D!a$YGe{DRbB+Uc^wmYuQw!@`?YFjK_nWxDm{AB~BWsCdkz&U&c>F&!G$eAPx^!1D=l6NbkL0+QPB=7|UM~HANFvW)JJqSfs!)eMj5onT*p-pCCO#bACS~eZ)2p~e2TVWIr z^G>>eFf@MX!D87SxLhm6?m_k9o<~(DibwyU^!m51)K@|rfeKW_|2=?=`h`9MN7HlRx?Ki*Au1@x<`RfJ?BWP6sl;;=sq8T*t*j zy;1A2zFY2O&+ct%cC4lfKU%;M?I>!fVl7=D$+oLhqroqBw@hC9BwiKEhVx)J@gMi1 z3N9W17?k@I0%zIHUqfExsYkX4>kGGh!(A=9hLSms9K?2NW@8T?eO{^pccixv!r}p# z?4jMq@spb&2?Zlny&NyicW@2 zJ?d9AqEW8?`46WS3E6hufTdZ>35{-9p{(OzESUBMcZJ=3;S-J}KvenOdKJ+E@I~15 zw=wWk1bKq5V#hzm0`D9O-%upf!T>ea#Tv^!urz6#?6Sn-n<$prXq`~#*Z0g zER;*S3YT2NC+}?9opZ*M+hF}%Mp}%g$Q4eV^rS@tQkEbxz?I-r!Tk(*Les&S%v}D6 zKat!5vPJSXWwQDm7hUQ*)Du_B#9THpO@n19l#)Mv)|T z4Y33+^#D#oc>UcgCTWlH8BNnYG}bG)9o0bRSH+rL&#aID9F2%-_^l!4`kFay654Oi z)oBjst;X3EC-PNNyVAHJD@ya1CVwABplygD{FSK*^Is23_J2hx`6yX|p6CEygmX1Y zbSPtL=04anddXYcET$o*JxQU&_~DyN1q5vutJEjwz1h2<)R(`Q+RQzr+9o0rnN8Oo zInTV;kE_>zwHCw1;8cHUHdau!`!i@)#I=Qag=2A^yPh@P<8^7RJNNjl8(UUdE?8}P zjXIUf2(a)_)q4c6K+Vm^A>Sn~l>W~GmZ;2jZwPiA?msK;KP{O|!edQk9EayE92ruc zv>Ck#Ejn&}>oRt*xD@@z0XbpyEhiZ(fp0tJgu9Xnpo@PaKOD;|B^(f^_%kH>v)@?U z)oXrL6DaD=lR8Th<(>D>pHG`QcXyP{quCg$N~t;#j=JdPIdC2q6 zr7BR=9en~yt+L^=vV=1_#u?>&`_(_9?v~ry4gbKi!6%e^%5NfCV0#I-yc+|@eUK+s zReHbTC33IGZ4CkuCWpwc&fN|6i13*X=+8K&y=bTFxCb7!We;|?W_R%Evr`JF4mJfJ zbOd1Z9X2RJ`$T){CK;Vtj9RYQupZn`?BoH=zkUSrNvDpQuIDZ|`!PUS#q;}lSfNU3 zc|Xd|$#jDaqGcmMtF~#<9;P&EvHyan*Vd+tdgR^*i5`v6?O_+!0G%1;a#lxZsZb96Lpl zO`=77i`hrrXjoMzmPvI!8X&c+gd4)HI&M?^_p&SHu+HrSm7NEuvnBky@8N$$`jh^v z=#o`zlu;BxU_(M!N$I2%6N~b%1xB@&Y2_xva1ks?%8RAvjsY$yy5HM5Uu!K(q)N+6 zW`CEJIAMwiW?8x&^<+6+InDUwG``)hre1%-s7DqSK>3{}#0(+u9XxH3v6iUV6&Cvn z6!DG7%DZ$E*57NS0n}N(Nh6A@uVfoULlEJ#(i|91>(aw?0Uo;B<`Uc_jaNHX5aJC| zmo{>3Qk6P`{2t0WC3nqHx@bIGyUXJOoTo$dLEqM~pqzkYEjY`3yIn_KjoZxi zC9E#WoWJ~ynGl=^->i~Byv#N}V1su_Ob)w6b+t~?EzUJj3V8bROJ#k?W8xJ5%a(Q9 z)??YF=XA}ax14I*{+ek4p)m(-c|s#CxUAVzR7UdLaN?3}hQVmnUX`EH$ zIr>>-;DM64JH`n13YHOOW734+_$_L;=5gCHRCi1N~HaXjYpyt?d z(TQjMNfX(h1Y+Z+E|c z)tt@}8S721b5Jokh5oeQl-Pqd}t&*Xl6(ILa6x#l1V2E` zu6?0&GdO1S;!2fTz0oY=75UOA%Ze@V0{*Tz;?ya|?)*UB`kZx~b=>vw`g@{87LThYzd#IvLYz zZbdjR5gx{+BXRs7OxgC7(HOk@*xMuF6WMLB@JK#^i}(wXh%b_8!#3(n=t#P~k<=Mx z@t^zNql8uRe7>uPvbdH5rKODu{(Y0F48unhYH3!bm_HhFbxO6u9}ZpO(p_>PY%{n_ z3LTOX$p@}Tp)uwi6?T#PPuieseenj5ARjM1$TdZZt}%9Rp=Y}`%|!C z`x5nLqP>ll{9ybPe5yec_^-&B&P~b1gh~dnP;77@t<;wyRnI~tT^fug;a5wUYDxwGl1-8Jl{l~bLOxs2Xhx-RzKAk&E6IpiM-!P} zq`|S>%InmJ#Z+OCibec6i+^A?FI7Zyyo%2B$TJoaOUXzhJpNYup$7F#t`Nvy00@@v z6dO7LkLvk|iIk$#d2hENhVwbmF(GL9FyRJ+qC&_NLo=;?_KtZEyZt# z(+?+~{)yi3c3mGE1qFv3C1QcSM;E!PU3W z+KCO9>i-ZASogq9e<53V7X*AnnR@rrct`&w9<-{b2ifXGP`lY!%ur_(f}H0DS{eHey98_D zKIV7~68O3CA%NhnYqDFqhZcOa5{-6pW88*?H1Z?I;DnkhXD!UKlXz{flitHqx@Y=_ z4L;|Y_nGWO1u8Qj1=Jg#+%cj#<5K++BD3Skb*Gy(^(DKrK7GkC!p}^nV=O{w$5G^N z1Uzsfx>uh_t+7Ck1%(FLD&x&Z< zLHXjAb8B`H-vOW)XnO35A-@7G4JlA>Y;BuBuU9gX5^Jrf-lH&R%$@q1vTit{F!djD}GVdaz@lUSrY+Z~uC{EZgP)E1Y zgMk678YX}aX}^p=#f*dIuq$y{eT{nb-#KK2Xw;A$EfPyMtd6w)As)1!7@w3TO$*|3 z`=a$Y_`DgYawbz>3J*+CwUHT5md6u!sg)`%F#OUB`EH`6W*ePx24 zPoZfZ2$?i7uNrP1AIn?mqU}W>R2L)ZEg%6kYashIW-A<*;vk9#9VcRRM)KbajO0(;$gqX#( zv+k99y_uT+{&;QY_o*$251j8M!FWXwwTGE8@D10A&b^PRHV+aU+1thdSqhwpmMYSY zQweILZmQlW53dqN$Evv0jtjqq&?XO7raEn?BS2z}@dogXlbtJvnRTZbZ!^~r5M81d zrW;QQO_3`3mUDSfk~j|9@S7x~eM?@Gf)=RP%5q|Hlyf&S<8JBCtE3b0ns7ak_Arz2 zJU6Nolr)=Y7Bv)cZ`mLS?8t4Ef!i(Gm$a)PlqQqfl!>ls<99X7AZnt4F?BSNBGyuA zP4Ky<$_kU7PrUb9pnh|tXQ;LT!p@tqO{lf%Nlw-#VCu!}xv+7TaOSwTE4b3`xMo*K zrjVblbC6kF*&?Z}o@*?$cDh6|B9lgXDgD~hn8qCHawUcjU+4ct` z;K(eu>tSreH%v8OJvavE8i-{+3bgMAPlin!%1$f0&XM2we4+Qbd`-fZe`M35naHDA z-n%C;0(T{5TK0RKrx4%eBH^auy946u`INa+v|YVV$D^pco&1Acy|d_D%At*6F(k<0 z!mA3>VE4)IRAPSe#2Y-Y$zQySp(PcD%SlC(a90Bbpq)s#WCW5_IuuEIz!wFJ~j1^1SlV zQIeEwC8JpdxZ!+oLyYS~?b5-3E744tH>(ZaX=@S#Xl+k<2m> zDyOJOn zet-wF^JI$j2H9Ccv=nhX_QPl`+8JK#PjzZ%=n1{J2&%-NNWk*B4$jdR=tkzroClOY z63d^Lt%QEPloqtF^V~)5ml}dDRFeR6fJ3f#-Pjpm9G08&pji_Dm}d3VwOUjrT3}iL zhL1zBKggpNGw@&Y48Kd3mbSpm#GLz(no&FZKgmsKj;c73qFKZ|tJ>2qyhCMQ&%659 zOwZiEo%mw<1;PIC^jEj950r_GUH(CcKldv2gU#6r{AAhINHEe+Bhpw0XP}BbDpZSD zp-rRMsfaO}$vaN~wlp7d8G>Ru@YHP_B3bqH`tuuN<4;`V z7^xVl632cjnKG%cnA1GGt35sca*T|xaroXCXt^Eo@#&W@e`|L+@V%9$L3j29NKE+O z{3ZW7>#~(WJ4lL19|+dv^p#1STMTN}pQ(OAHcN(uw29tF=nk*6u2XG!rtEQC6W!i4=4xt&(l*&kcPm?=3&-5l#c>}f zxpX)@%}M0VWJkRhFJ3#;g=7~w+iRyCC0D#9lW`b8RMbAS-}I3X;n;;;5^+z`_b^FEPyp>D%D>Q55A*-CzbB^aHLnD@?lm>&43b6O|=HO zzy=M2>nG6M=?N z!Bg1crr~u$|18KO4Aj4enSL!ppxB=ORK{%o~tdyu!lkeU? z7j@m%rCVGruA#lzK8R3*<^c~NG}6nKtzm-7M$x6ymCp>@&_MyW0)_7z*wj z?69Bfq$@XDeXw0i@r&_hVZDub+O{l7j?GTK{nPYr8{c(3TNdfnD@)l{tuj-5hX0xX zDEiIXgd1Po>T&HD%&CksDmUUeaGtP*JO^-e22ZW(M7rT@HLCJ~v8~tp07aJ_f?HP) zdl<@OL#3*cx066}Qx{L3xEfiZPqFk{>ecNKBL#g3Tio7IO7_0usM?k7=;u~$lQ#Nx zw8KTe5q`@YSQ)6qVee*X%jc#A(JiY)LFV9Kq2uZl3U4hA>eQ4InN1gOJjjaktG>n| zFYRJxHe3S{CWR#Q(X$TzVn0HYQ5z;%Tp0bU&K_9-;v8r?TIAbRn#J6rugV|IPceyC!)!5o z1t;}LT$zt@)qY=mfpZNaeZx9I1NyXbQ_=3_-wA!QFah%gj|ICV6K2!WF0o=a@QF(P zvwH>w{fdyUJQgksP3Hp>x+ry-_@rwDUWK1_VKacdcr9989Clj zQBgYr&aoOTxk#9ns*TdH|H=%o5zo9h1$oOC1*_lJ3>sDqe7V7rWqrjTaVmjtp>$4I zyx!{JYJEDtp4IBBIXbrp@a!M1srcJ*?^?rKTho>?#7RB&kECzBaKGuMGhupbBPF)b$DTr@7fml)4+_o>oIx-TGV3~eu7leCT+DmQ|IFn2I& zY`hScJTB|pC%E*^uu-kQuyF{FHEBSDR|*oO_0mSvOt>SBsYVT?lV4uGWTQ3El)FN_ zJZJz(yYJGMSMOqE?CfjHZRtnjq_Y&Y(*=pXB=6yKB2M6VpDlR87QxMx*OyKVoEe{x z_jCcva?J9INIP>P?l6imA`Gv>`0&30f)ZghNS5wj9MG8tMB2o0-+`+|^$Z-p@#BXo zImVDq3>bw7Ck(;_qlCVx{SJQlLTNXjEXPWyzKE>0ILo`oLq3dnvPY3JT$?iCXqJ4) z7%yTBN;qOND zy6Y9XJe%4>0+XWi=6eJ`LCZL;Bu?Olax3>8J8oB8J5L~gs{W@1O7l77)wsT@2or)M z&_Behgxyh3_(Kd{MkA#@bit=+uRelR#AMl#wtj>WgP~_%g4fikG=uuNdx>OQ96Lv9og^6o6d6Rtg1!BNc|AuIdX)kfQ&&|r76$nG({ZW%jf0Q0t< zu@%{UpQ|-iFrnS#mp@{`AnSo^5o4yIe5*6c&3;gh$=$%*P)-s`hsRJ~O@gS@ zKV&LH9Iuo1J?(0Y?OAwyC1f_oHW%}>&N3o;wdpn9y}+pkZCYn&OGlklWrldXKyE-R z6=KT1nPp(kZ1G%0`p2zkzymgeBs{BP3iKf@pGcBAHBX9&@cpE%RTJDPOMz7}bBN3k zk<@bBy(l2Fm$2JRkl1--RnD@Q0sRBId;jtmglkIjrMrDyO9^oBSBC`F|Mu=io}$ zZGRu`*y-4|ZQHhOCmpL}cWm3XJGR-eZ9DnRb@pDTYOnV_-*c)`sr)_X$o<^U7@u)n zRwwW)&##~ARM7IH{-|36t*R<<|rEue4Tc>W)E1m^#q^R8L_ z6(!@HB9&u1w3iK7TZ;s2C=AYq{--v;<1~wf8`sP{`D7D8e-Qsxv=@TE zCkCm4Tbi!c47|NUPBn{5^*-nKZ(k7im=r=Cno#$Eq;C~K*-X#)rgRTz{cBoxQ8{-M zdmijoCrXDKo;W!yk@eW5w=h?tI^v-pS-UDYH}OxWXSU$Wt*{BQX_0|~HN<1ao1>6v zmS|AZtDT8M_yN^&MMk9q9&}?_QPiX(fOE&_Vk49*&~2(>;n=uuXEOuthYxgVh6 z8x@F@S1J+jk6>?FLw)0slys2(L3>KhKE!vP87{Im%43?pa1pJP9+skO#lRg9L};T! zl3!yQSMhCn#2z(}39)1D+~{5Dky=?C#%5nqjbinsd&bgnjKP&4ag#c+x+f5S+XKwn zy_bRCQ?t-e#Lw^mZMC+8P*8mgqx>r?#!v_fw?fyGkP4M#tjb5v-v0Lxucz4IkL$%! z5MhB$ycN8SouR`yr^OGb$Bo0NSxcvFiFI@HfnWL&54Mo{Pv70zikTbQO&wq?_4D}~U*gSF za~v8i_vksmcE|g4_g^u9&Yrt}`|eEt<+}?A&ksie`0mJg0lvGk?am|f^-HIf<_xN9 zla5sjmgopt@Pfk}$Pmuqgk6GD+{o=%d^gF4?Pt2HX7_DNk5U$uVHnKHkVK-=f2B>L z@f&TI-v+$c)F0(YbPryXm}_iRG2_iUvE$gS8BAVzw!6~tB}IAs6%+~jmk{`kMjk&D z7P)f1jG)`~8qt2=5gj*>un`cF9Fw^J@0zv?*H~1BdYNx6_ z{LxDi;p}C#uWi(?TIlH%3G4R-J*eE-muv5(AMJSHvkOIQ5C84Ed;QyYw<pu79~4EkGlR|vQ84Xi9e zM|Hs|&Iviz69`(ORy2!==h{``yKNzfj8nw*K;-vNTyq3-?`w$Xap_$;jUazF;%axB9Y>V#-w0<@IFaX!NKL!mO3t&igf?R|CT1YR+FGt5Vi$ej z?%3XD5ovPGRS!mLuQx2V87-LfK4Jn0KtU-~PFv{C=h7W|e1TSL z!&yJzfBOa)M@cNvdL(N!Eh4N{{1`@I25sBb5M3rWV-iAJ#}S?nxjw*}>=WSTa_(fT z^%KRF23OKxs|`>MXy5<0?M}NA@nHGCY<1J8em*fNrj$p83~Jp37MM!VpF2pw1LBpY4IbJ2=v^ zY!h}>M17GlWwKO9N1t(661|%ZyQ5_>w4J`8+030#GR)j=ae2PKG6;BiWqpfoTB}hSP#q=s7=T_2{p{C$H$cEMyu$*L^Ghx z!@P&9JQEb{t;*P&^whQ>vdX6mf>(~syfBPKY3hA!m>@h3!F6R*tUHd&{eXBBbQ2yC zVgPffKzEreSK>f&!4mn=az&NQeFcXt1~K4_^cxF<@LjqtVwA;VKlJ>6&Y|o$H@PbT zZ&ZH&tLl#R-_h?ZRc(80Rlsh_(&OqBo1+fk<6$mB;g&orEJ8n5LOK%4;<7B8Pb}2& ztmmt1(Y$K!IAf^}azCKIhI!S2k^x2PyrYke6@ZLI2wMX&ow7?OW1)o9#+pn^V`ko( z$XucSdcFVg?Pw<2n15*ZSeP;5hT}Fh)|FYjnP7hDPqGR+`tlP}Gs2PQ^xAkN1xsKnC#1VHU8@JtIzlw&4%)55o zw9lxMEeCDhN@Px-;}`^Zb=K;#_CIhDmbtGZ2S9$dPucb-RYt%K!$(vVs17}Qce&MxJ1>Y;d= z;5V9DO-k0oXSQYo_!vf6by-N!Fnv8>=t4l`fn-TtNC(I(n|Df@x!y0ATovR{!J81j zng5Xvc^!kbfBjIc%QaFIRU4E=wO6cRn=hanrgj~l9ZV#I$aAY$PCz08%K#*ITrJzp zg4A0oRNgep`$QAGX2tQoO(@7R!jcM&POVK9wmE1+6g_lZSR`xqBSZHL)2q*l88PzRr1rMGaNiT1NK-k}9!PZ&zR zJ(>sNG8Ftf^|#E`)W5frZ>dkm@HG^Dg@s>mZ4cys)bk|~dMT~O<5yp#JQd)BOCzy!4{HfgL-DjYomRX0CQ|uyf!=hJCR^;Fib`$21o&y&8 z?g+z(>O(bE#xbqZnNM^z53ION2nK6E4ly)6GR~se_Y{fO~1>kPX=MMkaAw4 zBItLVK+23Q`~Hti6dr{N3<=;~Z~%BKA^K++@IN3(jQ<^q`U@q7!8>o)(O9i8Q!W8S zU)n_=t!QNQi?EC|8AVSDtADLF>f+S3WhG1WU6V`?3I82vNaSbSO@?Jbm6mHlTjtfo zOSS$_-%sFe#O4NxJvH!_y5vFJ^~r6#2r?Z`F5*vP>pYTq&2m^a-HTH)oZ zrS!=|#j<{bg+Sz9hGi!oM}(&sQTLG;ULiCs|Nbuc@47Dd{vqdWh=GguK8Q=ac%lK- z)KvuMtI==CPquUy8~+uHlJcl(*EN>Na-uIjsvM2Qy^ei46Fiqjf)0)n=0A-(7l&;V zL>Tpy=cvIp?+YjGjfwnnyN;69%V4?qQ6iq!NSczrqz0u$Kl}$)`ba`CH_r3r zBH_oqf7^=*rMc*FyS+A!NTF^mFQ|2Zi%hrO;YyZh5-<<68E>ox?53>Gq>ddIi_Nu` zcFiDAwPmgUBTpal0#rf`sIlh%$D1$9-%C_VTlRB*^>3;9J_tl*ea!qr$&q zG)S?LgKp}N+Uh#MP zf?R*o#yoCK2f%!o063?y_Pl5pTy~drBME+IA9X&XR`X`~7sMLRb+>X{%=T{OmdXz` z1TNJzpJ@nHx{3=!Vh#i^p8Y`2w((rb6vOI+S0gu;>7T^poUA*?O98lI3 zH)8PqTOUL=9ze~l4_Xp0T`ix&mJwh6Dvi|?aV{-J@;$+kgXO%D`$J5r*INIZz3dOu zSM{GA*BnSUl9c@JpJo@XR|fHHvG{=$nwloli@T zXBO3p*6#R${xf&?xYnSB3MeoMKr8Wo9KrlIQ$_bL8X~W-+lnhCun~2x1nL6XjF6(D z;J$ETd;kK9kqk?}D@EX6JVe%4E0!CG=ffHX##oDdzBlW4up#a>aFVji{_`&L!%eTL zE@u9pKVQHNkiOeEZ$|`IuxZ8q%0o3~8;WaSFwFlHWp$YBd!VrUQ_EQPk%4BX**ZDH zMXOt8hT(M5hxc=|!Sph@9(yds&XOwUr`Gkm(o$TF2kE%#qoGSLbp-3>-I$75LhlTn zu()8mQIpyV+9z?hF4?|G?+EBSQPho2PxR~Y?XIB2gU2QW zu9TX-g5rrTQ%VKg5=dYb^#GYQ$f1~t^*Bz!`ee*i%A`A^V0CQXdUSox{PMyha5ReW zuAN11@%0qXhN|j^ayl_xednxt^qE|>pw)+nL@!lPt5FygoUG$~Gn~4y4ynW-{9%^M z0mi@%G_$?bejec~e$=5lKWJqXf7Rf8%2#4qd7z^^H>JMYE55kvYtED)I;slg}okyQVBaE`j89 z&#@?lH=wL-L)HMif<&eFhcwHkGoLUs(|A1*YLVrw$h~e|d6SrcJ=gC{OD!8$Sn}ht z(;6`Qdv}4Fpia`%8Tmy#JkGT(YaBlx=xOKqRVnix4!O82A(0#*;F5kCHqV*0o>vEO z@XcMCS4t)G4^iK!&uMYuHhhZ}aTBuz3k~+DxM_yhC&TOjQu4_{%(rXA@Iu$N%~kPp zT*3=|Lw?Q`SHEYsUu2N%7pv7l{CvPu2SIae9{Jy|{2B|?D0gFE&N8oz7xdo*K1F`PbfUVZySjtAa=P-5L z%g1utlQp}VB^x&RNL=u$%Pg3XgXD{d0tW}OcVyqgSktBh&Gi$9Ew$Hy($Q{QhE!R= zQtQT!Ac4e&g@7y;{agyd&^D15utIqfh@akq>tWS*RZx<3^4}p(#34vur#v7zHu5vQhSi_Oc zM)dZ`uXaqfBkw)M_#a&7gXb$~C-#EP zk#|yCBTa>rl_Co3+`uXZgr7oK`xIcWh~;!bU5Nj@|KSWaMZ}0KWbq$#$T9iV2yxFR zVg~#c&LWytsY9q%ypVE`d--r;xmR`ooNh-OExI`RJQa@5=5zkgDIXE$QKs+$) zj72cKH{Kf*fyKIH#~b;@uzMmG}#5`lH*CI{fsk&!K7 z#A^x3?i3Y%@63W9c5TeEG@Byu4GV9{x$aGU-iCNq9O{Ays6sc3XMJmb)H|Ap$=T`# zaSxirky#%#!gRyDWL!6=NYh|cL>u9`@s{UDO{HelA2U9=FWPd^;<|sgYnrvdscr;| z(sC#Sy_KC-f4j*o=mS|fQ8sDEY^~o)&Kv&JoSEUJN2M(8s75+3^7F7my|Cn zUc;S?Wp{>)etvf+YpRScc`RHbCZ(lqDsR@veK0E2GWZ0h(qh>i`dvPxaZ)Sv;;BMH zyGdmDfUFM3iI%28`Wtm1ufmmwueEIU6?zNMD)ni>7jee+l6ndt_8;{@J<*-BH$>7z zwnoFtHR?6gF#jVUn(IF6IPdbsPFu^8%*|^km0j~`{#5GZuM&DZ_=AlNvl#==u7+6n zi-@1n)AC2G=Pf)%c3zFD9xdIUkgQMW8_u$Jk<>h@u$I~py8dl09WlP0I)xq-G9>Ht z%K3BE2?E*XUz)qyP$17P{J_p)BRJUD*|U z{eBNle|&Rw7USodPjkiD`d~h7>j)DBY(+4ePxBs~b5477c)YIt1tEcAjiO*O6p4k~ zb;YnZ5R7H-*BHPRbRwJ}q#udrKm%1r*{8)EXOa?Jhco_WKTa`T$^dG27II0$Fo1L* z{|qnR)HW-#h$GFOzM@ptm~O-He0x|t#GtVRSG9U!W1*I1-zbShn?YsI-dYrSmp60N z=$s))i5X&cu(saJ%B4q>p~hTv>eZRBD_8!9RYjZ~Zs+Cpy^1FAXJ|{uJts9|P)pk& zw?~6D$NtD%X0_>`U&0?${WHy$+%L(+d^9|B3)RBAGQ0cVq}O*=_R68vwG$;LccPQ4 z!a)NwJOTNVNh6G>F@pqcX}3ou)X8QKWo;)oNV6Vh)7T{^&;jm)M;zMaja4N05MGE7 zyW&rz`OqioP4x^seap*ABY)0tPT) z+YB~11<@Z`hh#}DQmk6V$&?&1=qT&`9k6+CMP9*UO0PpvQE$*zrPQrasF-kIS35pn z-)qYa;Lf1=5cY+=3%8AX9V?l2KJ1fBQaRL+?DZsOjUB73hGKdKzKJ zKxgpnm`QxDC6g>U+2!7m(}3tAcA#ycxxs5@)zdKTxY1=0Yftl?)Aq`t*NJ z_u_S^(Idgdq88d;Bi$riLvcrK2b_ubQVh);IqeO2tVX8#R8G>*g!RI#VYo z&qKsAbz5v9KQn!%@*i^h@gs+UWfR24l^EXw?fZa!nF!G#e>ziix5WII&-0T>$0JQZz$>cY86tSXDOPCDNazz9bk93C_gy2_S48g> zO!S1DMw-8X?&Gw$B^m*oF-F}X90{Wrh4vVgst2^IW>X_QWz0CtQ)B|p30%}~-_ZWqE)i6a{8z*)NzK{;TOF0p zS~|I?THkSh)pmoUQ8tOANC!kEhKazppaA zrrOk}zm6*O$@6`j=|s`z&y-k%YUF3JkAKlzn%$70J&_Pzn%>Ew3GT?MP|HN ze=^2e5>J~}Ax3NMzH(CV@__}X9>i{+se3K5!Aq5C?AlKyo!lw*p~0mFK%!I@<#Netz@d+twXVyY-_e+1$FEh z2tn1u2jaG|L)tvo*`Fr1_)gRl%&BW7z6z|{-C#TO<6?Y6@HJ}SV9;9Q5Y=YuVcq$1 z9fQE_qu?{FBm{KM=S+KMT{>=twHY%|Hm0)X0-41=1#Mgm)qX0+;N@Sc{MEo2Hsceb znY*H_TZ5LCIc#GG9`jc2eYAh7kY_BmLL^VaVglt>mT1~1bkUB#;o^swt9<8(zOZn% zE}^N?=W#PX(tm`WDOf|z@=eJJ0dU6HfRqmw#}*AyyyEjBXKOjL8|OM5qhNCp#= zY!cW2<;|ZR04r@iljI4mnw14wHpfnuS-XI&QQirl7rYWR@8}!vfKeqprNEU_36+gJ zH(5Vmq-qHy6k=QxXcGNJ$GD+6@3@#m%zfP{c*NFwNgK^!tYx|x!OXzf2dRH|ZXMIr z81!uis5;p&isZm?oVjAs%T#`NJ%>pSTQkv_G*Sy4b!WupiKb$*pAejb@bZLL>Ppp9 z>g3G9xFOo%=aHndV0r6lxQ?-ctx-8$WY$I_-tr_TDC!Cx*Q|cBghg3_( z;?18>bm^A>`v{bLpNo*gH@e}7{5~%gz?VNSegc)nCx*X#f_z^nm$Tm_U-!%sS)$<` z!dEeWbZqqtJaC$>cnWXF+c&V}QADC)n9RtGaj`;!~e1ahs0K{rjS)LlG}yD{F_N3hVr zjyQUo=CK|>SDQ#r|>Ky-2_K}N4# zaCrxj8bIdxZTK-a%C4{FwjiN^)3Yq9H57i9r_$JBiWvGq%wiKiN2fsJx)m?pkGNY9 zhN1eKvBgzNFemzVt|x{^9;B0XMy=Q>)Z=Hc(vIa)@~~G51^h@6UO_jBg%o1W$=D8+ z5&hzUBM?G>5aihf zk%w+~h*fdE5Z^sB0!@Cz#s{*VdbNwXLs5iJ3HthjA4jrC$p>CyvYe)@Dka%+#;MBP z=ceh4)+CpC%37#=^pfM&)BdJzMap+qsGfV`W8Om!+zd0)Ou;x?R=DT z&FlO(+?k*F9mt_l9 zRn$wLs1|TLatf+K$!jzVSjgv>yQIjI?2I3@_v{TmxF2TuW2b)tU+F(%-`bxH(&0B9 z3`QrTb00VDe~|>n}L}E)ss>D7)sIsK;?CfWQjA@La5jy;B+}xS*Fy#{SEb z3&({btQ@4te!kS4>!?lt1gHoi2@iT-6C3ml?Q}kbvdXr|kycjSlK9IlyW^yCK)0x4 z1mGWr_KuDcCuVV7`}5CHuL(G^2rr<`@qgff{~bV70`Q6iPTi& z-?ELFqX~I1MX+FCBVnasF##0paahfeB*5x?CA0{;&GJ$PF!IT%mj_Klv~x^7>&4oo zVx3YM4$V0X2I-Xo6s)?f`bEfO`|Xp-2H~{DVnbmAy-&u4bV(kzD}jNUj`|C!IJiI2 zkK)r8am{hQq{+&Km5Z6C#*4N}o}6iPFrwi(D9esg$N}xSl*xGX_Vgg{BETx{BmEu( zaCkEMv68SR{uucfG;7=P{1^Xr(?b0p?A186K&~>&GS#{vxb&;`dN4Bk*nA<7&1ZSF z7%I5&6#NcAZq|B1<7GgJ_7RM6f3(^o&%Vqy1A=`n>-R*9Ad~5`Q>UMuvG_@d8wEpc z1@K46E4$&Y8Y~#{bgkJO41s&^b-ZC#-biilHMBLbGP^DiufKIaa072$WSR1HdDd?? zH+kXQn(TtlEu=WkX0t~P*K1Jb0{1G4bJ<5Vc@U>!=W5O z9QhX{aY3pqBX^D4h#Yr7o)w0?q>WLeZ4!Q5$xOf4m3%yVdqFsa@WOc5)Z5glXg159 zr~W}5(YKHnr}ek_I9BUvy}wEns4L)gVa9q?ok{orx=7^eCWdHXn(35d`*o}r3LJ{d z?_ewbdnAvRI0I4l06q!5X*k+5s_`ldCh;r_;)UeG|6Zp98*KH*v_~PQiXp{@`w)I$ z&(gHU72kD^him2prxS`&b>=C8MWIY4Ohfx8g~LM9VW8^#ltbUFX{v?+Wo>1h4dlY- z$2Z`&-#iy}PGaxdYyMbH_vX(`1n#gWL{)NS(B*AIpu0)wurx}MR1^lWF5&G5W#OmL zs^BO{0+E?@Ewh;f%+j?K4JgP|P>xdD)nSY799b;ClCn%u!&Ec-sSJ4yZAmJvO-{fk z#O~aIjnUNdXFOrKQ&+nU&2Ej>8QFw=IOWv4|FQ0Y#SsCH189uC0Rkcz|NP=qoh@yC z{Og;_Qn~u8Prys>e;yz5jq-P(%bJpuA6xUPTCkcl3|hJ$mcY$&t0bG@+N7O%1JGN` zeS$JSr%Q3pKppe#Ean#(>hu~-EuxS(bHmEhWye$YkIOFmujjYrt#9jn?2aZ5)X|ci z72CY1SHp;dl>wY!EvoC`!o3i42f|=(1wEhL>l4(iCA+>jk`v_`3U#Hs#2(iIgYn+< z74D@3B?R;{%B-D(A-cCR!`(-TaQw5 zs*u^uA8ArBa{aWJk;OKO-W$wSuc{{*J;To={_=_lBaQbaqxDhccEQ>|G-#JRHhgWk zxtm>Q)MZ_X{8=kuGJo2h9nS-kHK0dXP1bhBP9LBS%U7UM7FTPE4bJn37aZ@^Vnca{ zOoNGO-5x&7p_L?0J{>nv;tpx6uCBG4CI?;y*NT&p(poeevhYlnS%{VxrmXCRKMqLc zF_^^_-0;^Y3L8Rs`_qHbb%D>{43qlTT(fGgY_3!;M%9mcTC7c$Nj9Mj;KTXr{SHpH zQ*Wpk6c-zPb2HVhd0s(2loKG;GaPrs#L1d)=tIGGY9ey5b2_COr;}bF0a~e2Vv2RGK^jCm{@1dLXZy{&q#k$C;uC$akbp76G4A}n5Q)Uf46&H~Lyji!cethDZrbjd?~9bf zk2n$zyWhMh$=cFRS46M$UPYBe8Q6G(n0AiKB74CHzNM7(Jr~_1 z|LJ;&a{E2kC~Y4<-aEU5nxxh94_Fdw%06-EJ~82~(*v4%UkW2iJYrrHn^bk3oGhv5 zOb8#<$#-a82NerW5D1-wZj-EJB3sfN{En#yMaefI(qDo!IHA577o70C&&tT2$1D~p z$y~OZKLake%leMMml4cy1M)mJ zuuRr%h|ziA9^Ky0kVDD4{l;_URFl4?=D(*3Gr0Q^4_ofB#<~5#qy8fMQ&DXI?Ynzd z?1m{r<0t|>0E=L;Fi!Mm$25g!G`_K|V;n?$jkYAuN^S&!+A|vxO&7paoKW7Nn7dns zh6Qgd#~zP|TzDLM+{Z)i@|C2q>}^`un}uw8+)soOoaA)dU{nH_MJAJ4uRi>-Fm zElsavfVB-%s2WokG%ySzbd;AuE%$qq6~VwC_yt;Q`N9^GK`{UT;bW3{xrXc*iStKP zQ7=+cwjp?kFK~;5vlS?d=LSbna(goyWm5e8m54>m%?8;EtI)p!E6}{@&|M* zFkQY~0`<4aG~dm+;*II@+Q}v{<#uqcSiK^tzbo+ctJQeCO3Zza(yksqv%WiD0De)J zhVYkNk2FN2X3Xqo>Ay!DyLmjw9!bgf&wJbBcg)}@`xEyXXKIwBrz|2 zn%OVln_q4i&y1WLZ{UaeHO@})j8Z+HVRkYw zhVmyneEk$}xQ>qyLo0V-eBz}XdGb5XB3zCXCcu05#8BV0(nb<+4h4 z5aI&#J4oHbOihz2`sw^Z!$Pz7WeFa82U}JoW#lbOFoX@qFcfS>nuB znHUna5kdWdBe;qr@^+hs5FkSs58_p$Z1{y|a>-VR>kUUmW5e2h z*V#(?ju~O*k#cnxFzi#J5SMqxO*+0x1(By*yRqzD(_?B_(2a8*CX095rBpo@t3oI& zzw$8XX>6|@B>&tjo+iUt+opbySFu}QqO`OgMrixe4Vm&+fT}M~4MiDd5J}i7#|sXa zDKcmYm3lEIvmiWA9tX06aFAJji7f`&`DYWgIf`6b^fdX; zl>eLvk8@b=Z&75WkJMfb&RS93c7Obn)S^}-79njOrzHHW&^dF|WEq_sTl%n(Csso1 zUU=cR_ON{`gi#h)XC~*I_(YH`$pSx4+&fDYN9AE5tQH4u*|pypL%i$e7&f*vC$^5g z48CL?NN*B-@?-|b*I~_`tqDPC1ur3AXYl=g#QRRfu)*{xojtxyR5~SO#v~UFSI!v! zj=o%Z?U72dSLwXuKr=P*Bh~LhDgHc_Ugoyrv}mSKvp&yvqK*JEp`o4d&aR-F%FF8h z{xE+n<^khLO{pas@#)8yg^!CZ=L`0RwCp_F@XIQGA+d_kY^Z|L@AxmdEM88$HB5|{#)hHSe3zsR^`3!s#EyV z(PCT%lJRQlcid12|6Hd^^g)~M5rmF?xr|qhH0Nd%O57ZG0i^!gRwOm;2xD!w7%trL zl{ehCb*uQz@_i)~`A;(d%u)BSncnw3n8jClMGX4&jz^*UE7g@7 zda5iL1>;g2BAcQkpU*#yNoHN>ULKoJ)-D>DcM`hSICR&TI~YjQFM*V&s+v{I!b!9@ z#~;o&w1n`xzMmrZnxhgO|CCsyGr9+hYI_+ibG+dGvwS^4thMJ1uF?CgiaD%Y8DqeK zV^9G41pX0&_}rO!vSdyA+7id8)%t$Pf-G?IUDfWhym0s*Zwm-7% z5mttv7bF2Joaf!n&+2Y{{Ev)a>O9*-0-!?u|M$%Ozl#-+xmR9OL--7`8OLz^0X+zY z6slxo+_RprY6*R)Bm+mu>VkEAK@e>VMu;hSc)QT~NPdrg&&f<_x|;Ub#b17c7tS&B z?P4K&Wu>9R-0k-UZw3F?>x0|3Cr2bf_Cw-hA-bqwNG1&w6Uo>E$ol6(%4oS-hiHuldRK5*^q>v01~W&?^C3kGa-GpB=rTFSYR7CJ4R zym}3y`%m0E3|5jFi*sr}SbofpyD0x~8Ar*6Feht33J9Gjop+(6CsZcHr_4HQA#q-y8P+Xzvb8@+wRTAvNE=uLLQ5O3Pf@j&sX&U)AJ@sIh4$Og?dro# zSwnjLVB*+cTG~js$8y6_+lkBuDk@`|tYYrxm$q1)Qsnx4SPvin@!+J{dUCsw+MeV~ zTzqEx-a5pZa@Flhpm-Vnb3msh!ljDKUWJBB?I|UiRS#K1uT;rllKlvglf`S7~*qyt$P3Lq8}Fb{s3-uy*Gi4<^0Lj;3j%ZtZfT!g5ow zoR@D?RFvkH3k@@)dId0puAmPD73CK1erpC64~}e)5XOBD15u@VO-nY4iBznmr^H0_3RG2d zP>N)zTlit0#54>ToNpNZj2eMho-;rgqGW5&@OFb@-_J{@{DB>ZZ3Vr$OW%ISyL{8! zdyVSSNw8A6x)g`eVN=h-F1Pi|E=(W!D1pop9Hn41-6!oUvGCn>j00bu+g8cp7PThBff?*Gw|GOeEgQjH z{V6sqZz0M0+l-sJcMHJ>s|%G_;p+V+pf<>o6el;K9_`ran83LOG5IQA7f&FDCk)5~ zlK7T)Vhn0xRI+l6(q^RKPx7!r^048UGDj@=_DI6BUiKrEvKZ(^V_JwqOzl%{5NNzg z2M|iB6(z+V0e&$Fv=CudtU-(wMNzCK?`W1G(W>x_9uieK2j$danSPET*bO*?qKa2lUj7)7&Fsk5sUhZo$l~gLa9=zjci4cOZvA^)(xc*gF{Eks2p#? z@6I4{S5vLObNm!`2{gg#-UCB)G1ec^bLeCEK|s_d{i4mlXl%66=VOx!*zU}?+Vu|( zbstdRsM%b&Dl-dNgj%Z|FnPy)Yh3O>Epj2QSSYFsw*q64hiyoJ6do$DU05-xYkX?C zcUQ$k%0x2uTFa1_m8qZ?jN$?*ZNeH>U4&YdA*7JxN5GMg(jrItoxv#T$-cxrEpsPz%oF-=Orqk%sj`4k`gvNku?p<0KPm zuACRUl850NB>6^vr#b+vR*M7dc@z=~BWi5CKJD`IIhXIBcX#N1qLrt|1BKCEtu=c< zGPsyBC#gd+=y*nI!fO3E!cHqUIBk6lHfURS#Mv%w>Z8df+wMu3rkWjTxFPPnTj;R7 zqQl5*zgDv$$GoRW<@IM<+B*6{#IxkH95r2p0HJywL=>IBgbRD3{G}$gN5nOzZ4=e( zJlb5{C8^{`=uWWV#FuOoS(>n(SrDCS*VzBXOcdJ=<}P`r6E43Dtye&O+*z%w#g1Q> zKKz2|Jd8hD&7-wbFH4BuSK5~<7u?GwO*h*EVY8H z2!XKBQa6mw_;g>(3Ph@1PXY=5ZcC$V?r$4svAUdSI!w-{vph9&@`cCxAY3d7#7oBk zU=vMAPJp7yPG;-;p9OXy{w!0B<+{b^BfJHjnaC4X@6P;E88FU8_X!hKBcfLpGEl15S4@cYNe&VSy#QqR2$KqVkK(IrzeuTLFtR{n(J3EHBrwsXf zd0OIo&bxJU*%XY$B4Wd>(Iv2TjsRXRbI75}C+^lRfR}6iU%Xse058|+i6}7nAtE4; z(7wZnZe0WM6IkC7(sIJ<->x0R($RQa28`k}X#TTn0)$4}X|GSM)q1yQvGnnKnPaWF zN1efSR*p==(v@8~iDAHg^431z)`({wb@&K6)BT3Q%}Dn|?4Nt!G+HQYA5U$KJQ8;r zmq#%A*8Mc8d72ifL`gv>N+z(R+&a&(jJyM2Drgo3h_AbHgCe7s_m2}2%4=X@-8c@J z7K&!d(Gy!~8!E5dKrRG&cYtdLQycYW?4;qNoLPkPLMs|w!Q3pE#zQn@l@oD9T?Gb2 zeWrA;%^wP|z|9mp2m>;oN?4xI4Up|v17tf$HKhkfL-su)a>Ldj9urup!c#riq?<&; zGoFg%Uqw}wg5tqd>pV<&>bsru2i*hth?s?+Iq8d?;t694`r!&?xLVO>#axTBj^s3~ zj1gA#8uy|C$}4H@x#673lVV*~D+|#m$~&j!*O>7K_EKBI1`mo8r_ICH@2kKm=|*I>WmQDzQca8xHWKca<(D7$XEX0VGu3BPS;99G&jMQ15?pQO}T`Rh&SB}E3P9>6#f;Z0@Y{T*?AH?_`7p=Yh z#oRD*qEN~HmzGtl*ehlb3t6qkB(Rw(#mo6O374z|~SkPB4D_v~glMsf4Eu3f~h!0}l0T=I>;EO`7bf*`ywnY5hmldAqdG zSJr-3%~ID5Dr1L(y8{O-VtwsI39?%J{~u%L09@I&?)&bfW81dvq+{F0ifyygLC5ac zwylnhj%{~rCvRn+ea?M%SKa$wRZ^9j^BZHX)EaZ(!~dt>SDO6Q7``pB#9qme&f=$a zMiqAM2;9%_5m4FrJ7FaU<-{&C;BK{QVL)1psM$C*w_v;f3aa9qtL7;AHU3?|18SCJ z$ZR_HMEil@5{HRp zN@2qM2-`5;CEvjuKIsa`fL*eOs2`iSrz4v*tR+C9Q=)v)Lg;UCN>=%uForzkd7VqE4R2-%Hz|)*PBS&0Yf0KpHcEa@&Uo>^~*5`ZYi$&XM1o^Oi~c+Tz3cn$h-^_($gp= zx!>oCN251n6=yG6Ch`0P1EVV-4kc;&(dd!OiVtaK#!ouqHp*z)UAKuo!T z!kwYe9YF8SNPE7ThND?zMkG{07jdoki(#%3D@p?2?uIMXsLC~3yR?haA%D1}rze%+ z^v`bbXJU5*WtB3I5jNR6pew~by3eXuNi?Se;@iw$N;tRF|%G32Y94G<5XPZSJqkXGri7IUSAIenuk(1LoZ+cMZ zZ59LpJ@hgu$?QzMF&-F00u_w3sMJL-={zpki9IKyj{|R1g`yV_+))qP!LUow`(PVs3PR?Od85Dz zdQZh<=zn5PAGELMf8CFp2*#J%BLRYG5797stf(C@i(2GgRMsK*y^1kxB(8l)P05*0FMib;c$QT+j?tyJm3h>g_bB;U6lGwSZM z!U=abG6MQ218jg`S{wV|OCEEsns}9sAqqBs6^I3ncxu!mTgUg?kesp+H2pRe!wL^a zVqzty)1R2$AAH*1W0Ce`f^`rYJ*=T z*UIJ}ft7#kKGQGpPOOxSl*bg{Eg7Tsm$zE0sc2Xowhh*SB@KgG(Mx2F><05&sK&K_{VPR>Oj7-yimU(_ma9hK z7q<`mCHv|cssQi`m=()u)Z2u zP~nSUe~&G!BJq-lq1xpFEfCl#HMXqb9-gvgpZx5}pZO`+ys)^)98u}0mc>i7_zt)d zME%xiTa{*;Bk#G}-*2>bWP7y4O(#fy7zv!9ZW1)JEMxGrn_bkx;50X52t3QR4+MCj z&a94r)TJzCzxWqmi}pU|#XL=@%tNk1F5O5WjN}Ow zgM0W#Zc<}nIJOw(PQ2CS_b+yze4OGAMA)XX=Z_1qv8xlG#*SMvYd!U}!!C#e|?*0c@`S$#g5E|o~MEC`l zm>wu#D5Al$jWoKqVF02H_sPlTQ-=>VF?*9XovMXsXrP+dAE268N-#~o;4B|{H_+oG z!Lo3Hch=y(Y3Y#-R1<^4DsKghqD1y!D&M+F`XIiQ*t^ZQ?FVpo(vf78Fz#2i1=#Nahq zX!ZWBCWiEvni%}TUut5@EhL^FYGSb;YGOct)7i>N^}p1_@^>DI;EeQ(qx^apJqU#w zq;G-#CJV#yztzMxOG3@i*=t1qr6#7405JJ-?Dn^sSmnRf#E!yUEFWse4F*try+#vFmxM0)Fx%n;7Clz9Ww}iKyFwif8@`3mT72dO<=X`a#T;&s0N>* zYJOfx&TWGfUYSaX@}M0f3=e%^ldk&8{lg>7c$3?)FNdU^Bn^|M!bcFO-`UyI91@em zf00V8G9c38pXPv$ZVMo4Y_!J-?`svY3#c|}J@1R4X~sEr7QHu=gjC=iu zaAz&dm?j)BUxoL7tG55QS5xI*f+509x*F*121qLO>!YIK#o5^*q6w+F_be&6NaU{C@=5e7I=BAN7jqdiTDh$RbTn>NxP=?}a&PQ0IP&-lJsth#P) zM?}`aFUK@7ZFTi@(JdQR-HPQ5WK8 z=^TpVGUlssPnde=@o2DBL&Re99Qqv%hoyzmzR=+#P5&DdO*>`Gb;refpQX zlA;kS%iFGn1}|--14kZ`!~%7aEi}4@RG)|FleRtak(tn1XKPnYn^TBmP zX6`?)H0j(g0#&w|-&Je_=XgepTOzA-G&5PNG|hT?q9))^+3&s&&*{c$Vb2BaFQq%;(q2sf5l0JOd$`sEpBuf<1#`25 zNFqC*rPFlnXUfa=_}KYE;7Vh-O?@&3-#f%wY-t1(gzhr%WuSxBQ#{@RSmDrzz|Upfy{ znsH|7@{7Y%{7oQ3G!gT!`+unNeVcZy!vjv%Gr(7g_@ArPfAijQ{5wSd_f{BmB{5MI zS_zG66T(tEhOIM3!>CKkFkVPTt8B|gW5Bgx&BoOXNE&5Geb_{}5XYLN{h~d4X6kmf z>U02{t~0WJb-sV9hNJj_dbVqiaqp-x_zH_}e4#-2I(E(Xs$)Kkc)%%& z0DAX_V7?XKo^N>;HOIU&Kiw-&w{(e#3+{4~ZM2k@mZho#_O!A@J={0rNajF_HR|MOpDbQ^OwXFaEm{kD+t7LM*2s3_Oy&n z*~G75Pd53n!`aq(-y>n!ZGc=d0SBclu7md;5G$iaO)Dt9S?|Z2Vq?96O5j$UtD|4Z zqn+)hmgI5ttN)UJ81a&7+vz4)m4tpV?B+r0j{Y7*;b7Mnp3h|Id27vGd|3i(H1uPFo3yz%d+}pUtQ)h z?M*mq2l|-KwA_t^suqK)mU6R|%6}eim368<(LUTF$wIp??4^n{qD({DTWtTdFWyj) zJY2%PCjR{&(oQyy&Jj7l!PWNv3=OdSd!|+%Rzgt(<|~t0Ag5}om_qak#4u7xfr`Zw zc7F?3pk3rkCAWZDE5S#6@BJywQCTrP?YIT;K&57JgrKp$A_@2|Y>>K6ipt=3wFP1L`$D0!0x znN?%es8nxoXQ(Y6y?xyb#R=1E?&16=zFS~vzfEOsS&w!=XWXD#>#Dit8`bLY!+M#_ zpebA@nsBCsIqL@W#eP3cP-=Zt!Y{~A(z>QwW3dS*i5_RL1c^1$yd2iu1>06V8p?|} z)y4;Ngq?Jwbt|geKszj5u1uoBc>X7+~%6EjN6$_(+ zfL#s-&HyPI+IsR3t*WeInMk6~iY^(70k$kzL0~&#*`a>98fvSuG@x27;y87Ox2D0O zxwLl3KR%OF%_Uv6NH1$Pb1`z)DR7*)<#{}dfQEOdl3k)P5EiBy`~%h^SR6JQiy2n4 zoN}l{qZbA2nEGlF5wq1?#94Gg6()lH$WobT&%(p31E-A)F|KZwV1%%qV{{sq1)OU# zLXe)3b#5^@%_>M-7vrxti5Mfo__!_Nnb$mD+;l!40g+)2|1pDd|@* z&Di=#%8qZ`DY)2YF!!Qgh1y}tTcPrd!7kzW`Y4c0d2VtULh`nmE3?TA)I;a$f%4&j z4}`&#;C@?hlI5dv9pWKsqh$-pd3-W7Lf+|jlypH4pH3_44f}ZFF6Be~1Xi@`$&A=W zQX0Ob+}g`r;<0Q)uwJ5IU6v7zo>(Mm!WspZ5weF_C7{$DHw!x0A|sIGQ(?j+chU7+ zWoSPjtLuxA!-v|AX+m%)nL;$dyDsV4jXr^wfwp9GAj+c#T0z~ z&ld{UKP;*3=UqGeElM!B*bD5~ffCq}`BgeaY)vOn94`Le1+SD@`CM@mT_+Ejj^Hl% zCDErxD)8U2{9r-)vue(MI|1t_Vj?sh*$Z@hD}=LKO}E98ryR^OO*W`IH7wTf=($n@ zqv#S*NAacQvR}?25IG?lvGA!VzW*!PI z*BNRRPC(CAx59uiFRO+Ps{MT8Z1Eg?v=#0!xCnXsNT~Y&^4~+2SD6WY7QlH)_5V39 zar|2pO!@CfPor#!Af*zu6f_$a!faic`;pHz3CtE_CgaK1%k$YkEfuzQeB~?*08K?&+1LpQQ?tD%N~zynM#QYd>tIP-d$=OpnSjCk^cl=@?8MeMS?}~+Iq?Ya%1XV znoQk>!hn&clIGP>{EL77vG@43HrNUm%G!5@ptau(KX+PEJsc`D3_~(P%|k*Fo531{^4Nyp6~xXlvpp-JqQi7O18bbRPtJ%;}RVQwyvom zW8h6c836dycF5|lPNs8zcK*pmO}ex~o^?!;hG&s#S}8y&FiChLsd5>;-i7@UR(pwB z_P&)3u9#w?vDK3RzTIYItaN?an{P_RK0ZTR6)qB41q#SA323yoWTSgDMFRI>eBuy6 za6yN4J9PcV$@eWqJ2gZ4+WCZMv1w=|pG8h>Q#2wj9dyC9Is7tYPy`wUGb-PUR5v?b zf<){Jl^J-Z!JUycN_pW5KEx|bIuM^g8ohSvdj}7ve1Mke3`5oLMb$rtNpFEp@Ai>7 z?vwERhIKVRO}kH-;t|IMfN}mBkxa1VkU!JUAIb_9G4;##Th0;t;6IB41E&M@{eT1Y z|AVgaZ*9c?qHCCAWq%79QWdqKVjdEu79}e|l!m64(x`sM3U3F?u;*{0Q2M-dv(H2 zOP)qgO2%}0zIcl1V#y-Igr8_EDPimHvi z00Vc_9-8I!J)P2^bfr3z%ySSgdpE&G2``YkCGmtE3wt*g792{T6#VY9eYr5 zq*$|(E~l5ZLO*#zRW~2iVy*qDrxFwJurZn=zh9d*D$E;a%WU*)O1%hwx7a-Xff^SUN7ltgccwLz9OU~8t}$(|KL~w=t>(h}>D22c z*X1|Y%K{za)V`JVw{)|L^hw){2mz7lrBGS$Yg%*-TIib>u3vG-Dqrd^2s81aVe>gy z3S8aZ{Qd*i$e#ehHFEw5*EpP6nyTqOG&lk6TVX6sGfvMX?9Baf!J3QvL+>Vtm-~t7 zTNI)$@$3csHnU;i5Bng_2e>K;BYoR_JmS!#s#v1m-3D<23EhbPm;ssAS0vl#IUR!P z>{H)#WW_aP`L9Fll94w6Ap{!e{Myu`8LElBjFpaifntnOE{xc{oR>0)QN%)i5b;FF z&0<%ciNN;dxcJo_U9u9O^ju#ouQYD)edxEZQza(}V8 z8i8kYsu0MLDs&q`y6gvam1;vus^9r=sqO%|B$9G#H;%R-zCDz3b?`7`m z`jOaZwYA6XGk5$kS#wd`{90wKWCe>t}Q>#A85J=TKlIy=rR!^Rzf^^RTxv3IB3j5R(Ma65O*vqD4#A$Uc z<&i-V#UvwxtmkM!C3?`y$!Ee%@tv_Lu|@qnL(jRalL}u`%A#t{Th(9b9s_sA6_mT$ zgotC!C3qeUX-JeK2KLo2->yplLiol1ctqf7wM-NugGndaC?OFGl*4|ei*D8Q(8gOz z#XA5lc#C8{sM`2XpRcs@s-Rfp3GLhj-{iUprfgx9Yun8<+@SN^Upz0_(-OCHqNH}y zLSxt;%`-BC8+`&IG3_`sO{7Sj(>`8$GzQAj(Qq>75B304DE zkB&4;P23>WPz)8u^c8L;N7n}UdMU@M@AzFV^%>-bKAluuBo(_gevwvckS%hWTx~g6 zjb1zF=lA(s9r8>L?%+>il?#L6)g8;i^h4S+<)FFz9fP%f$RTLQ5_v)9hG^0uV+UM# z(q+ME4!KvS^+l*1d8zfHbjby+RgX=dnvdZbz;@islN*ah{me-Dfc7X6m>WB3&4{^d zj7i&;>HXW5#A_~p8>p1`&AZA7Nx>y^0tmm@bwYU%Sz|3VaO!m*c@dA?C>qaUK;8*zDDx(b98I6d`G z1u^G!`L~HtHl#k2on&Li`(a{u%fyy54q@?i2N%s}EwtwB040X7OyzqL%Z*eOwqHFl z-h?C#f5b<65Q9rZ{1&A%<5aPxg}VW4ch11klq3Q7?gs__*6)#-gQ{w z#;Z_(TFQk-LyULiO*tRw?K1!%oJUTPigKP4ilX2LD(hzz{zLBfs9OXduo;ydA#>*~@@@55Z1 zRrHHl6Mhd`>RaV-J|(VUz5YBv---d(VS&XbJS=riA16P2f``L&bB7mPg_SjRe-cwL zcjM zOY63dIHJyqjunjPyUm%msNBo>$8z>4&(awU+>PM_UpK;k`pV?(KkDTbfWaIa(!WL8 zB9;I3%FWQPDN#>ThlCJ=Oe+BtFF~b4rUk5(sGy__LlAmhwGyq}QrD>b{R-rfQ6i(o z^j}1QL1TY(Dd#ln^Yj*{vzCL2x0mZZoNs0tQrQ?YK0}0Q0TfZ+gW{&cB^{9!=+M&E^ zBKI0B)Fo(ag*8!-t07oVDMa0XphTBIkVR)0Cr{|ms*dT^r#~$o*s|PIhBs~n!>Y98 zpxHjuilL(#&nUNY(!_m}UAnQU**DLESdK`Y(P7O|opd!32wI11prIjk(Eajw*NJj4dSn^%9|O*>`QA_a8-(g?sB<>lPd zCF1UgtPBvenRrD!>PLH$SDqDjU``~oxM|0I_($6AGRVf+|6i2uKPeC&)JRlcdS@CO zG;mHdz%~q!8u`5{lqJ|Wic{%#{;1)Dt2(`QgH_TU`lVTlIjb*_#gZ3s;2LgycnBn# za!mp?248QVf$0yE7o_2=wr2Ja$C|7C9k6FWh1LALu^8X2r%LbPYMuJ^YZP10)$`{z}Gwgly@e|H=kcIhjEM-_TsCuyd_5a&%EkojXg zbQ>Ts-wKS<@uSJnO1`!~_`Rv?TzxQqGU(9^rRhcI)NZi`N#1z+^b34Irp&`QQ?HUkm2qV6oFo^V7&DK?_bvF{37&InB2nqePAcwMOh#h~%Q&^8 zNJo=~n1!nnJA*8gGTJqZT99?a#4~1A6LB;8Ehffj+`{+;kPh%e;zY{0;L#zJA7K|! z*2v|xmte{pOs{Cmnt`;hpVNq0DMVVnmr0_7R|2?d*zNIX`dX#zBCA2xIFJX}R%M(^ zbG1XsBc&x#wWLLx;-CK!rQ^!!*1G^snID?i#Q&T!|66#0{qITBM|n-@Bd0d8kPc1| z8f4@bu{pGaAYvdfNP&b^_(a0i_cpR})@kw#Zi;Ut?5J5 ztijW+WWGsxbbAS;7+vL(LN74m5ihZ3Zvz7Meovp}7#t3*L@k!6!Ur~uB8&t&@9Bmo z0Rek7uOL7|?;Tb|`3@w5?tUkO1l~m3y5e;0q&wNLiDTLR2jvIN!x})5jb6cnpm6PFJu>(@22P-$3u9aI# z|DZ@4ujg!)N!Dm>a$*h3UZEz+#T=#T>Z7G9I27kK^t+YByG6E_$sn6AEWsa*H`;Kn z3#MGO#@0ai$O1GCi1z6jrEG*Vj%B%k_5cMtVT4_;r`aB9Fb$3+*Y@Evepy)FTZuHv zqmK@hS>ToDbPPa2qw5heC3e%Dc|z><0f#%$)@Kl`Jq{^cE|UdA&xQ%3lSrxF zUkKf6N?+tCR-Agv(Kl$zGSzM^MCJOzEGAQWRd;JJ*medk+dH<_g)G`3_ij0unxb6j z%53sV_ayil$Em{j8fnF%z~Abrbyz?b@I+{Xf#mtw$k5TgSo|2u59}G&-Q_nH5ra8s zNIdUn*~c6dAO!)ubt*uC@HS4RX}w!FQ1VQezh43CYiuR+n0!W_$rX&y!S7C@_TpIl zBULkJsT5B(hbT3&{;%Gof;{3fs>smPN9s)VNPP{8HC3oV!FI8!Ysfznwpi0LQ23QMCWodA}u--=B(=tN9=tm9X_57w_Ut}_~c zy}vv_ZQ`wy=IoS2nMsOV@cOg0OKg+r9St2*GWKooRFX7djXXYa{n|8*Zfjgh=-gTU zsdZX^_Sk5_rQUAmXn~J#*B)hVpLI_Yg#J+`+tRudmIwGn_Y&Q{Mq*rZrrEx*-I@1? zsd%^&g%Oi&0qspM`lC$NBDHUM1zPyz`+abq5!)4)Lawv-BfK;$%(jw-FqItcl1&7b z_Tx%~u&v9mow6epb}i43CP_lR0;qrq9z6Xo^;zY5T84yp zlW@+cCAOZz8^{E>Xi}D}UBQ0@B-M1w4!}^~a$amf z#Zre)_b#-HJ#{+GVcycmW=MhJzf>(4Pni|63LO+ivK^@aj!=z?J-kU&nd9$-v6-~(2+PEs|F4^G@!8P|Cq@A?{Nn#dRP%d zeN&4=Qj}>SV}*}thN@?VPj0S<;>^ka5~l5Bkr9*Qq19(dGZZHthw`+1gm8#(B+acX z(Mx*j%-r!>=N-W2YC7q}2 z3R8v@)?ZqEhZ#KfgD$?pjvshz3pnr%`UB?*sf?G!uW8$?X6|LnOG<-sv&Oz)&l5NA zO+BRy-_p=Day<3Goro1t;3GY2BA30x66}j4>|^j_E^^;{Vf?PNY_$}*LxmPK2t`ct z3-c3tc4GzI;rUfeR)`)r)e@x_+y?S!BX3I`)39@?_Zl^5RiGOqtiQxnk83=XrVu4~I>XJoVbQ1bkCmkIm?g3la12p2 z?O&zFOpUE1wMW)3iZ|ITTwC!PC!b!>IwJ#$S`T`j-&bH&3r!tonvwe)~Ph&X*In4?y!m~32Lo>#N7 zX{e}ZTz<+}%1DCJ z3AAs)mefls|J$(DTR*|Z#bDFRrQO>@bnpg~L@%Xq5EII`0`uQVROE!xvL@Jx+zdzL612JjxTdKpJdBZWh5=l5Ap^m+fOb|L00{^xmi5C~+j+B*G4+%% zG*1vto(yEQ3>aWSKohs_k>m&eK&f?lkKBPz)PezNT6RsSb=(L!jdfU9 z1GbEbvIVhqYENNVvs6GzCZ8~XMYVj!pwetoY0(<(m`!7I$qAKRy&oJ7n)ewtXk6K$ z0bDebPagoIqLU{WYIZjbsSu)@?bFHvC zFihRtzN>Bptx>I?hK=)N>43&MBTU`mBEhUmeG!b>c7C(iOux7~T%}zr;DGPhFi1Cd zn+>~tcL@izQL*2OjdOn~iTZ>Yt!ie|WwKKTkfzeE7tqA}3>~CfvJHpbez2rYWnCAh z&Fa2USEaVdMr}K{DLmP!23RO@&jTm$xVwdJ6YNi8edw!eqO>jxTVZ_|tFu#Cl%=*^ z*o2u}FRVVLUN7%X%Jix0u~oc8hwHpM(MD?&?$>5}n6I-_UOc5f`;Ex^3?H;svTcJs zlIfGwbEbCb3a2{RDGuOKZ5Qs%Hak~Z?4VvR$vInULu(Z8Z(}_Llr$^%0H1`kvntRotu&yondYSFj!MajCw? zH+x(7bn5r!>ZNGJP(zRw z)1${G2&l3=ak&%2ex~wL2G~)Z*aR9lI60NG{bE|4N4FA=fw>MJK?+Xn(>^AjWi5|p zmkl8&r?s}Llw|EoyWVN2;ewioMaV+D0vE?F5$L09f5AA^LEvIdTdggXNirm0|es&yIwW@dvX0jq!R>>yp zlQ?@b9H<}3f?9sgwcr)-27c{P&(K!eT-rlJV#ZKQOmbcJ$dsjmTCm8O{Dco8W zT@ycv88_KQM^!;CF$*5Xs#L2N;f~veZqBc*jRBb$r5mOjWyr^MR%bXmQ;)?d*}ul< zRk-)YbFj0O91dmcb(alECjj&Gkm>2`sO4AlJf4J>ECIUoO!^ zt_o$(#Iv#4^a+LE+c#H^SjhN|)z8zE(?Y})J-yJY(cj-cS~pn;k9-TqT97~sh#sOo zJ+FX_oO4@x9ym& z*UCF^%i!wJ)84j$atjL@&Yt;W7@BLY-NAA4?gG6;V~$fWj~srYwhW8SkkprYjC@O~ zs9at_rzM`pG{F*>()1N+)JngBdS0rElc8fA?h-1)HnsQLPh*c)m42Ak8!9upi9)y{ zm-!i2Z5ieAt(l#%)#YV1Y)GPnIqBW-@RlbjR!R6-_cl$du*%~@LAwa{itkN zer6ten|MR&+t{>|ER+ikPeZ9`)v!YJ3uS##Z_e_SZX54gNkVlMe-ZRX=T}@>;EnJD zU;1?c8svjwyiw~%P-VX0f@k~=*Z?EThVarCLbjIC4mlr=(KyOsYRGZ-B1Y9m(~_j= zY_xGTH>5Gh4{**8~ee>xgl$WLkqE-d%bYP-!#o5NHN-Sd6AgwZ zSy;n5f*&GL&khos==|_h-kAhHNNsO-Vg_t{agn*U28n*IymFFWYw^P$d3Pe1F(0*0 zL<@6Q-S5OqU_3ZT9KgJC=3k5Y;!Zyrewc(3{BU0193;x%cmm#(_Tf~PI2e5an(P5h zJmP_OJ9~ib^^briRad}H(mvYHeYD5^XkUR~Mt$U+@nN?PwA=XTs_El76(1&IA0{82 ztnYtZ2Y%>-G#}0xi39DAM|_xg{P<`({?YUdXmWl5e1zM_M``=8So`P%pJ2jy+N0>B zm%R@Y;6FRj_VFq3KYGvlXukn$&;Rl0`;Vp&KAbEP2R}YK=>2%ahly_=(1hn9ftX5A z@~4u^ouFq}{?S8Gb^e^TDD(F?s@pt8I{=?t}s_mi6({EoN#vP ztp13AhDx{Hv6XO!x>64982sFTkxkCNJMk>4?Ck81IEs;(Wuc0i>t!4*d2@fpYN}T9 zL;}0YQ1V1}(my346chU*I9Zww4$*__5t)_W%?ApIi}5QJz4LbHglvYTnHlOoYmO$} zV+u@~NuMkAw%z3BHLn+#NUqv&D1SLr6oNyQ^)fOtC(DS4>kj;rXQRuOixQ6`Ev7c9 z<=!j%weo!Uy{uE&;&VdZ@t=peq0RCgTPR0SG!x%%tO}r-*v?mJX_e}q&(tK~`G{86 zr>7S;*K3V$7Wr*rmKR6vxbMLWn&90FpIws@3kAH&WuG?&7WzSr})i&5vgyg)O9 zc7R&sd3m8&nv-W>TSjZ&@txGd=R9BS>oUYX-P->U(Tr|X9ms9i8;?AWuk;^=^Xu`6 zUq-gGx~PN$ciFxFqLy8gQ$p0OTt|FvobIa`UMH_(2inSj?Lfd-w34!tMo5>hda1UI z0a@p%U@Urm9tyS^aqN{oVTQS;}r`edVt38?gy-#ADTd0AsG3UN;rt zj?^bgea2+{(9ejK$Ql&YdIkYqb07Fq1JuE8o% zn)NopKp0TnW4pq^^oZ>Px*|as(A-nHM!{^6-GjPl!1T!M>$=Lo^hhqW{1>3w^)}%^ znjlxHFU0&gpjOH4+q%-g)`>6F{CU2tlV7;{yFs=qZ|Z`aqq|3UWq`OrwX1L1`g?s@ zr?~L-M}S777=`Es|~KRD$$H2@kY`~b&Ih@Z>c+xvv*%OtU(3XaUX6awSJki4HuqTQT1 zKY@I@5|fB!2+H=xO1|1-lvv+|j{#*q|KhhB)+HT1bwk$F7j$9Sg8G@LJN!&U4;kgL zGkNHcNbHlV;0BS{h+CUrjlhbX;Bt)3526Rs8vvmxWy|N=JL9yU(BAfR}V&Lr|Z_I*Ye^OhnULi5Irw;On z-R~}fl(wS<53Lv^wBr?yG&L&*wtwc{P2LatW_yGg+O;}lY# z#4$7|S~iU3^-1RG*M{KXWcU?5rd0AJ@y)@M2LR#X`_8R@hV5&Y?QKB&lLG|Rq__cO zS3F~a;KZUsFG5fC1XU!+Ps1hIr4EGPk^5dw4~DT=f& zcjuN|6}VrRof5sr5#2JRl4I_Un5&#h;-fy02*guFzr*uT$>EYN+(WHWM`MPYbts5Q zPY{UYEvF*0<_i&lvG<0!?28fjg-YBVp{JN8cL%=fkfw2CoNXv#7dMbed{d>e+hP)u zy9b|33u3KV5v&|mW!WtR;6$k^fhfxhaUhn7(;iVypPTb{ z3-X{a_V_O1O>DG)|4A3}qXqIY#BXAPpoXiuYC&K4%EYW6+PehZYgf{hA)@P~JR+-e zMBDU4+fvEV=PfJ6VB}kqt2XuM1KX3A7^G9+m_g?|5HZ_Q=^!UKl)u{~pQK-YF6y=P z9njQ9S4KEfcvV)ipB=xI#^}5BVsDyO$%||FTnN9g&5gd;P4{hkhs;CyS&UJqrR~UQ!Q;?hzt$*i4C+=F~ z>~k`57-k|dBDR=i!d>HOATl{A!y2!~n+bPsRW>XrXmw}J0)-49J z;8BJq7jUz|*sG_&?ZK;vjMX_?CiUq4WDmqE4ojAHwk)<^SYoGjG0^ObI4l7#?J)lctQ|yqWCKV4BF9%)|}y8!K5r|hd+T^UH%^gbc}y)DPKIA;z9W__y#2yFIW+}5mm zZ6WSN#-48g;8DAk&o=H|{vAlyfteOBdCk&&2yAPmrMUTb1HA?qmM1NY@TN z`Tlv$qqW_xn*x2_LI_x#scrDf_Cx-?NY|3DsGuw@$02Z0pz@+)MYoFJQCmxI{&16= z_N<0)3IsNrC9AjPaFbjY-hJMJldKo4XHT(k4BQt=eJ?d%2fAq27e?9Jx4UXW9!q^o z{g+m6QE>E}Hg7{7gMCx|xmItLan_wTD?%O{Gfg%%&O8T$-SFHQ4BuoX+3j)NURQ(l zIWK(sUIZqYE}YMv3gCV@ZuW*eTKSaw@3b6qu)oavM-%U#-#CEF?K-@!2S?*QkQ%-T z@~?GCuHKfw`8jXag*;ODw0D0qJDgxThFg zO2DOw@Cz45V& ze2H_pTjnZq>HygC!M!xHh$~sN)A|)Q;GHB$IN4}N17a}t{Qop|9`ICt{{xrJMP-L% zlkA<5O=bfvAz6_*R|CKkW z@~rmi#I@W>uDx9IFUoupBK(?yA~ReXKcAZHS9x7aHPO;L$Jl?}Dyh`F-R1fG>?G4S=?iRMY8vmAQU(y$e&}8kc0(QCa_B6K| z%k`w?CRmIA^eagPcyC$|2$2oF@5@{HQXzDaSuDYhAAGtwgx6$<8D0g@h z`zb@2=Q=S*3eVBkDqy;4^Q^C!%RGB}no?epXQxcd(`?H7k~~H-4Np5M{Up`nM;R~; z%&qsm$!kwx@;6A_P1>73d1&Xu?l}qE#QO#Iy+6K@W44BdWS>(H!$?jnSUd8!+a-hl zc<%2=H+Uw>=efQ5B<<<-qqL{DjLPiOjG63ON1daD)5uWo9dj4U`Rd)lAG@Pkw40;- z@PVCSsoGRUk#D_ad!Ft)7CC&EiRr20j6(%xB1bgZy5eH}ZLlwf#JIw^e zbs6fDE2=)9&u5vVRlV*!wkJK@>y7%NYGF;-QAW(0LrTob_nS`0Z>u}mFIU>nrQR%6 zn|YUeHnfaJ(S8?%ny;y|OgLBYB`dqRaWm z4S$+n559?2dY%?zyfl*f&NwVdpsP9;QFwtP9iI=?QFpTU4E%3d7n*x-K?`5 zF4fRug~?+n*=Y~+c^B7QCC{f%7xPZ9k-%Oh*Cmgl;}N=Btz~m$cb52Wc3R#PW8Tw} zGbUI&+TW(5qU4&)KeKD*sj5V0B(Zle>LN43*mjJ%_{>&p6cz7=3OkTSpQQS{vBF{$ z}K2mzJR~U^NU^(axi^}ld)jyI`6M}s}-p+O+K`S|I!vf8}f8~E4&u-L= z{vFX+tzgO}%otT0m-*!#wt4FAGxE>f+vWo@sXx*%e{7fQBYVX=Js^F5f1m80MY@yI z@ZEI@pB%hmeh# z)2)Q{Tz8YJoXk}^zpave9GOXBqtY{1ACv~ZKeYHck$L{=#T4UMDfw+DfApWs3|(m3 zp|&u0$1aR!Mpnt6=P52B;)?OnLk(=F@)}KWcGr+McqH6OIndLYsK#T$6SRr>vSb5&u^jIHTbiR*6CPtdUoX|%Pd`** zDL&jJDN`phROmP*Wu1_Y;cn@=#?hi=wAfs{^%K?DLAOfDII4gaaPWG9U?rXP&nn#8 zl6xO}zvexV;tJk4k+0jpAn|HX#;(IEc8<|^=+$u3MRi%q=R~G8#~Ssl4-$C1M!WqwHU zJx!N`$^iK@(a8+9qDhzBFR^Lvn|9wb_(dI=kn7|7MzK(kqrEEdXembQy<1(}9U5Qt z!w2`UQ{0wGc>B&Swkh=T;p4{gNxl0|ZnC=a$I#r*Q&{1nL*R>Cr%Kre&dqs+2Xf21 z_GvsD1MimB{GyLmE(*-ni~17NFdb#E-HP)>4i&?*b#e+@+Ri)oQ1RILoq8r)=aV?T zFg8WQKU}qYfx4gZdp#5J&JrKPm>;5J=(6QW>mp_GuyM~DFwxPd+uX3vZLa~1&xBaGoyY<-5YN4 zBr+D+57)4$JE<(Z?r>JA!j$dPO}X*nO;`H0esjg~kgL}j4%`JDkDyNH; zw648*{!i*y<6tq9&2VV%ulI*Ml1JvB6l@yl`pfX=WT~XCiy5{2(>vu){=UXswFokL zD89YtN`g^S+$Y&0* zm$+3P^&A^GxVW&L^85R&sLz`@M{;}BnZJ?Mcg!T`P=JFwrH@Dp+b#lNg+&u|A(GkJ3Vx&H~U;yOYgkUf1)ggqI5uSzxFQC z?nU-TVOps^_>wrq0}dEFiJ5)fQQbFhp4QC$bDYZR5^i>eHS}V2p&wVJ<)n?! zgOgJ)KRfKUuJ?JNx9~Y-?#LGcz{oDpu&B3*>z;yY>+`?I$Lv$ssps&d%DW_(SBhTgS#igCM^stvN z8lSq%Cezj#M3$Lyjg#u5Xo#d+q->m_T(1sG^2COOPHWLy5#rUyHNs#oR9^f$etjOi zAol+_q?B+Az2)Fu@ZPQs4gQeQJ+}mCPe@bUrnUV@C8lqI`EU`t={3`PtbuNqk@<$q z6SK9mwLVY&M9DL?;jf`zcRHbBSF={^xu({(9ml`i|Dm|>XMR$eOnFu!fl?}HlwQu~ z@bhClHWhgeMe#*_eFFm@v}o!B4)6cM?eoaT#I^iVMULLS2Aj?EoJWH^UZu`&3fcRd z<5av0&*x%CO< z>RT6K|EPGzk7+%*cEi$*$vch`zZC6WcwfCrXJayuaqCKZVuJIgftM95T>;K=b+?8l zZ*7_^yMcV z<#lnl)B0?}=Fg`w?0IL?GE1J;T9m|gr?q2;DEUHkhR$-$~=n1}74x=qWZmX5-7HfV zUJ#gL5%2!t0XV4i>pgH#>4VYYX{SK@<}9ZeIS1CsV=QVBwv0}a_cJaYk;d#` z9JlLm?ibbUD%a8uT|sbAsVlE+2$`V9P>^bY@Zjk)p|MXAlH-y&W|)jpT){!54L)+< zpwbA7WAUZ7U2ZRTq#JO*|HH}0#5T76Iyk7*D3Kw|utnt1ce7)IA7ty4Mn*aes&X_6 zDmxF{sMHBL#S+Cnf5d?0lQ&C|h5ci;Z@Qujyiv2*d%vR@c6925)eBUO3Rf9iihuQW zu%NY;uTAfgno6u+9j$jAi2YcFQ{mfDg=<_6Lp_JK~G(q6tUQVy=c_8!vO zmeSr{9#SVf+W?x5nn16!TjSihPT@Pvh`)Tbzqy8>PVZ?DY&rm)?AN z?K#8A*g50w{U&R_$qVkEM@zI~lPPQ&qWG5r{6aDEE<&R^+yY|vyA`Utq)tk1l|6i3 zBUNo$S0Z4q2t}0chRo*?7mkUrr2cU<*8SUeN#IE;g=wKbc?F2>)KIv6}&xOIuDE#~t!} zLee#-)&1I+a_`OEWM#=EN^#!U>wGwyE7fKukh?E+U-}go7e1+UZ~S{{IlOtv*{##Vso&J8Sk?oU(x5q``<^#>!&@X z=nZC8=NkIC;bpguclWLb;`6dRIo|%1o2C8hU;cJDPgA|-o#UP0q`@|m!geVT6Zk*) zlLr4+YnwA7Gj2TNeOG}c;f#s3!De~J`{2n&%jXf_v zH|Ngy-Sitn1volm9XTrnD;XIrEgAlM>8*!rc7yNR1O9-&{rACP@xT6)!c5!T$Uxu1 zQp(I=nLrN+U`GKU{CfoaLig`KHjqtghp#29eGGrJ;=lhB`~s#IDkqk7l4~# z@S-k%5&Q}8w`-ngS$Sf)_gMR!0qmO?36GKCziAHgoV`EfE8%Th4|$Xq960h%JInpo z+V6!wyb1EK3Gu_LogXO-{Ug_DAhg_yL0p1c{*@NdWyJ~%8nczGF#gaWG8pmLS~pfT{? zvjBkte&Vn+z{C&`WTT2A3eW#<-GSehe_)+P#7++uz~M{Ql!kf&icq zuI^Z+9RuEgE}h)UfhBOB-4p}24B)*dYHjmIQamLT`AoiE!+Zn01<+ckK~;g(A@Z#l zR31gLz10+j#(_+dU778}+xV#d{KGatdO;);GkD__NhZaW92rg;bncEf z0wg9NlK?^W|M>>jW*7D2PS;HPD0)4tq$11P*_AsjIuw%F(5887o?r4zerTD&eFQ?W9tC z|3>V@2jr1SiYPM8HGG|}1|d>uCU_8RmfFjv=%JHd3fD}yR z!F=Y3q-76Ak?V$zNEd#1XazKAjB@L9r05DO@v zc$}AWy=DuL><5ttOCKP~Lm8ZP+~mpZfqaC!oC!{X!>U@tc!b^vWpGxFkLnlHwMdIZnim!H07;m|y%khOm%WtLngPi1C@PW}FttH%A47Y2{ zuwWBDDu_oyUVj*G#SV%n@v8IOCgvpYJ2fIN77Rir$)ZH+g_%d(H9)2?pfP@k7x#rA zl2$Z^B3Rm$`6zOLdklob@5Um0aM)L&FdRW;diMVI%X_on`VyOTPDb`y_A@XT!EaG| z$`o%YH~a`V0>ly^$OL}kuzC?N5lS7ej!c~4q z1n>wa%o9Y!VVR;y@lXQ(jkJ3>K2ahbX8g{>aBIT=rLo38ROXRqTHf(68dP~GLBHAI z!7Q@!MiWW#c|9B3_ZUap%0;}P5hh4iVayYMj}5hs}5mk>$I!GO|ECeHU| z7r;C%4WyH+H4D7cxn4n}AxA;zTx*dAh|Wc#N`w4;M2wIEid+1>Y?X`87lFOf*CFDBVicHPj0;jeB z68;<#Xzuq3RPYdUKa@PCHn+J3d6d&{hmj!0R6vpJ&NQ}(9Vov6uQ7tE{_ATyI$=D> z9Me=I$eFq005S=!l3{vpl z>jyGTCr4c`Pd|TqPk%pT9h&VvGnft7Q6Ti7eiZQ<#wT>1D1P)_Jmexim-8kHV%s+` z62uxxD1J0Dcl??j;1OrR6mLoKTKb6J5Rn%(Ns31}ZxEiHEQEVLt^hpZ@+i{> z7;kMSgOWJC)sdHR1GE4l?NLs{Xi)8;@Lrw1Ciw;Mi09PBUr6!fQ1ZmiKRbe-1KvF% zKN$vvHl+TBa032Xz2TQ0ysAxi1OSi5m(oNb9zFCOMgmnYhvPb40iKTVE1}jx@#XmL zo>O#UAaseRb*b|(C?RxFvYjL5*Gk)gXvCB1pNq&OS(K!%R6IEF5>VGOP<#h@&W=wAbtN^n+c1 z$4tcgKt+m&Rtz(7R84XRmLM)12GhcLE0#bjgMAZ~YUu)c!>{l_7e=-)z)0Hurd}>C zXocI|MMJUu0JIZ;1R#bGXG8%R05f>`1|ypwgmHEU{w6af&`eMa{rACPb(vtim6`uq zOTdoKhkNqNb!@FZz#f^v9{m5%_^gp`0}Q^FDuj~KKHP4>PX}=LS}wGIf5;5Ot(XI? zB78x-B>w~;nF7I}=J?HuOhSo^I_wE8{A7oC@^qU627UEU(**T$E4P4kJN)10|@G1Nbv%fKbHa z{WmpIIJ70I`yEBDfgk|1z=E6rMEydVq!(H6eU{N_Z)CRV3CkFcL&rXcdIXhT?>CfEP=oeB%gGJhZik(}o62 zTLF)_UbsGz6b~i2XC2UI!uOB#MB)WSlfo&XlsWR1-81oZG~)W>=kqY$n!Z~Ig{D86 zG=f|los5UkAjUwkZ9%{()n7n3V%wS~k;0*^on;knj*kMoI3ndmT_D9%L@~yMKuOMh zz*;2oV)IlOZzZtMR^gO+PwjmHBqOf(sa-=Rp@`J=OTY_%i02m&@6f=~&Oipqq2y{I zSqYS90Euw_%W7af%z}|1wvFcJt5VBjYX8=OOmr;IX;Dk}Y=ehtQV zb)uF!<-vF>%15g~o}rTpLiXCzH(?}5OHteA5-$ZO=|g(Pt(LFKu9M6C#T*%_ko zeSI-09$Gz;SIiQFoa+9%M~Wwl5~Yy~>+-h&!l!tMc#M~DKHA^FGP{X=Z{fr=1SoO!D5wp_!>{03IJtN@dHVm4IX9Qed1@YD)L$SNI3W#{>3~74{9WDskej;H zza-=g)KC19LU<31vf{F6%Yla7I4&$unkJA5+TRNFA(POSoKKs~o$&x9bwGm7@P-GF zNhtl#m2Rch3jl&8>MtUOVIYVd(N?fM*)-#XfndZ%fPHUB@zB<>JNaK;hylDpAfo`p zgpVgk@f1-8mUmI2HeUgcI7>J?Ns5P742+~Jj6MfsdrUN{mwkZo2>ls~WPuFB?v6k* z;&eIS3o;1-grBqv`-afr%UJliEO_nk-v@_f{t5#bc?R0MyEDhigK-J(hrw@+hZh6jH?XfFTff1O5bs0jc;Q!Vuj1t9VK_phqGU$!+lE(3!HX~w zL@>hL!|;Z}FPL5Bz5Gpzhg59G&(h%cd9L!x|H637FTWz)?gsfnRE|itA%0i9-<&e(3)yj~1*9Bh>~_0u_FY`zk1m z69t4qf*%vTN^<3ak=AMqd8CK$<6nibfoU|PeQRMtXv@X$J+Q01t?HzBNQ=fun*&#Q zM>NUdTWx^2L|X;dMm&D|4SYw{D)1C|9T2v6hX}a3ziPJ$6)236k=+IV$SIPMeZ{{_ GNcMjLnE!kL literal 0 HcmV?d00001 diff --git a/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom new file mode 100644 index 00000000..0dc1aedc --- /dev/null +++ b/lib/org/ciyam/AT/1.4.0/AT-1.4.0.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.ciyam + AT + 1.4.0 + POM was created from install:install-file + diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 8f8b1f6e..063c735d 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,14 +3,15 @@ org.ciyam AT - 1.3.8 + 1.4.0 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 + 1.4.0 - 20200925114415 + 20221105114346 diff --git a/pom.xml b/pom.xml index eb306420..860cdce5 100644 --- a/pom.xml +++ b/pom.xml @@ -11,7 +11,7 @@ 0.15.10 1.69 ${maven.build.timestamp} - 1.3.8 + 1.4.0 3.6 1.8 2.6 From db2244594836dcd952e97746b2ec529483ddfd98 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 6 Nov 2022 14:52:14 +0000 Subject: [PATCH 10/97] Include API key automatically in publish-auto-update-v5.pl --- tools/publish-auto-update-v5.pl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/publish-auto-update-v5.pl b/tools/publish-auto-update-v5.pl index aad49d4e..f97fe115 100755 --- a/tools/publish-auto-update-v5.pl +++ b/tools/publish-auto-update-v5.pl @@ -4,6 +4,7 @@ use strict; use warnings; use POSIX; use Getopt::Std; +use File::Slurp; sub usage() { die("usage: $0 [-p api-port] dev-private-key [short-commit-hash]\n"); @@ -34,6 +35,8 @@ while () { } close(POM); +my $apikey = read_file('apikey.txt'); + # Do we need to determine commit hash? unless ($commit_hash) { # determine git branch @@ -124,7 +127,7 @@ my $raw_tx = `curl --silent --url http://localhost:${port}/utils/tobase58/${raw_ die("Can't convert raw transaction hex to base58:\n$raw_tx\n") unless $raw_tx =~ m/^\w{300,320}$/; # Roughly 305 to 320 base58 chars printf "\nRaw transaction (base58):\n%s\n", $raw_tx; -my $computed_tx = `curl --silent -X POST --url http://localhost:${port}/arbitrary/compute -d "${raw_tx}"`; +my $computed_tx = `curl --silent -X POST --url http://localhost:${port}/arbitrary/compute -H "X-API-KEY: ${apikey}" -d "${raw_tx}"`; die("Can't compute nonce for transaction:\n$computed_tx\n") unless $computed_tx =~ m/^\w{300,320}$/; # Roughly 300 to 320 base58 chars printf "\nRaw computed transaction (base58):\n%s\n", $computed_tx; From 9255df46cf1d3724fa6484fe532e37d74b07a8ce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 6 Nov 2022 19:46:12 +0000 Subject: [PATCH 11/97] Script updates to support add/remove dev group admins --- tools/approve-dev-transaction.sh | 97 ++++++++++++++++++++++++++++++++ tools/tx.pl | 7 ++- 2 files changed, 103 insertions(+), 1 deletion(-) create mode 100755 tools/approve-dev-transaction.sh diff --git a/tools/approve-dev-transaction.sh b/tools/approve-dev-transaction.sh new file mode 100755 index 00000000..6b611b59 --- /dev/null +++ b/tools/approve-dev-transaction.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +port=12391 +if [ $# -gt 0 -a "$1" = "-t" ]; then + port=62391 +fi + +printf "Searching for auto-update transactions to approve...\n"; + +tx=$( curl --silent --url "http://localhost:${port}/transactions/search?txGroupId=1&txType=ADD_GROUP_ADMIN&txType=REMOVE_GROUP_ADMIN&confirmationStatus=CONFIRMED&limit=1&reverse=true" ); +if fgrep --silent '"approvalStatus":"PENDING"' <<< "${tx}"; then + true +else + echo "Can't find any pending transactions" + exit +fi + +sig=$( perl -n -e 'print $1 if m/"signature":"(\w+)"/' <<< "${tx}" ) +if [ -z "${sig}" ]; then + printf "Can't find transaction signature in JSON:\n%s\n" "${tx}" + exit +fi + +printf "Found transaction %s\n" $sig; + +printf "\nPaste your dev account private key:\n"; +IFS= +read -s privkey +printf "\n" + +# Convert to public key +pubkey=$( curl --silent --url "http://localhost:${port}/utils/publickey" --data @- <<< "${privkey}" ); +if egrep -v --silent '^\w{44,46}$' <<< "${pubkey}"; then + printf "Invalid response from API - was your private key correct?\n%s\n" "${pubkey}" + exit +fi +printf "Your public key: %s\n" ${pubkey} + +# Convert to address +address=$( curl --silent --url "http://localhost:${port}/addresses/convert/${pubkey}" ); +printf "Your address: %s\n" ${address} + +# Grab last reference +lastref=$( curl --silent --url "http://localhost:${port}/addresses/lastreference/{$address}" ); +printf "Your last reference: %s\n" ${lastref} + +# Build GROUP_APPROVAL transaction +timestamp=$( date +%s )000 +tx_json=$( cat < 0; seconds--)); do + if [ "${seconds}" = "1" ]; then + plural="" + fi + printf "\rBroadcasting in %d second%s...(CTRL-C) to abort " $seconds $plural + sleep 1 +done + +printf "\rBroadcasting signed GROUP_APPROVAL transaction... \n" +result=$( curl --silent --url "http://localhost:${port}/transactions/process" --data @- <<< "${signed_tx}" ) +printf "API response:\n%s\n" "${result}" diff --git a/tools/tx.pl b/tools/tx.pl index db6958e2..fe3cd872 100755 --- a/tools/tx.pl +++ b/tools/tx.pl @@ -71,9 +71,14 @@ our %TRANSACTION_TYPES = ( }, add_group_admin => { url => 'groups/addadmin', - required => [qw(groupId member)], + required => [qw(groupId txGroupId member)], key_name => 'ownerPublicKey', }, + remove_group_admin => { + url => 'groups/removeadmin', + required => [qw(groupId txGroupId admin)], + key_name => 'ownerPublicKey', + }, group_approval => { url => 'groups/approval', required => [qw(pendingSignature approval)], From 4e829a2d05819aa8f694e4bcff9667bc16b42ab2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 7 Nov 2022 21:12:34 +0000 Subject: [PATCH 12/97] Bump version to 3.7.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 860cdce5..52c574b0 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.6.4 + 3.7.0 jar true From b0c9ce7482d93f74b8724f392f9f103080d60956 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:19:23 +0000 Subject: [PATCH 13/97] Add blocks minted penalty to Accounts table --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 1174f5c8..33466af4 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -975,6 +975,11 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN receiving_account_info SET DATA TYPE VARBINARY(128)"); break; + case 44: + // Add blocks minted penalty + stmt.execute("ALTER TABLE Accounts ADD blocks_minted_penalty INTEGER NOT NULL DEFAULT 0"); + break; + default: // nothing to do return false; From 617c801cbd7a5790498312a8927e716e8767fc18 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:25:44 +0000 Subject: [PATCH 14/97] Made Block.ExpandedAccount public, and added some more getters. This is needed for upcoming additional validation and unit tests. --- src/main/java/org/qortal/block/Block.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 5e838458..52e3b3ef 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -136,7 +136,7 @@ public class Block { } /** Lazy-instantiated expanded info on block's online accounts. */ - private static class ExpandedAccount { + public static class ExpandedAccount { private final RewardShareData rewardShareData; private final int sharePercent; private final boolean isRecipientAlsoMinter; @@ -169,6 +169,13 @@ public class Block { } } + public Account getMintingAccount() { + return this.mintingAccount; + } + public Account getRecipientAccount() { + return this.recipientAccount; + } + /** * Returns share bin for expanded account. *

From 68a0923582f59f36b2f5f271530cddfb65124dcf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:37:02 +0000 Subject: [PATCH 15/97] Disallow level 0 minters in blocks, and exclude them when minting a new block. The validation is currently set to a feature trigger of height 0, although this will likely be set to a future block, in case there are any cases in the chain's history where this validation may fail (e.g. transfer privs?) --- src/main/java/org/qortal/block/Block.java | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 52e3b3ef..3f130359 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -370,12 +370,24 @@ public class Block { return null; } + int height = parentBlockData.getHeight() + 1; long timestamp = calcTimestamp(parentBlockData, minter.getPublicKey(), minterLevel); long onlineAccountsTimestamp = OnlineAccountsManager.getCurrentOnlineAccountTimestamp(); // Fetch our list of online accounts, removing any that are missing a nonce List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); + + // Remove any online accounts that are level 0 + onlineAccounts.removeIf(a -> { + try { + return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; + } catch (DataException e) { + // Something went wrong, so remove the account + return true; + } + }); + if (onlineAccounts.isEmpty()) { LOGGER.debug("No online accounts - not even our own?"); return null; @@ -442,7 +454,6 @@ public class Block { int transactionCount = 0; byte[] transactionsSignature = null; - int height = parentBlockData.getHeight() + 1; int atCount = 0; long atFees = 0; @@ -1036,6 +1047,15 @@ public class Block { if (onlineRewardShares == null) return ValidationResult.ONLINE_ACCOUNT_UNKNOWN; + // After feature trigger, require all online account minters to be greater than level 0 + if (this.getBlockData().getHeight() >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + List expandedAccounts = this.getExpandedAccounts(); + for (ExpandedAccount account : expandedAccounts) { + if (account.getMintingAccount().getEffectiveMintingLevel() == 0) + return ValidationResult.ONLINE_ACCOUNTS_INVALID; + } + } + // If block is past a certain age then we simply assume the signatures were correct long signatureRequirementThreshold = NTP.getTime() - BlockChain.getInstance().getOnlineAccountSignaturesMinLifetime(); if (this.blockData.getTimestamp() < signatureRequirementThreshold) From 1c8a6ce20436153bc10bd3c9e4f3ead2812440fe Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:52:27 +0000 Subject: [PATCH 16/97] When synchronizing, filter out peers that have a recent block with an invalid signer. This avoids the wasted time and consensus confusion causes by syncing and then validation failing. This is significant after the algo has run, as many signers will become invalid. --- .../org/qortal/controller/Controller.java | 22 +++++++++++++++++++ .../org/qortal/controller/Synchronizer.java | 3 +++ 2 files changed, 25 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index bcd010e8..f2ca853d 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -29,6 +29,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qortal.account.Account; import org.qortal.api.ApiService; import org.qortal.api.DomainMapService; import org.qortal.api.GatewayService; @@ -756,6 +757,27 @@ public class Controller extends Thread { return peer.isAtLeastVersion(minPeerVersion) == false; }; + public static final Predicate hasInvalidSigner = peer -> { + final List peerChainTipSummaries = peer.getChainTipSummaries(); + if (peerChainTipSummaries == null) { + return true; + } + + try (Repository repository = RepositoryManager.getRepository()) { + for (BlockSummaryData blockSummaryData : peerChainTipSummaries) { + if (Account.getRewardShareEffectiveMintingLevel(repository, blockSummaryData.getMinterPublicKey()) == 0) { + return true; + } + } + } catch (DataException e) { + return true; + } + + // We got this far without encountering invalid or missing summaries, nor was an exception thrown, + // so it is safe to assume that all of this peer's recent blocks had a valid signer. + return false; + }; + private long getRandomRepositoryMaintenanceInterval() { final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index cd9483e9..e3ace9ed 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -247,6 +247,9 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); + // Disregard peers that have a block with an invalid signer + peers.removeIf(Controller.hasInvalidSigner); + final int peersBeforeComparison = peers.size(); // Request recent block summaries from the remaining peers, and locate our common block with each From 9c3a4d6e371f5ba3325b326a192c67bb95a5ec6c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 11:55:27 +0000 Subject: [PATCH 17/97] BlockChain.java additions for onlineAccountMinterLevelValidationHeight, which were missing from commit 68a0923 --- src/main/java/org/qortal/block/BlockChain.java | 7 ++++++- src/main/resources/blockchain.json | 3 ++- src/test/resources/test-chain-v2-block-timestamps.json | 3 ++- src/test/resources/test-chain-v2-disable-reference.json | 3 ++- src/test/resources/test-chain-v2-founder-rewards.json | 3 ++- src/test/resources/test-chain-v2-leftover-reward.json | 3 ++- src/test/resources/test-chain-v2-minting.json | 3 ++- src/test/resources/test-chain-v2-qora-holder-extremes.json | 3 ++- .../resources/test-chain-v2-qora-holder-reduction.json | 3 ++- src/test/resources/test-chain-v2-qora-holder.json | 3 ++- src/test/resources/test-chain-v2-reward-levels.json | 3 ++- src/test/resources/test-chain-v2-reward-scaling.json | 3 ++- src/test/resources/test-chain-v2-reward-shares.json | 3 ++- src/test/resources/test-chain-v2.json | 3 ++- 14 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 5e1f44f3..75513e83 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -74,7 +74,8 @@ public class BlockChain { transactionV5Timestamp, transactionV6Timestamp, disableReferenceTimestamp, - increaseOnlineAccountsDifficultyTimestamp; + increaseOnlineAccountsDifficultyTimestamp, + onlineAccountMinterLevelValidationHeight, } // Custom transaction fees @@ -483,6 +484,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); } + public long getOnlineAccountMinterLevelValidationHeight() { + return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 34671c76..6189ad36 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -80,7 +80,8 @@ "transactionV5Timestamp": 1642176000000, "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 4a883bd9..c1ab9db0 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -70,7 +70,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index e8fee5e0..de653a36 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -73,7 +73,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 17a713a0..5af3b381 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index b57c3195..40310517 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 60b3cd76..bb12e314 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 2d044687..04ecec99 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 3cf8848e..5b9ecbc4 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -75,7 +75,8 @@ "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, "aggregateSignatureTimestamp": 0, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 93965b76..86aea1b3 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 06422e71..e39033de 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 6adcd0ac..1170a5a1 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 95324b56..550dca01 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 84c692d5..69f486fb 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -74,7 +74,8 @@ "transactionV5Timestamp": 0, "transactionV6Timestamp": 0, "disableReferenceTimestamp": 9999999999999, - "increaseOnlineAccountsDifficultyTimestamp": 9999999999999 + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, }, "genesisInfo": { "version": 4, From f50c0c87ddc5e5b57508ac12bc63b429e555088a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 12:03:13 +0000 Subject: [PATCH 18/97] Account repository modifications for blocksMintedPenalty. --- .../org/qortal/data/account/AccountData.java | 14 +++- .../network/message/AccountMessage.java | 6 +- .../qortal/repository/AccountRepository.java | 23 +++-- .../hsqldb/HSQLDBAccountRepository.java | 83 ++++++++++++++++--- 4 files changed, 106 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/qortal/data/account/AccountData.java b/src/main/java/org/qortal/data/account/AccountData.java index 4d662f04..868d1bc1 100644 --- a/src/main/java/org/qortal/data/account/AccountData.java +++ b/src/main/java/org/qortal/data/account/AccountData.java @@ -18,6 +18,7 @@ public class AccountData { protected int level; protected int blocksMinted; protected int blocksMintedAdjustment; + protected int blocksMintedPenalty; // Constructors @@ -25,7 +26,7 @@ public class AccountData { protected AccountData() { } - public AccountData(String address, byte[] reference, byte[] publicKey, int defaultGroupId, int flags, int level, int blocksMinted, int blocksMintedAdjustment) { + public AccountData(String address, byte[] reference, byte[] publicKey, int defaultGroupId, int flags, int level, int blocksMinted, int blocksMintedAdjustment, int blocksMintedPenalty) { this.address = address; this.reference = reference; this.publicKey = publicKey; @@ -34,10 +35,11 @@ public class AccountData { this.level = level; this.blocksMinted = blocksMinted; this.blocksMintedAdjustment = blocksMintedAdjustment; + this.blocksMintedPenalty = blocksMintedPenalty; } public AccountData(String address) { - this(address, null, null, Group.NO_GROUP, 0, 0, 0, 0); + this(address, null, null, Group.NO_GROUP, 0, 0, 0, 0, 0); } // Getters/Setters @@ -102,6 +104,14 @@ public class AccountData { this.blocksMintedAdjustment = blocksMintedAdjustment; } + public int getBlocksMintedPenalty() { + return this.blocksMintedPenalty; + } + + public void setBlocksMintedPenalty(int blocksMintedPenalty) { + this.blocksMintedPenalty = blocksMintedPenalty; + } + // Comparison @Override diff --git a/src/main/java/org/qortal/network/message/AccountMessage.java b/src/main/java/org/qortal/network/message/AccountMessage.java index d22ef879..453862b0 100644 --- a/src/main/java/org/qortal/network/message/AccountMessage.java +++ b/src/main/java/org/qortal/network/message/AccountMessage.java @@ -41,6 +41,8 @@ public class AccountMessage extends Message { bytes.write(Ints.toByteArray(accountData.getBlocksMintedAdjustment())); + bytes.write(Ints.toByteArray(accountData.getBlocksMintedPenalty())); + } catch (IOException e) { throw new AssertionError("IOException shouldn't occur with ByteArrayOutputStream"); } @@ -80,7 +82,9 @@ public class AccountMessage extends Message { int blocksMintedAdjustment = byteBuffer.getInt(); - AccountData accountData = new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment); + int blocksMintedPenalty = byteBuffer.getInt(); + + AccountData accountData = new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty); return new AccountMessage(id, accountData); } diff --git a/src/main/java/org/qortal/repository/AccountRepository.java b/src/main/java/org/qortal/repository/AccountRepository.java index 281f34f1..1175337c 100644 --- a/src/main/java/org/qortal/repository/AccountRepository.java +++ b/src/main/java/org/qortal/repository/AccountRepository.java @@ -1,13 +1,9 @@ package org.qortal.repository; import java.util.List; +import java.util.Set; -import org.qortal.data.account.AccountBalanceData; -import org.qortal.data.account.AccountData; -import org.qortal.data.account.EligibleQoraHolderData; -import org.qortal.data.account.MintingAccountData; -import org.qortal.data.account.QortFromQoraData; -import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.*; public interface AccountRepository { @@ -19,6 +15,9 @@ public interface AccountRepository { /** Returns accounts with any bit set in given mask. */ public List getFlaggedAccounts(int mask) throws DataException; + /** Returns accounts with a blockedMintedPenalty */ + public List getPenaltyAccounts() throws DataException; + /** Returns account's last reference or null if not set or account not found. */ public byte[] getLastReference(String address) throws DataException; @@ -100,6 +99,18 @@ public interface AccountRepository { */ public void modifyMintedBlockCounts(List addresses, int delta) throws DataException; + /** Returns account's block minted penalty count or null if account not found. */ + public Integer getBlocksMintedPenaltyCount(String address) throws DataException; + + /** + * Sets blocks minted penalties for given list of accounts. + * This replaces the existing values rather than modifying them by a delta. + * + * @param accountPenalties + * @throws DataException + */ + public void updateBlocksMintedPenalties(Set accountPenalties) throws DataException; + /** Delete account from repository. */ public void delete(String address) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java index 9fdb0a3f..cb188502 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBAccountRepository.java @@ -6,15 +6,11 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.qortal.asset.Asset; -import org.qortal.data.account.AccountBalanceData; -import org.qortal.data.account.AccountData; -import org.qortal.data.account.EligibleQoraHolderData; -import org.qortal.data.account.MintingAccountData; -import org.qortal.data.account.QortFromQoraData; -import org.qortal.data.account.RewardShareData; +import org.qortal.data.account.*; import org.qortal.repository.AccountRepository; import org.qortal.repository.DataException; @@ -30,7 +26,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public AccountData getAccount(String address) throws DataException { - String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment FROM Accounts WHERE account = ?"; + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty FROM Accounts WHERE account = ?"; try (ResultSet resultSet = this.repository.checkedExecute(sql, address)) { if (resultSet == null) @@ -43,8 +39,9 @@ public class HSQLDBAccountRepository implements AccountRepository { int level = resultSet.getInt(5); int blocksMinted = resultSet.getInt(6); int blocksMintedAdjustment = resultSet.getInt(7); + int blocksMintedPenalty = resultSet.getInt(8); - return new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment); + return new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty); } catch (SQLException e) { throw new DataException("Unable to fetch account info from repository", e); } @@ -52,7 +49,7 @@ public class HSQLDBAccountRepository implements AccountRepository { @Override public List getFlaggedAccounts(int mask) throws DataException { - String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, account FROM Accounts WHERE BITAND(flags, ?) != 0"; + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty, account FROM Accounts WHERE BITAND(flags, ?) != 0"; List accounts = new ArrayList<>(); @@ -68,9 +65,10 @@ public class HSQLDBAccountRepository implements AccountRepository { int level = resultSet.getInt(5); int blocksMinted = resultSet.getInt(6); int blocksMintedAdjustment = resultSet.getInt(7); - String address = resultSet.getString(8); + int blocksMintedPenalty = resultSet.getInt(8); + String address = resultSet.getString(9); - accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment)); + accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty)); } while (resultSet.next()); return accounts; @@ -79,6 +77,36 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public List getPenaltyAccounts() throws DataException { + String sql = "SELECT reference, public_key, default_group_id, flags, level, blocks_minted, blocks_minted_adjustment, blocks_minted_penalty, account FROM Accounts WHERE blocks_minted_penalty != 0"; + + List accounts = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return accounts; + + do { + byte[] reference = resultSet.getBytes(1); + byte[] publicKey = resultSet.getBytes(2); + int defaultGroupId = resultSet.getInt(3); + int flags = resultSet.getInt(4); + int level = resultSet.getInt(5); + int blocksMinted = resultSet.getInt(6); + int blocksMintedAdjustment = resultSet.getInt(7); + int blocksMintedPenalty = resultSet.getInt(8); + String address = resultSet.getString(9); + + accounts.add(new AccountData(address, reference, publicKey, defaultGroupId, flags, level, blocksMinted, blocksMintedAdjustment, blocksMintedPenalty)); + } while (resultSet.next()); + + return accounts; + } catch (SQLException e) { + throw new DataException("Unable to fetch penalty accounts from repository", e); + } + } + @Override public byte[] getLastReference(String address) throws DataException { String sql = "SELECT reference FROM Accounts WHERE account = ?"; @@ -298,6 +326,39 @@ public class HSQLDBAccountRepository implements AccountRepository { } } + @Override + public Integer getBlocksMintedPenaltyCount(String address) throws DataException { + String sql = "SELECT blocks_minted_penalty FROM Accounts WHERE account = ?"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql, address)) { + if (resultSet == null) + return null; + + return resultSet.getInt(1); + } catch (SQLException e) { + throw new DataException("Unable to fetch account's block minted penalty count from repository", e); + } + } + public void updateBlocksMintedPenalties(Set accountPenalties) throws DataException { + // Nothing to do? + if (accountPenalties == null || accountPenalties.isEmpty()) + return; + + // Map balance changes into SQL bind params, filtering out no-op changes + List updateBlocksMintedPenaltyParams = accountPenalties.stream() + .map(accountPenalty -> new Object[] { accountPenalty.getAddress(), accountPenalty.getBlocksMintedPenalty(), accountPenalty.getBlocksMintedPenalty() }) + .collect(Collectors.toList()); + + // Perform actual balance changes + String sql = "INSERT INTO Accounts (account, blocks_minted_penalty) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE blocks_minted_penalty = blocks_minted_penalty + ?"; + try { + this.repository.executeCheckedBatchUpdate(sql, updateBlocksMintedPenaltyParams); + } catch (SQLException e) { + throw new DataException("Unable to set blocks minted penalties in repository", e); + } + } + @Override public void delete(String address) throws DataException { // NOTE: Account balances are deleted automatically by the database thanks to "ON DELETE CASCADE" in AccountBalances' FOREIGN KEY From ab687af4bbb0df936175331f4cf00959ce7884ab Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 12:21:43 +0000 Subject: [PATCH 19/97] Added new db query to fetch a list of all accounts that have created a non-self-share, based on confirmed transactions. This will be used as the input dataset for the self sponsorship algo. --- .../repository/TransactionRepository.java | 9 +++++ .../HSQLDBTransactionRepository.java | 33 ++++++++++++++++--- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 4fb9bb12..105a317d 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -179,6 +179,15 @@ public interface TransactionRepository { public List getAssetTransfers(long assetId, String address, Integer limit, Integer offset, Boolean reverse) throws DataException; + /** + * Returns list of reward share transaction creators, excluding self shares. + * This uses confirmed transactions only. + * + * @return + * @throws DataException + */ + public List getConfirmedRewardShareCreatorsExcludingSelfShares() throws DataException; + /** * Returns list of transactions pending approval, with optional txGgroupId filtering. *

diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index e3ef13be..a8df1ab5 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -7,11 +7,7 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.sql.ResultSet; import java.sql.SQLException; -import java.util.ArrayList; -import java.util.EnumMap; -import java.util.EnumSet; -import java.util.List; -import java.util.Map; +import java.util.*; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -969,6 +965,33 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + public List getConfirmedRewardShareCreatorsExcludingSelfShares() throws DataException { + List rewardShareCreators = new ArrayList<>(); + + String sql = "SELECT account " + + "FROM RewardShareTransactions " + + "JOIN Accounts ON Accounts.public_key = RewardShareTransactions.minter_public_key " + + "JOIN Transactions ON Transactions.signature = RewardShareTransactions.signature " + + "WHERE block_height IS NOT NULL AND RewardShareTransactions.recipient != Accounts.account " + + "GROUP BY account " + + "ORDER BY account"; + + try (ResultSet resultSet = this.repository.checkedExecute(sql)) { + if (resultSet == null) + return rewardShareCreators; + + do { + String address = resultSet.getString(1); + + rewardShareCreators.add(address); + } while (resultSet.next()); + + return rewardShareCreators; + } catch (SQLException e) { + throw new DataException("Unable to fetch reward share creators from repository", e); + } + } + @Override public List getApprovalPendingTransactions(Integer txGroupId, Integer limit, Integer offset, Boolean reverse) throws DataException { StringBuilder sql = new StringBuilder(512); From 7003a8274b5fed6c795ac0b4a17bb0cace0dc517 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 15:51:06 +0000 Subject: [PATCH 20/97] Added some API endpoints relating to penalties. Relies on some code not yet committed. --- .../qortal/api/model/AccountPenaltyStats.java | 56 +++++++++++++++++++ .../api/resource/AddressesResource.java | 51 +++++++++++++++++ .../data/account/AccountPenaltyData.java | 52 +++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 src/main/java/org/qortal/api/model/AccountPenaltyStats.java create mode 100644 src/main/java/org/qortal/data/account/AccountPenaltyData.java diff --git a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java new file mode 100644 index 00000000..68c3a6ed --- /dev/null +++ b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java @@ -0,0 +1,56 @@ +package org.qortal.api.model; + +import org.qortal.block.SelfSponsorshipAlgoV1Block; +import org.qortal.data.account.AccountData; +import org.qortal.data.naming.NameData; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; +import java.util.ArrayList; +import java.util.List; + +@XmlAccessorType(XmlAccessType.FIELD) +public class AccountPenaltyStats { + + public int totalPenalties; + public int maxPenalty; + public int minPenalty; + public String penaltyHash; + + protected AccountPenaltyStats() { + } + + public AccountPenaltyStats(int totalPenalties, int maxPenalty, int minPenalty, String penaltyHash) { + this.totalPenalties = totalPenalties; + this.maxPenalty = maxPenalty; + this.minPenalty = minPenalty; + this.penaltyHash = penaltyHash; + } + + public static AccountPenaltyStats fromAccounts(List accounts) { + int totalPenalties = 0; + Integer maxPenalty = null; + Integer minPenalty = null; + + List addresses = new ArrayList<>(); + for (AccountData accountData : accounts) { + int penalty = accountData.getBlocksMintedPenalty(); + addresses.add(accountData.getAddress()); + totalPenalties++; + + // Penalties are expressed as a negative number, so the min and the max are reversed here + if (maxPenalty == null || penalty < maxPenalty) maxPenalty = penalty; + if (minPenalty == null || penalty > minPenalty) minPenalty = penalty; + } + + String penaltyHash = SelfSponsorshipAlgoV1Block.getHash(addresses); + return new AccountPenaltyStats(totalPenalties, maxPenalty, minPenalty, penaltyHash); + } + + + @Override + public String toString() { + return String.format("totalPenalties: %d, maxPenalty: %d, minPenalty: %d, penaltyHash: %s", totalPenalties, maxPenalty, minPenalty, penaltyHash == null ? "null" : penaltyHash); + } +} diff --git a/src/main/java/org/qortal/api/resource/AddressesResource.java b/src/main/java/org/qortal/api/resource/AddressesResource.java index 468b90a8..79cb6e05 100644 --- a/src/main/java/org/qortal/api/resource/AddressesResource.java +++ b/src/main/java/org/qortal/api/resource/AddressesResource.java @@ -14,6 +14,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; @@ -27,6 +28,7 @@ import org.qortal.api.ApiErrors; import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; +import org.qortal.api.model.AccountPenaltyStats; import org.qortal.api.model.ApiOnlineAccount; import org.qortal.api.model.RewardShareKeyRequest; import org.qortal.asset.Asset; @@ -34,6 +36,7 @@ import org.qortal.controller.LiteNode; import org.qortal.controller.OnlineAccountsManager; 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.network.OnlineAccountData; import org.qortal.data.network.OnlineAccountLevel; @@ -471,6 +474,54 @@ public class AddressesResource { } } + @GET + @Path("/penalties") + @Operation( + summary = "Get addresses with penalties", + description = "Returns a list of accounts with a blocksMintedPenalty", + responses = { + @ApiResponse( + description = "accounts with penalties", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AccountPenaltyData.class))) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public List getAccountsWithPenalties() { + try (final Repository repository = RepositoryManager.getRepository()) { + + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + List penalties = accounts.stream().map(a -> new AccountPenaltyData(a.getAddress(), a.getBlocksMintedPenalty())).collect(Collectors.toList()); + + return penalties; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/penalties/stats") + @Operation( + summary = "Get stats about current penalties", + responses = { + @ApiResponse( + description = "aggregated stats about accounts with penalties", + content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = AccountPenaltyStats.class))) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public AccountPenaltyStats getPenaltyStats() { + try (final Repository repository = RepositoryManager.getRepository()) { + + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + return AccountPenaltyStats.fromAccounts(accounts); + + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @POST @Path("/publicize") @Operation( diff --git a/src/main/java/org/qortal/data/account/AccountPenaltyData.java b/src/main/java/org/qortal/data/account/AccountPenaltyData.java new file mode 100644 index 00000000..61947a5f --- /dev/null +++ b/src/main/java/org/qortal/data/account/AccountPenaltyData.java @@ -0,0 +1,52 @@ +package org.qortal.data.account; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class AccountPenaltyData { + + // Properties + private String address; + private int blocksMintedPenalty; + + // Constructors + + // necessary for JAXB + protected AccountPenaltyData() { + } + + public AccountPenaltyData(String address, int blocksMintedPenalty) { + this.address = address; + this.blocksMintedPenalty = blocksMintedPenalty; + } + + // Getters/Setters + + public String getAddress() { + return this.address; + } + + public int getBlocksMintedPenalty() { + return this.blocksMintedPenalty; + } + + public String toString() { + return String.format("%s has penalty %d", this.address, this.blocksMintedPenalty); + } + + @Override + public boolean equals(Object b) { + if (!(b instanceof AccountPenaltyData)) + return false; + + return this.getAddress().equals(((AccountPenaltyData) b).getAddress()); + } + + @Override + public int hashCode() { + return address.hashCode(); + } + +} From 58e5d325ff3d55c73bf0c3cc5c171961ade4fead Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:10:18 +0000 Subject: [PATCH 21/97] Added algo feature triggers to BlockChain.java (at future undecided block height & timestamp) --- src/main/java/org/qortal/block/BlockChain.java | 13 +++++++++++++ src/main/resources/blockchain.json | 2 ++ .../resources/test-chain-v2-block-timestamps.json | 2 ++ .../resources/test-chain-v2-disable-reference.json | 2 ++ .../resources/test-chain-v2-founder-rewards.json | 2 ++ .../resources/test-chain-v2-leftover-reward.json | 2 ++ src/test/resources/test-chain-v2-minting.json | 2 ++ .../test-chain-v2-qora-holder-extremes.json | 2 ++ .../test-chain-v2-qora-holder-reduction.json | 2 ++ src/test/resources/test-chain-v2-qora-holder.json | 2 ++ src/test/resources/test-chain-v2-reward-levels.json | 2 ++ .../resources/test-chain-v2-reward-scaling.json | 2 ++ src/test/resources/test-chain-v2-reward-shares.json | 2 ++ src/test/resources/test-chain-v2.json | 2 ++ 14 files changed, 39 insertions(+) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 75513e83..6182bd1d 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -76,6 +76,7 @@ public class BlockChain { disableReferenceTimestamp, increaseOnlineAccountsDifficultyTimestamp, onlineAccountMinterLevelValidationHeight, + selfSponsorshipAlgoV1Height; } // Custom transaction fees @@ -197,6 +198,9 @@ public class BlockChain { * featureTriggers because unit tests need to set this value via Reflection. */ private long onlineAccountsModulusV2Timestamp; + /** Snapshot timestamp for self sponsorship algo V1 */ + private long selfSponsorshipAlgoV1SnapshotTimestamp; + /** Max reward shares by block height */ public static class MaxRewardSharesByTimestamp { public long timestamp; @@ -357,6 +361,11 @@ public class BlockChain { return this.onlineAccountsModulusV2Timestamp; } + // Self sponsorship algo + public long getSelfSponsorshipAlgoV1SnapshotTimestamp() { + return this.selfSponsorshipAlgoV1SnapshotTimestamp; + } + /** Returns true if approval-needing transaction types require a txGroupId other than NO_GROUP. */ public boolean getRequireGroupForApproval() { return this.requireGroupForApproval; @@ -484,6 +493,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.increaseOnlineAccountsDifficultyTimestamp.name()).longValue(); } + public int getSelfSponsorshipAlgoV1Height() { + return this.featureTriggers.get(FeatureTrigger.selfSponsorshipAlgoV1Height.name()).intValue(); + } + public long getOnlineAccountMinterLevelValidationHeight() { return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 6189ad36..6f60e505 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,6 +24,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, @@ -82,6 +83,7 @@ "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index c1ab9db0..59d8b273 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -14,6 +14,7 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -72,6 +73,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index de653a36..3dacf6f1 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -18,6 +18,7 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -75,6 +76,7 @@ "disableReferenceTimestamp": 0, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index 5af3b381..092f51da 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index 40310517..a60e2692 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index bb12e314..ec1dc979 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 04ecec99..d76a4dd1 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 5b9ecbc4..7e57fd46 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -77,6 +78,7 @@ "aggregateSignatureTimestamp": 0, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index 86aea1b3..7c7ccf5c 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index e39033de..1aeee763 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 1170a5a1..3c115b8c 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 550dca01..5ba16774 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -18,6 +18,7 @@ "founderEffectiveMintingLevel": 10, "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index 69f486fb..40f6c492 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -19,6 +19,7 @@ "onlineAccountSignaturesMinLifetime": 3600000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, "rewardsByHeight": [ { "height": 1, "reward": 100 }, { "height": 11, "reward": 10 }, @@ -76,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { "version": 4, From 5f0263c0783ddd9d34d56e7ebe1093f7f9a09a3a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:14:45 +0000 Subject: [PATCH 22/97] Modifications to block minting for unit tests, in order to solve an NPE and give more options to callers. This shouldn't affect the behaviour of existing tests, other than an NPE being replaced with an assertNotNull(). --- .../org/qortal/controller/BlockMinter.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 7e3b4b9e..e2d01147 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -26,9 +26,6 @@ import org.qortal.data.block.CommonBlockData; import org.qortal.data.transaction.TransactionData; import org.qortal.network.Network; import org.qortal.network.Peer; -import org.qortal.network.message.BlockSummariesV2Message; -import org.qortal.network.message.HeightV2Message; -import org.qortal.network.message.Message; import org.qortal.repository.BlockRepository; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -38,6 +35,8 @@ import org.qortal.transaction.Transaction; import org.qortal.utils.Base58; import org.qortal.utils.NTP; +import static org.junit.Assert.assertNotNull; + // Minting new blocks public class BlockMinter extends Thread { @@ -511,6 +510,21 @@ public class BlockMinter extends Thread { PrivateKeyAccount mintingAccount = mintingAndOnlineAccounts[0]; + Block block = mintTestingBlockRetainingTimestamps(repository, mintingAccount); + assertNotNull("Minted block must not be null", block); + + return block; + } + + public static Block mintTestingBlockUnvalidated(Repository repository, PrivateKeyAccount... mintingAndOnlineAccounts) throws DataException { + if (!BlockChain.getInstance().isTestChain()) + throw new DataException("Ignoring attempt to mint testing block for non-test chain!"); + + // Ensure mintingAccount is 'online' so blocks can be minted + OnlineAccountsManager.getInstance().ensureTestingAccountsOnline(mintingAndOnlineAccounts); + + PrivateKeyAccount mintingAccount = mintingAndOnlineAccounts[0]; + return mintTestingBlockRetainingTimestamps(repository, mintingAccount); } @@ -518,6 +532,8 @@ public class BlockMinter extends Thread { BlockData previousBlockData = repository.getBlockRepository().getLastBlock(); Block newBlock = Block.mint(repository, previousBlockData, mintingAccount); + if (newBlock == null) + return null; // Make sure we're the only thread modifying the blockchain ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); From 6ea3c0e6f78bc3539db766cb1aaba7478fa8179c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:19:43 +0000 Subject: [PATCH 23/97] Give founder accounts as an effective minting level of 0 if they have a penalty. --- src/main/java/org/qortal/account/Account.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index c3a25fb6..2b23f91b 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -211,7 +211,8 @@ public class Account { if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToMint()) return true; - if (Account.isFounder(accountData.getFlags())) + // Founders can always mint, unless they have a penalty + if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return true; return false; @@ -243,7 +244,7 @@ public class Account { if (level != null && level >= BlockChain.getInstance().getMinAccountLevelToRewardShare()) return true; - if (Account.isFounder(accountData.getFlags())) + if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return true; return false; @@ -271,7 +272,7 @@ public class Account { /** * Returns 'effective' minting level, or zero if account does not exist/cannot mint. *

- * For founder accounts, this returns "founderEffectiveMintingLevel" from blockchain config. + * For founder accounts with no penalty, this returns "founderEffectiveMintingLevel" from blockchain config. * * @return 0+ * @throws DataException @@ -281,7 +282,8 @@ public class Account { if (accountData == null) return 0; - if (Account.isFounder(accountData.getFlags())) + // Founders are assigned a different effective minting level, as long as they have no penalty + if (Account.isFounder(accountData.getFlags()) && accountData.getBlocksMintedPenalty() == 0) return BlockChain.getInstance().getFounderEffectiveMintingLevel(); return accountData.getLevel(); From 41cdf665ed9d14c96530143960f785bad16d8436 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 16:53:38 +0000 Subject: [PATCH 24/97] Code tidy --- src/main/java/org/qortal/account/Account.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index 2b23f91b..6e2fff65 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -291,8 +291,6 @@ public class Account { /** * Returns 'effective' minting level, or zero if reward-share does not exist. - *

- * this is being used on src/main/java/org/qortal/api/resource/AddressesResource.java to fulfil the online accounts api call * * @param repository * @param rewardSharePublicKey @@ -311,7 +309,7 @@ public class Account { /** * Returns 'effective' minting level, with a fix for the zero level. *

- * For founder accounts, this returns "founderEffectiveMintingLevel" from blockchain config. + * For founder accounts with no penalty, this returns "founderEffectiveMintingLevel" from blockchain config. * * @param repository * @param rewardSharePublicKey @@ -324,7 +322,7 @@ public class Account { if (rewardShareData == null) return 0; - else if(!rewardShareData.getMinter().equals(rewardShareData.getRecipient()))//the minter is different than the recipient this means sponsorship + else if (!rewardShareData.getMinter().equals(rewardShareData.getRecipient())) // Sponsorship reward share return 0; Account rewardShareMinter = new Account(repository, rewardShareData.getMinter()); From a75fd14e4548fba63eaa40909940ba5662d5e424 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 17:23:43 +0000 Subject: [PATCH 25/97] Added Account method needed for unit tests. --- src/main/java/org/qortal/account/Account.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java index 6e2fff65..2c75dbc0 100644 --- a/src/main/java/org/qortal/account/Account.java +++ b/src/main/java/org/qortal/account/Account.java @@ -223,6 +223,11 @@ public class Account { return this.repository.getAccountRepository().getMintedBlockCount(this.address); } + /** Returns account's blockMintedPenalty or null if account not found in repository. */ + public Integer getBlocksMintedPenalty() throws DataException { + return this.repository.getAccountRepository().getBlocksMintedPenaltyCount(this.address); + } + /** Returns whether account can build reward-shares. *

From 3965f24ab564753fc568704bf46689e9f6adf525 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 26 Nov 2022 19:06:02 +0000 Subject: [PATCH 26/97] Fixed bug --- .../java/org/qortal/api/model/AccountPenaltyStats.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java index 68c3a6ed..aafe25fc 100644 --- a/src/main/java/org/qortal/api/model/AccountPenaltyStats.java +++ b/src/main/java/org/qortal/api/model/AccountPenaltyStats.java @@ -13,15 +13,15 @@ import java.util.List; @XmlAccessorType(XmlAccessType.FIELD) public class AccountPenaltyStats { - public int totalPenalties; - public int maxPenalty; - public int minPenalty; + public Integer totalPenalties; + public Integer maxPenalty; + public Integer minPenalty; public String penaltyHash; protected AccountPenaltyStats() { } - public AccountPenaltyStats(int totalPenalties, int maxPenalty, int minPenalty, String penaltyHash) { + public AccountPenaltyStats(Integer totalPenalties, Integer maxPenalty, Integer minPenalty, String penaltyHash) { this.totalPenalties = totalPenalties; this.maxPenalty = maxPenalty; this.minPenalty = minPenalty; From 76686eca21df3b9e1e43eb6fde76116321240518 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 27 Nov 2022 12:23:17 +0000 Subject: [PATCH 27/97] Modified reward share creation in test/common/AccountUtils to allow a fee to be specified. --- src/test/java/org/qortal/test/common/AccountUtils.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 0d8baae2..c31cd85e 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -49,10 +49,10 @@ public class AccountUtils { public static TransactionData createRewardShare(Repository repository, String minter, String recipient, int sharePercent) throws DataException { PrivateKeyAccount mintingAccount = Common.getTestAccount(repository, minter); PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, recipient); - return createRewardShare(repository, mintingAccount, recipientAccount, sharePercent); + return createRewardShare(repository, mintingAccount, recipientAccount, sharePercent, fee); } - public static TransactionData createRewardShare(Repository repository, PrivateKeyAccount mintingAccount, PrivateKeyAccount recipientAccount, int sharePercent) throws DataException { + public static TransactionData createRewardShare(Repository repository, PrivateKeyAccount mintingAccount, PrivateKeyAccount recipientAccount, int sharePercent, long fee) throws DataException { byte[] reference = mintingAccount.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1; @@ -78,7 +78,7 @@ public class AccountUtils { } public static byte[] rewardShare(Repository repository, PrivateKeyAccount minterAccount, PrivateKeyAccount recipientAccount, int sharePercent) throws DataException { - TransactionData transactionData = createRewardShare(repository, minterAccount, recipientAccount, sharePercent); + TransactionData transactionData = createRewardShare(repository, minterAccount, recipientAccount, sharePercent, fee); TransactionUtils.signAndMint(repository, transactionData, minterAccount); byte[] rewardSharePrivateKey = minterAccount.getRewardSharePrivateKey(recipientAccount.getPublicKey()); From 5ff7b3df6d68e011a3e1372cc5cf9c5e0cf610c3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 27 Nov 2022 19:59:46 +0000 Subject: [PATCH 28/97] hasInvalidSigner() now only checks the chain tip block, to reduce the amount of unintended side effects that can occur. --- .../java/org/qortal/controller/Controller.java | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index f2ca853d..0a323cb2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -758,24 +758,15 @@ public class Controller extends Thread { }; public static final Predicate hasInvalidSigner = peer -> { - final List peerChainTipSummaries = peer.getChainTipSummaries(); - if (peerChainTipSummaries == null) { + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + if (peerChainTipData == null) return true; - } try (Repository repository = RepositoryManager.getRepository()) { - for (BlockSummaryData blockSummaryData : peerChainTipSummaries) { - if (Account.getRewardShareEffectiveMintingLevel(repository, blockSummaryData.getMinterPublicKey()) == 0) { - return true; - } - } + return Account.getRewardShareEffectiveMintingLevel(repository, peerChainTipData.getMinterPublicKey()) == 0; } catch (DataException e) { return true; } - - // We got this far without encountering invalid or missing summaries, nor was an exception thrown, - // so it is safe to assume that all of this peer's recent blocks had a valid signer. - return false; }; private long getRandomRepositoryMaintenanceInterval() { From ae991dda4d997892a9857bb7ccfee753e131e90c Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 28 Nov 2022 21:52:37 +0000 Subject: [PATCH 29/97] Fix creatorPublicKey not being unmarshaled when calling POST /at to deploy an AT --- .../data/transaction/DeployAtTransactionData.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java b/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java index 7a2ebdab..fed69cd5 100644 --- a/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/DeployAtTransactionData.java @@ -2,6 +2,7 @@ package org.qortal.data.transaction; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.qortal.transaction.Transaction.TransactionType; @@ -90,4 +91,17 @@ public class DeployAtTransactionData extends TransactionData { this.aTAddress = AtAddress; } + // Re-expose creatorPublicKey for this transaction type for JAXB + @XmlElement(name = "creatorPublicKey") + @Schema(name = "creatorPublicKey", description = "AT creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + public byte[] getAtCreatorPublicKey() { + return this.creatorPublicKey; + } + + @XmlElement(name = "creatorPublicKey") + @Schema(name = "creatorPublicKey", description = "AT creator's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + public void setAtCreatorPublicKey(byte[] creatorPublicKey) { + this.creatorPublicKey = creatorPublicKey; + } + } From 99ba4caf7578338de4c7728ace6261aab661818f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 11:50:58 +0000 Subject: [PATCH 30/97] We definitely can't retroactively validate minter levels, because there are confirmed cases in the chains history where this fails. Set to 999999999 until we have decided on a future block height. --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 6f60e505..126ec7ae 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -82,7 +82,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, - "onlineAccountMinterLevelValidationHeight": 0, + "onlineAccountMinterLevelValidationHeight": 999999999, "selfSponsorshipAlgoV1Height": 999999999 }, "genesisInfo": { From f14cc374c6bcd4bdf43f99cde865ce48841487de Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 11:52:51 +0000 Subject: [PATCH 31/97] Include blocksMintedPenalty in effectiveBlocksMinted. This will be zero until the algo runs, so doesn't need a feature trigger. --- src/main/java/org/qortal/block/Block.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index 3f130359..bbd62dd3 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1523,7 +1523,7 @@ public class Block { accountData.setBlocksMinted(accountData.getBlocksMinted() + 1); LOGGER.trace(() -> String.format("Block minter %s up to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""))); - final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment(); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { @@ -1824,7 +1824,7 @@ public class Block { accountData.setBlocksMinted(accountData.getBlocksMinted() - 1); LOGGER.trace(() -> String.format("Block minter %s down to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : ""))); - final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment(); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { From f4d20e42f3075d9b3768a8aa697fccdedd93d34b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 11:54:05 +0000 Subject: [PATCH 32/97] Disallow TRANSFER_PRIVS transactions if the sending account has a penalty. Again, there will be no penalties until the algo runs, so it's safe without a feature trigger. --- src/main/java/org/qortal/transaction/Transaction.java | 1 + .../org/qortal/transaction/TransferPrivsTransaction.java | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index 203cc342..f0e9b3f6 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -245,6 +245,7 @@ public abstract class Transaction { ADDRESS_BLOCKED(96), NAME_BLOCKED(97), GROUP_APPROVAL_REQUIRED(98), + ACCOUNT_NOT_TRANSFERABLE(99), INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); diff --git a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java index f6a9de68..97e67160 100644 --- a/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java +++ b/src/main/java/org/qortal/transaction/TransferPrivsTransaction.java @@ -67,6 +67,11 @@ public class TransferPrivsTransaction extends Transaction { if (getSender().getConfirmedBalance(Asset.QORT) < this.transferPrivsTransactionData.getFee()) return ValidationResult.NO_BALANCE; + // Check sender doesn't have a blocksMintedPenalty, as these accounts cannot be transferred + AccountData senderAccountData = this.repository.getAccountRepository().getAccount(getSender().getAddress()); + if (senderAccountData == null || senderAccountData.getBlocksMintedPenalty() != 0) + return ValidationResult.ACCOUNT_NOT_TRANSFERABLE; + return ValidationResult.OK; } From eea42b56eecd2a73a0e980c85e39d2cc66f2873d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 18:21:01 +0000 Subject: [PATCH 33/97] Added SelfSponsorshipAlgoV1Block, and call it when processing/orphaning a block at an undecided future height. --- src/main/java/org/qortal/block/Block.java | 6 + .../block/SelfSponsorshipAlgoV1Block.java | 133 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index bbd62dd3..a31c522b 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1461,6 +1461,9 @@ public class Block { if (this.blockData.getHeight() == 212937) // Apply fix for block 212937 Block212937.processFix(this); + + else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) + SelfSponsorshipAlgoV1Block.processAccountPenalties(this); } // We're about to (test-)process a batch of transactions, @@ -1696,6 +1699,9 @@ public class Block { // Revert fix for block 212937 Block212937.orphanFix(this); + else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV1Height()) + SelfSponsorshipAlgoV1Block.orphanAccountPenalties(this); + // Block rewards, including transaction fees, removed after transactions undone orphanBlockRewards(); diff --git a/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java b/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java new file mode 100644 index 00000000..a9a016b6 --- /dev/null +++ b/src/main/java/org/qortal/block/SelfSponsorshipAlgoV1Block.java @@ -0,0 +1,133 @@ +package org.qortal.block; + +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qortal.account.SelfSponsorshipAlgoV1; +import org.qortal.api.model.AccountPenaltyStats; +import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountData; +import org.qortal.data.account.AccountPenaltyData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.utils.Base58; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Self Sponsorship AlgoV1 Block + *

+ * Selected block for the initial run on the "self sponsorship detection algorithm" + */ +public final class SelfSponsorshipAlgoV1Block { + + private static final Logger LOGGER = LogManager.getLogger(SelfSponsorshipAlgoV1Block.class); + + + private SelfSponsorshipAlgoV1Block() { + /* Do not instantiate */ + } + + public static void processAccountPenalties(Block block) throws DataException { + LOGGER.info("Running algo for block processing - this will take a while..."); + logPenaltyStats(block.repository); + long startTime = System.currentTimeMillis(); + Set penalties = getAccountPenalties(block.repository, -5000000); + block.repository.getAccountRepository().updateBlocksMintedPenalties(penalties); + long totalTime = System.currentTimeMillis() - startTime; + String hash = getHash(penalties.stream().map(p -> p.getAddress()).collect(Collectors.toList())); + LOGGER.info("{} penalty addresses processed (hash: {}). Total time taken: {} seconds", penalties.size(), hash, (int)(totalTime / 1000.0f)); + logPenaltyStats(block.repository); + + int updatedCount = updateAccountLevels(block.repository, penalties); + LOGGER.info("Account levels updated for {} penalty addresses", updatedCount); + } + + public static void orphanAccountPenalties(Block block) throws DataException { + LOGGER.info("Running algo for block orphaning - this will take a while..."); + logPenaltyStats(block.repository); + long startTime = System.currentTimeMillis(); + Set penalties = getAccountPenalties(block.repository, 5000000); + block.repository.getAccountRepository().updateBlocksMintedPenalties(penalties); + long totalTime = System.currentTimeMillis() - startTime; + String hash = getHash(penalties.stream().map(p -> p.getAddress()).collect(Collectors.toList())); + LOGGER.info("{} penalty addresses orphaned (hash: {}). Total time taken: {} seconds", penalties.size(), hash, (int)(totalTime / 1000.0f)); + logPenaltyStats(block.repository); + + int updatedCount = updateAccountLevels(block.repository, penalties); + LOGGER.info("Account levels updated for {} penalty addresses", updatedCount); + } + + public static Set getAccountPenalties(Repository repository, int penalty) throws DataException { + final long snapshotTimestamp = BlockChain.getInstance().getSelfSponsorshipAlgoV1SnapshotTimestamp(); + Set penalties = new LinkedHashSet<>(); + List addresses = repository.getTransactionRepository().getConfirmedRewardShareCreatorsExcludingSelfShares(); + for (String address : addresses) { + //System.out.println(String.format("address: %s", address)); + SelfSponsorshipAlgoV1 selfSponsorshipAlgoV1 = new SelfSponsorshipAlgoV1(repository, address, snapshotTimestamp, false); + selfSponsorshipAlgoV1.run(); + //System.out.println(String.format("Penalty addresses: %d", selfSponsorshipAlgoV1.getPenaltyAddresses().size())); + + for (String penaltyAddress : selfSponsorshipAlgoV1.getPenaltyAddresses()) { + penalties.add(new AccountPenaltyData(penaltyAddress, penalty)); + } + } + return penalties; + } + + private static int updateAccountLevels(Repository repository, Set accountPenalties) throws DataException { + final List cumulativeBlocksByLevel = BlockChain.getInstance().getCumulativeBlocksByLevel(); + final int maximumLevel = cumulativeBlocksByLevel.size() - 1; + + int updatedCount = 0; + + for (AccountPenaltyData penaltyData : accountPenalties) { + AccountData accountData = repository.getAccountRepository().getAccount(penaltyData.getAddress()); + final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty(); + + // Shortcut for penalties + if (effectiveBlocksMinted < 0) { + accountData.setLevel(0); + repository.getAccountRepository().setLevel(accountData); + updatedCount++; + LOGGER.trace(() -> String.format("Block minter %s dropped to level %d", accountData.getAddress(), accountData.getLevel())); + continue; + } + + for (int newLevel = maximumLevel; newLevel >= 0; --newLevel) { + if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) { + accountData.setLevel(newLevel); + repository.getAccountRepository().setLevel(accountData); + updatedCount++; + LOGGER.trace(() -> String.format("Block minter %s increased to level %d", accountData.getAddress(), accountData.getLevel())); + break; + } + } + } + + return updatedCount; + } + + private static void logPenaltyStats(Repository repository) { + try { + LOGGER.info(getPenaltyStats(repository)); + + } catch (DataException e) {} + } + + private static AccountPenaltyStats getPenaltyStats(Repository repository) throws DataException { + List accounts = repository.getAccountRepository().getPenaltyAccounts(); + return AccountPenaltyStats.fromAccounts(accounts); + } + + public static String getHash(List penaltyAddresses) { + if (penaltyAddresses == null || penaltyAddresses.isEmpty()) { + return null; + } + Collections.sort(penaltyAddresses); + return Base58.encode(Crypto.digest(StringUtils.join(penaltyAddresses).getBytes(StandardCharsets.UTF_8))); + } + +} From c108afa27c5d84dd197a5fa8cc343099a6e53671 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 4 Dec 2022 20:57:36 +0000 Subject: [PATCH 34/97] Self sponsorship algo tests --- .../test/SelfSponsorshipAlgoV1Tests.java | 1627 +++++++++++++++++ .../test-chain-v2-self-sponsorship-algo.json | 114 ++ ...est-settings-v2-self-sponsorship-algo.json | 20 + 3 files changed, 1761 insertions(+) create mode 100644 src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java create mode 100644 src/test/resources/test-chain-v2-self-sponsorship-algo.json create mode 100644 src/test/resources/test-settings-v2-self-sponsorship-algo.json diff --git a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java new file mode 100644 index 00000000..91628dd3 --- /dev/null +++ b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java @@ -0,0 +1,1627 @@ +package org.qortal.test; + +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.Account; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.block.Block; +import org.qortal.controller.BlockMinter; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.PaymentTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.data.transaction.TransferPrivsTransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.settings.Settings; +import org.qortal.test.common.*; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transaction.TransferPrivsTransaction; +import org.qortal.utils.NTP; + +import java.util.*; + +import static org.junit.Assert.*; +import static org.qortal.test.common.AccountUtils.fee; +import static org.qortal.transaction.Transaction.ValidationResult.*; + +public class SelfSponsorshipAlgoV1Tests extends Common { + + + @Before + public void beforeTest() throws DataException { + Common.useSettings("test-settings-v2-self-sponsorship-algo.json"); + NTP.setFixedOffset(Settings.getInstance().getTestNtpOffset()); + } + + + @Test + public void testSingleSponsor() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob self sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(11, block.getBlockData().getOnlineAccountsCount()); + assertEquals(10, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMultipleSponsors() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(26, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees have no penalties + List chloeAndSponsees = new ArrayList<>(chloeSponsees); + chloeAndSponsees.add(chloeAccount); + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees have no penalties + List dilbertAndSponsees = new ArrayList<>(dilbertSponsees); + dilbertAndSponsees.add(dilbertAccount); + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees still have no penalties + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees still have no penalties + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMintBlockWithSignerPenalty() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + List onlineAccountsAliceSigner = new ArrayList<>(); + List onlineAccountsBobSigner = new ArrayList<>(); + + // Alice self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccountsAliceSigner.add(aliceSelfShare); + + // Bob self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccountsBobSigner.add(bobSelfShare); + + // Include Alice and Bob's online accounts in each other's arrays + onlineAccountsAliceSigner.add(bobSelfShare); + onlineAccountsBobSigner.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccountsAliceSigner.addAll(chloeSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccountsAliceSigner.addAll(dilbertSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Bob is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseeSelfShares); + onlineAccountsBobSigner.addAll(bobSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Bob is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Mint a block, but Bob is now an invalid signer because he is level 0 + block = BlockMinter.mintTestingBlockUnvalidated(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + // Block should be null as it's unable to be minted + assertNull(block); + + // Mint the same block with Alice as the signer, and this time it should be valid + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + // Block should NOT be null + assertNotNull(block); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testMintBlockWithFounderSignerPenalty() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + List onlineAccountsAliceSigner = new ArrayList<>(); + List onlineAccountsBobSigner = new ArrayList<>(); + + // Alice self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccountsAliceSigner.add(aliceSelfShare); + + // Bob self share online, and will be used to mint (some of) the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccountsBobSigner.add(bobSelfShare); + + // Include Alice and Bob's online accounts in each other's arrays + onlineAccountsAliceSigner.add(bobSelfShare); + onlineAccountsBobSigner.add(aliceSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccountsAliceSigner.addAll(aliceSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 9 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); + onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(19, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Alice is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + onlineAccountsAliceSigner.addAll(aliceSponseeSelfShares); + onlineAccountsBobSigner.addAll(aliceSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees have no penalties + List aliceAndSponsees = new ArrayList<>(aliceSponsees); + aliceAndSponsees.add(aliceAccount); + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Alice is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice and her sponsees now have penalties + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(-5000000, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getLevel()); + + // Mint a block, but Alice is now an invalid signer because she has lost founder minting abilities + block = BlockMinter.mintTestingBlockUnvalidated(repository, onlineAccountsAliceSigner.toArray(new PrivateKeyAccount[0])); + // Block should be null as it's unable to be minted + assertNull(block); + + // Mint the same block with Bob as the signer, and this time it should be valid + block = BlockMinter.mintTestingBlock(repository, onlineAccountsBobSigner.toArray(new PrivateKeyAccount[0])); + // Block should NOT be null + assertNotNull(block); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testOnlineAccountsWithPenalties() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + // Bob self share online + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + onlineAccounts.add(bobSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(27, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that chloe's sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(chloeSponsees, block)); + + // Ensure that dilbert's sponsees are present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(dilbertSponsees, block)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Mint another few blocks + while (block.getBlockData().getHeight() < 24) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(24, (int)block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees are NOT present in block's online accounts (due to penalties) + assertFalse(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that chloe's sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(chloeSponsees, block)); + + // Ensure that dilbert's sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(dilbertSponsees, block)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testFounderOnlineAccountsWithPenalties() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Bob self share online, and will be used to mint the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(bobSelfShare); + + // Alice self share online + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 9 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks (Bob is the signer) + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Get reward share transaction count + assertEquals(19, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up (Alice is the signer) + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + onlineAccounts.addAll(aliceSponseeSelfShares); + + // Mint blocks (Bob is the signer) + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount aliceSponsee : aliceSponsees) + assertTrue(new Account(repository, aliceSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) (Bob is the signer) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees have no penalties + List aliceAndSponsees = new ArrayList<>(aliceSponsees); + aliceAndSponsees.add(aliceAccount); + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs (Alice is the signer) + // Block should be valid, because new account levels don't take effect until next block's validation + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice and her sponsees now have penalties + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(-5000000, (int) new Account(repository, aliceSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + for (PrivateKeyAccount aliceSponsee : aliceAndSponsees) + assertEquals(0, (int) new Account(repository, aliceSponsee.getAddress()).getLevel()); + + // Ensure that alice and her sponsees don't have penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Ensure that alice and her sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(aliceAndSponsees, block)); + + // Mint another few blocks + while (block.getBlockData().getHeight() < 24) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(24, (int)block.getBlockData().getHeight()); + + // Ensure that alice and her sponsees are NOT present in block's online accounts (due to penalties) + assertFalse(areAllAccountsPresentInBlock(aliceAndSponsees, block)); + + // Ensure that bob and his sponsees are still present in block's online accounts + assertTrue(areAllAccountsPresentInBlock(bobAndSponsees, block)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyAccountCreateRewardShare() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors 10 accounts + List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(21, block.getBlockData().getOnlineAccountsCount()); + assertEquals(20, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Bob creates a valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob can no longer create a reward share transaction + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, bobAccount)); + + // ... but Chloe still can + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, chloeAccount)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Bob creates another valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyFounderCreateRewardShare() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Bob self share online, and will be used to mint the blocks + PrivateKeyAccount bobSelfShare = Common.getTestAccount(repository, "bob-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(bobSelfShare); + + PrivateKeyAccount aliceAccount = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Alice sponsors 10 accounts + List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + onlineAccounts.addAll(aliceSponseesOnlineAccounts); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(21, block.getBlockData().getOnlineAccountsCount()); + assertEquals(20, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + onlineAccounts.addAll(aliceSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Alice then consolidates funds + consolidateFunds(repository, aliceSponsees, aliceAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Alice creates a valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that alice now has a penalty + assertEquals(-5000000, (int) new Account(repository, aliceAccount.getAddress()).getBlocksMintedPenalty()); + + // Ensure that alice and her sponsees are now level 0 + assertEquals(0, (int) new Account(repository, aliceAccount.getAddress()).getLevel()); + + // Alice can no longer create a reward share transaction + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, aliceAccount)); + + // ... but Bob still can + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Alice creates another valid reward share transaction + assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + /** + * This is a test to prove that Dilbert levels up from 6 to 7 in the same block that the self + * sponsorship algo runs. It is here to give some confidence in the following testPenaltyAccountLevelUp() + * test, in which we will test what happens if a penalty is applied or removed in the same block + * that an account would otherwise have leveled up. It also gives some confidence that the algo + * doesn't affect the levels of unflagged accounts. + * + * @throws DataException + */ + @Test + public void testNonPenaltyAccountLevelUp() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Dilbert sponsors 10 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Make sure Dilbert hasn't leveled up yet + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure Dilbert has leveled up + assertEquals(7, (int)dilbertAccount.getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Make sure Dilbert has returned to level 6 + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testPenaltyAccountLevelUp() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Dilbert sponsors 10 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Generate self shares so the sponsees can start minting + List dilbertSponseeSelfShares = generateSelfShares(repository, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Dilbert then consolidates funds + consolidateFunds(repository, dilbertSponsees, dilbertAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Make sure Dilbert hasn't leveled up yet + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Make sure Dilbert is now level 0 instead of 7 (due to penalty) + assertEquals(0, (int)dilbertAccount.getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Make sure Dilbert has returned to level 6 + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testDuplicateSponsors() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + final int initialRewardShareCount = repository.getAccountRepository().getRewardShares().size(); + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Chloe sponsors THE SAME 10 accounts + for (PrivateKeyAccount bobSponsee : bobSponsees) { + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, chloeAccount, bobSponsee, 0, fee); + TransactionUtils.signAndImportValid(repository, transactionData, chloeAccount); + } + List chloeSponsees = new ArrayList<>(bobSponsees); + List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + onlineAccounts.addAll(chloeSponseesOnlineAccounts); + + // Dilbert sponsors 5 accounts + List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + onlineAccounts.addAll(dilbertSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + assertEquals(26, block.getBlockData().getOnlineAccountsCount()); + assertEquals(25, repository.getAccountRepository().getRewardShares().size() - initialRewardShareCount); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + assertEquals(6, (int)dilbertAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees also have penalties, as they relate to the same network of accounts + List chloeAndSponsees = new ArrayList<>(chloeSponsees); + chloeAndSponsees.add(chloeAccount); + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(-5000000, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees have no penalties + List dilbertAndSponsees = new ArrayList<>(dilbertSponsees); + dilbertAndSponsees.add(dilbertAccount); + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that chloe and her sponsees still have no penalties again + for (PrivateKeyAccount chloeSponsee : chloeAndSponsees) + assertEquals(0, (int) new Account(repository, chloeSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that dilbert and his sponsees still have no penalties + for (PrivateKeyAccount dilbertSponsee : dilbertAndSponsees) + assertEquals(0, (int) new Account(repository, dilbertSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsBeforeAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 18 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 18) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(18, (int) block.getBlockData().getHeight()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount.getLevel()); + + // Mint another block, so that the TRANSFER_PRIVS confirms + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob's sponsees are greater than level 0 + // Bob's account won't be, as he has transferred privs + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Ensure recipient account has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getLevel()); + + // TODO: check both recipients' sponsees + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that Bob's sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure recipient account has no penalty again and has a level greater than 0 + assertEquals(0, (int) new Account(repository, recipientAccount.getAddress()).getBlocksMintedPenalty()); + assertTrue(new Account(repository, recipientAccount.getAddress()).getLevel() > 0); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsInAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount.getLevel()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure recipient has no level again + assertNull(recipientAccount.getLevel()); + + // Ensure that bob and his sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testTransferPrivsAfterAlgoBlock() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 19 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 19) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(19, (int) block.getBlockData().getHeight()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Bob then issues a TRANSFER_PRIVS, which should be invalid + Transaction transferPrivsTransaction = randomTransferPrivsTransaction(repository, bobAccount); + assertEquals(ACCOUNT_NOT_TRANSFERABLE, transferPrivsTransaction.isValid()); + + // Orphan last 2 blocks + BlockUtils.orphanLastBlock(repository); + BlockUtils.orphanLastBlock(repository); + + // TRANSFER_PRIVS should now be valid + transferPrivsTransaction = randomTransferPrivsTransaction(repository, bobAccount); + assertEquals(OK, transferPrivsTransaction.isValid()); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + @Test + public void testDoubleTransferPrivs() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Alice self share online, and will be used to mint the blocks + PrivateKeyAccount aliceSelfShare = Common.getTestAccount(repository, "alice-reward-share"); + List onlineAccounts = new ArrayList<>(); + onlineAccounts.add(aliceSelfShare); + + PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); + + // Bob sponsors 10 accounts + List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + onlineAccounts.addAll(bobSponseesOnlineAccounts); + + // Mint blocks + Block block = null; + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() == 0); + + // Mint some blocks, until accounts have leveled up + for (int i = 0; i <= 5; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Generate self shares so the sponsees can start minting + List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + onlineAccounts.addAll(bobSponseeSelfShares); + + // Mint blocks + for (int i = 0; i <= 1; i++) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getConfirmedBalance(Asset.QORT) > 10); // 5 for transaction, 5 for fee + + // Bob then consolidates funds + consolidateFunds(repository, bobSponsees, bobAccount); + + // Mint until block 17 (the algo runs at block 20) + while (block.getBlockData().getHeight() < 17) + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + assertEquals(17, (int) block.getBlockData().getHeight()); + + // Bob then issues a TRANSFER_PRIVS + PrivateKeyAccount recipientAccount1 = randomTransferPrivs(repository, bobAccount); + + // Ensure recipient has no level (actually, no account record) at this point (pre-confirmation) + assertNull(recipientAccount1.getLevel()); + + // Bob and also sends some QORT to cover future transaction fees + // This mints another block, and the TRANSFER_PRIVS confirms + AccountUtils.pay(repository, bobAccount, recipientAccount1.getAddress(), 123456789L); + + // Now ensure that the TRANSFER_PRIVS recipient has inherited Bob's level, and Bob is at level 0 + assertTrue(recipientAccount1.getLevel() > 0); + assertEquals(0, (int)bobAccount.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + + // The recipient account then issues a TRANSFER_PRIVS of their own + PrivateKeyAccount recipientAccount2 = randomTransferPrivs(repository, recipientAccount1); + + // Ensure recipientAccount2 has no level at this point (pre-confirmation) + assertNull(recipientAccount2.getLevel()); + + // Mint another block, so that the TRANSFER_PRIVS confirms + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Now ensure that the TRANSFER_PRIVS recipient2 has inherited Bob's level, and recipient1 is at level 0 + assertTrue(recipientAccount2.getLevel() > 0); + assertEquals(0, (int)recipientAccount1.getLevel()); + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees have no penalties + List bobAndSponsees = new ArrayList<>(bobSponsees); + bobAndSponsees.add(bobAccount); + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob's sponsees are greater than level 0 + // Bob's account won't be, as he has transferred privs + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Mint a block, so the algo runs + block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); + + // Ensure that bob and his sponsees now have penalties + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(-5000000, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure that bob and his sponsees are now level 0 + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getLevel()); + + // Ensure recipientAccount2 has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getLevel()); + + // Ensure recipientAccount1 has penalty too + assertEquals(-5000000, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getLevel()); + + // TODO: check recipient's sponsees + + // Orphan last block + BlockUtils.orphanLastBlock(repository); + + // Ensure that Bob's sponsees are now greater than level 0 + for (PrivateKeyAccount bobSponsee : bobSponsees) + assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); + + // Ensure that bob and his sponsees have no penalties again + for (PrivateKeyAccount bobSponsee : bobAndSponsees) + assertEquals(0, (int) new Account(repository, bobSponsee.getAddress()).getBlocksMintedPenalty()); + + // Ensure recipientAccount1 has no penalty again and is level 0 + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getBlocksMintedPenalty()); + assertEquals(0, (int) new Account(repository, recipientAccount1.getAddress()).getLevel()); + + // Ensure recipientAccount2 has no penalty again and has a level greater than 0 + assertEquals(0, (int) new Account(repository, recipientAccount2.getAddress()).getBlocksMintedPenalty()); + assertTrue(new Account(repository, recipientAccount2.getAddress()).getLevel() > 0); + + // Run orphan check - this can't be in afterTest() because some tests access the live db + Common.orphanCheck(); + } + } + + + + private static PrivateKeyAccount randomTransferPrivs(Repository repository, PrivateKeyAccount senderAccount) throws DataException { + // Generate random recipient account + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + + BaseTransactionData baseTransactionData = new BaseTransactionData(NTP.getTime(), 0, senderAccount.getLastReference(), senderAccount.getPublicKey(), fee, null); + TransactionData transactionData = new TransferPrivsTransactionData(baseTransactionData, recipientAccount.getAddress()); + + TransactionUtils.signAndImportValid(repository, transactionData, senderAccount); + + return recipientAccount; + } + + private static TransferPrivsTransaction randomTransferPrivsTransaction(Repository repository, PrivateKeyAccount senderAccount) throws DataException { + // Generate random recipient account + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + + BaseTransactionData baseTransactionData = new BaseTransactionData(NTP.getTime(), 0, senderAccount.getLastReference(), senderAccount.getPublicKey(), fee, null); + TransactionData transactionData = new TransferPrivsTransactionData(baseTransactionData, recipientAccount.getAddress()); + + return new TransferPrivsTransaction(repository, transactionData); + } + + private static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { + final int sharePercent = 0; + Random random = new Random(); + + List sponsees = new ArrayList<>(); + for (int i = 0; i < accountsCount; i++) { + + // Generate random sponsee account + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + sponsees.add(sponseeAccount); + + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); + TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); + } + + return sponsees; + } + + private static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { + // Bob attempts to create a reward share transaction + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + TransactionData transactionData = AccountUtils.createRewardShare(repository, account, sponseeAccount, 0, fee); + return TransactionUtils.signAndImport(repository, transactionData, account); + } + + private static List generateSelfShares(Repository repository, List accounts) throws DataException { + final int sharePercent = 0; + + for (PrivateKeyAccount account : accounts) { + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, account, account, sharePercent, 0L); + TransactionUtils.signAndImportValid(repository, transactionData, account); + } + + return toRewardShares(repository, null, accounts); + } + + private static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { + List rewardShares = new ArrayList<>(); + + for (PrivateKeyAccount account : accounts) { + PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; + byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); + PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); + rewardShares.add(rewardShareAccount); + } + + return rewardShares; + } + + private boolean areAllAccountsPresentInBlock(List accounts, Block block) throws DataException { + for (PrivateKeyAccount bobSponsee : accounts) { + boolean foundOnlineAccountInBlock = false; + for (Block.ExpandedAccount expandedAccount : block.getExpandedAccounts()) { + if (expandedAccount.getRecipientAccount().getAddress().equals(bobSponsee.getAddress())) { + foundOnlineAccountInBlock = true; + break; + } + } + if (!foundOnlineAccountInBlock) { + return false; + } + } + return true; + } + + private static void consolidateFunds(Repository repository, List sponsees, PrivateKeyAccount sponsor) throws DataException { + for (PrivateKeyAccount sponsee : sponsees) { + for (int i = 0; i < 5; i++) { + // Generate new payments from sponsee to sponsor + TransactionData paymentData = new PaymentTransactionData(TestTransaction.generateBase(sponsee), sponsor.getAddress(), 1); + TransactionUtils.signAndImportValid(repository, paymentData, sponsee); // updates paymentData's signature + } + } + } + +} \ No newline at end of file diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json new file mode 100644 index 00000000..7712ceb1 --- /dev/null +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -0,0 +1,114 @@ +{ + "isTestChain": true, + "blockTimestampMargin": 500, + "transactionExpiryPeriod": 86400000, + "maxBlockSize": 2097152, + "maxBytesPerUnitFee": 0, + "unitFee": "0.00000001", + "nameRegistrationUnitFees": [ + { "timestamp": 1645372800000, "fee": "5" } + ], + "requireGroupForApproval": false, + "minAccountLevelToRewardShare": 5, + "maxRewardSharesPerFounderMintingAccount": 20, + "maxRewardSharesByTimestamp": [ + { "timestamp": 0, "maxShares": 20 }, + { "timestamp": 9999999999999, "maxShares": 3 } + ], + "founderEffectiveMintingLevel": 10, + "onlineAccountSignaturesMinLifetime": 3600000, + "onlineAccountSignaturesMaxLifetime": 86400000, + "onlineAccountsModulusV2Timestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "rewardsByHeight": [ + { "height": 1, "reward": 100 }, + { "height": 11, "reward": 10 }, + { "height": 21, "reward": 1 } + ], + "sharesByLevelV1": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.05 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.10 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.15 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.20 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.25 } + ], + "sharesByLevelV2": [ + { "id": 1, "levels": [ 1, 2 ], "share": 0.06 }, + { "id": 2, "levels": [ 3, 4 ], "share": 0.13 }, + { "id": 3, "levels": [ 5, 6 ], "share": 0.19 }, + { "id": 4, "levels": [ 7, 8 ], "share": 0.26 }, + { "id": 5, "levels": [ 9, 10 ], "share": 0.32 } + ], + "qoraHoldersShareByHeight": [ + { "height": 1, "share": 0.20 }, + { "height": 1000000, "share": 0.01 } + ], + "qoraPerQortReward": 250, + "minAccountsToActivateShareBin": 30, + "shareBinActivationMinLevel": 7, + "blocksNeededByLevel": [ 5, 20, 30, 40, 50, 60, 18, 80, 90, 100 ], + "blockTimingsByHeight": [ + { "height": 1, "target": 60000, "deviation": 30000, "power": 0.2 } + ], + "ciyamAtSettings": { + "feePerStep": "0.0001", + "maxStepsPerRound": 500, + "stepsPerFunctionCall": 10, + "minutesPerBlock": 1 + }, + "featureTriggers": { + "messageHeight": 0, + "atHeight": 0, + "assetsTimestamp": 0, + "votingTimestamp": 0, + "arbitraryTimestamp": 0, + "powfixTimestamp": 0, + "qortalTimestamp": 0, + "newAssetPricingTimestamp": 0, + "groupApprovalTimestamp": 0, + "atFindNextTransactionFix": 0, + "newBlockSigHeight": 999999, + "shareBinFix": 999999, + "sharesByLevelV2Height": 999999, + "rewardShareLimitTimestamp": 9999999999999, + "calcChainWeightTimestamp": 0, + "transactionV5Timestamp": 0, + "transactionV6Timestamp": 0, + "disableReferenceTimestamp": 9999999999999, + "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, + "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 20 + }, + "genesisInfo": { + "version": 4, + "timestamp": 0, + "transactions": [ + { "type": "ISSUE_ASSET", "assetName": "QORT", "description": "QORT native coin", "data": "", "quantity": 0, "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "assetName": "Legacy-QORA", "description": "Representative legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + { "type": "ISSUE_ASSET", "assetName": "QORT-from-QORA", "description": "QORT gained from holding legacy QORA", "quantity": 0, "isDivisible": true, "data": "{}", "isUnspendable": true }, + + { "type": "GENESIS", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "amount": "1000000000" }, + { "type": "GENESIS", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "amount": "1000000" }, + { "type": "GENESIS", "recipient": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "amount": "1000000" }, + + { "type": "CREATE_GROUP", "creatorPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupName": "dev-group", "description": "developer group", "isOpen": false, "approvalThreshold": "PCT100", "minimumBlockDelay": 0, "maximumBlockDelay": 1440 }, + + { "type": "UPDATE_GROUP", "ownerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "groupId": 1, "newOwner": "QdSnUy6sUiEnaN87dWmE92g1uQjrvPgrWG", "newDescription": "developer group", "newIsOpen": false, "newApprovalThreshold": "PCT40", "minimumBlockDelay": 10, "maximumBlockDelay": 1440 }, + + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "TEST", "description": "test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "assetName": "OTHER", "description": "other test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + { "type": "ISSUE_ASSET", "issuerPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "assetName": "GOLD", "description": "gold test asset", "data": "", "quantity": "1000000", "isDivisible": true, "fee": 0 }, + + { "type": "ACCOUNT_FLAGS", "target": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "andMask": -1, "orMask": 1, "xorMask": 0 }, + { "type": "REWARD_SHARE", "minterPublicKey": "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP", "recipient": "QgV4s3xnzLhVBEJxcYui4u4q11yhUHsd9v", "rewardSharePublicKey": "7PpfnvLSG7y4HPh8hE7KoqAjLCkv7Ui6xw4mKAkbZtox", "sharePercent": "100" }, + + { "type": "ACCOUNT_LEVEL", "target": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "level": 5 }, + { "type": "REWARD_SHARE", "minterPublicKey": "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry", "recipient": "QixPbJUwsaHsVEofJdozU9zgVqkK6aYhrK", "rewardSharePublicKey": "CcABzvk26TFEHG7Yok84jxyd4oBtLkx8RJdGFVz2csvp", "sharePercent": 100 }, + + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "QaUpHNhT3Ygx6avRiKobuLdusppR5biXjL", "level": 5 }, + { "type": "ACCOUNT_LEVEL", "target": "Qci5m9k4rcwe4ruKrZZQKka4FzUUMut3er", "level": 6 } + ] + } +} diff --git a/src/test/resources/test-settings-v2-self-sponsorship-algo.json b/src/test/resources/test-settings-v2-self-sponsorship-algo.json new file mode 100644 index 00000000..5ea42e66 --- /dev/null +++ b/src/test/resources/test-settings-v2-self-sponsorship-algo.json @@ -0,0 +1,20 @@ +{ + "repositoryPath": "testdb", + "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", + "restrictedApi": false, + "blockchainConfig": "src/test/resources/test-chain-v2-self-sponsorship-algo.json", + "exportPath": "qortal-backup-test", + "bootstrap": false, + "wipeUnconfirmedOnStart": false, + "testNtpOffset": 0, + "minPeers": 0, + "pruneBlockLimit": 100, + "bootstrapFilenamePrefix": "test-", + "dataPath": "data-test", + "tempDataPath": "data-test/_temp", + "listsPath": "lists-test", + "storagePolicy": "FOLLOWED_OR_VIEWED", + "maxStorageCapacity": 104857600, + "arrrDefaultBirthday": 1900000 +} From d435e4047bd3fe0e1d9d03745ff0fe38a2901f8a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 08:21:45 +0000 Subject: [PATCH 35/97] SelfSponsorshipAlgoV1 --- .../qortal/account/SelfSponsorshipAlgoV1.java | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java 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; + } + +} From 9afc31a20ddcc4d028a4aeea7064f6995502c9d1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 08:52:09 +0000 Subject: [PATCH 36/97] selfSponsorshipAlgoV1SnapshotTimestamp set to 1670230000000 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 126ec7ae..270456fc 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -24,7 +24,7 @@ "onlineAccountSignaturesMinLifetime": 43200000, "onlineAccountSignaturesMaxLifetime": 86400000, "onlineAccountsModulusV2Timestamp": 1659801600000, - "selfSponsorshipAlgoV1SnapshotTimestamp": 9999999999999, + "selfSponsorshipAlgoV1SnapshotTimestamp": 1670230000000, "rewardsByHeight": [ { "height": 1, "reward": 5.00 }, { "height": 259201, "reward": 4.75 }, From 4d9964c080fd90923eda9326ed507e03957f2844 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 18:52:33 +0000 Subject: [PATCH 37/97] Block connections with peers older than 3.7.0, as this has been released for long enough now. --- src/main/java/org/qortal/network/Handshake.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index 22354cc4..b2e5f829 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "3.1.0"; + private static final String MIN_PEER_VERSION = "3.7.0"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index acfd0e78..89d18057 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -209,7 +209,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.6.3"; + private String minPeerVersion = "3.7.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From 45a6f495d20c4656342019aa9481c56202b891d5 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 19:38:26 +0000 Subject: [PATCH 38/97] selfSponsorshipAlgoV1Height set to 1092400 (approx 4pm UTC on Sat 10th December) --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 270456fc..c13455d6 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -83,7 +83,7 @@ "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 999999999, - "selfSponsorshipAlgoV1Height": 999999999 + "selfSponsorshipAlgoV1Height": 1092400 }, "genesisInfo": { "version": 4, From 51ad0a5b48fe09c9922c03e93daa41585189399d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 19:38:44 +0000 Subject: [PATCH 39/97] onlineAccountMinterLevelValidationHeight set to 1093400 (approx 20 hours later) --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index c13455d6..d28c1ea0 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -82,7 +82,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, - "onlineAccountMinterLevelValidationHeight": 999999999, + "onlineAccountMinterLevelValidationHeight": 1093400, "selfSponsorshipAlgoV1Height": 1092400 }, "genesisInfo": { From a69618133e9361bf1b44dd0591fe0d1d9f24fb1b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 21:34:26 +0000 Subject: [PATCH 40/97] Level 0 online account removals moved inside feature trigger, so it is coordinated with the new validation. --- src/main/java/org/qortal/block/Block.java | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index a31c522b..df0ca7cd 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -378,15 +378,17 @@ public class Block { List onlineAccounts = OnlineAccountsManager.getInstance().getOnlineAccounts(onlineAccountsTimestamp); onlineAccounts.removeIf(a -> a.getNonce() == null || a.getNonce() < 0); - // Remove any online accounts that are level 0 - onlineAccounts.removeIf(a -> { - try { - return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; - } catch (DataException e) { - // Something went wrong, so remove the account - return true; - } - }); + // After feature trigger, remove any online accounts that are level 0 + if (height >= BlockChain.getInstance().getOnlineAccountMinterLevelValidationHeight()) { + onlineAccounts.removeIf(a -> { + try { + return Account.getRewardShareEffectiveMintingLevel(repository, a.getPublicKey()) == 0; + } catch (DataException e) { + // Something went wrong, so remove the account + return true; + } + }); + } if (onlineAccounts.isEmpty()) { LOGGER.debug("No online accounts - not even our own?"); From 6f95e7c1c8b1f65dc04a949a15da347c8671c11a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 5 Dec 2022 21:57:32 +0000 Subject: [PATCH 41/97] Bump version to 3.8.0 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 52c574b0..b52dd2fc 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.7.0 + 3.8.0 jar true From 12fb6cd0adcf0cf942eb144d7371a2829d29d498 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:24:34 +0000 Subject: [PATCH 42/97] onlineAccountMinterLevelValidationHeight moved forward to block 1092000 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index d28c1ea0..7e4497fe 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -82,7 +82,7 @@ "transactionV6Timestamp": 9999999999999, "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, - "onlineAccountMinterLevelValidationHeight": 1093400, + "onlineAccountMinterLevelValidationHeight": 1092000, "selfSponsorshipAlgoV1Height": 1092400 }, "genesisInfo": { From ccc1976d0053b6c052356f3c9440262146104ca3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:25:03 +0000 Subject: [PATCH 43/97] Added defensiveness --- src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java index 474bbdf2..725e53f5 100644 --- a/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java +++ b/src/main/java/org/qortal/account/SelfSponsorshipAlgoV1.java @@ -52,6 +52,11 @@ public class SelfSponsorshipAlgoV1 { public void run() throws DataException { + if (this.accountData == null) { + // Nothing to do + return; + } + this.fetchSponsorshipRewardShares(); if (this.sponsorshipRewardShares.isEmpty()) { // Nothing to do From 5c9109aca9197db5e662f1b74b849a469e3b8316 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:25:19 +0000 Subject: [PATCH 44/97] minPeerVersion set to 3.8.0 --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 89d18057..9045d0ad 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -209,7 +209,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.7.0"; + private String minPeerVersion = "3.8.0"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From cdeb2052b07dabef6df92bfddf1f9c55f156c1e8 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 8 Dec 2022 18:26:34 +0000 Subject: [PATCH 45/97] Bump version to 3.8.1 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b52dd2fc..da6d8e27 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.0 + 3.8.1 jar true From 1dc7f056f9cb9b08fb44ae50896844cfcd144ead Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 14 Dec 2022 16:39:43 +0000 Subject: [PATCH 46/97] Filter out peers of divergent or significantly inferior chains when syncing. --- src/main/java/org/qortal/controller/Controller.java | 13 +++++++++++++ .../java/org/qortal/controller/Synchronizer.java | 3 +++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0a323cb2..182889f5 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -752,6 +752,19 @@ public class Controller extends Thread { return peerChainTipData == null || peerChainTipData.getSignature() == null || inferiorChainTips.contains(ByteArray.wrap(peerChainTipData.getSignature())); }; + /** + * If a peer has a recent block timestamp, but its height is more than 25 blocks behind ours, + * we can assume it has a significantly inferior chain, and is most likely too divergent. + * Early filtering of these peers prevents a lot of very expensive chain weight comparisons. + */ + public static final Predicate hasInferiorChain = peer -> { + final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); + final int ourHeight = Controller.getInstance().getChainHeight(); + final BlockSummaryData peerChainTipData = peer.getChainTipData(); + boolean peerUpToDate = peerChainTipData != null && peerChainTipData.getTimestamp() != null && peerChainTipData.getTimestamp() >= minLatestBlockTimestamp; + return peerUpToDate && ourHeight - peerChainTipData.getHeight() > 25; + }; + public static final Predicate hasOldVersion = peer -> { final String minPeerVersion = Settings.getInstance().getMinPeerVersion(); return peer.isAtLeastVersion(minPeerVersion) == false; diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index e3ace9ed..54b13580 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -247,6 +247,9 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); + // Disregard peers that are on a very inferior chain, based on their heights and timestamps + peers.removeIf(Controller.hasInferiorChain); + // Disregard peers that have a block with an invalid signer peers.removeIf(Controller.hasInvalidSigner); From 99d5bf91031b0919f7e67024237bbe97a654f56d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 14 Dec 2022 16:40:11 +0000 Subject: [PATCH 47/97] Disallow transactions with timestamps more than 30 mins in the future (reduced from 24 hours) --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 9045d0ad..7372a7c9 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -110,7 +110,7 @@ public class Settings { /** Maximum number of unconfirmed transactions allowed per account */ private int maxUnconfirmedPerAccount = 25; /** Max milliseconds into future for accepting new, unconfirmed transactions */ - private int maxTransactionTimestampFuture = 24 * 60 * 60 * 1000; // milliseconds + private int maxTransactionTimestampFuture = 30 * 60 * 1000; // milliseconds /** Whether we check, fetch and install auto-updates */ private boolean autoUpdateEnabled = true; /** How long between repository backups (ms), or 0 if disabled. */ From 08de1fb4ec0604de2a01a30a96059d283c777c4a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 14 Dec 2022 16:40:57 +0000 Subject: [PATCH 48/97] Disallow CHAT transactions with timestamps more than 5 minutes in the future. --- src/main/java/org/qortal/transaction/ChatTransaction.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index 9cccd42a..b4ae9f37 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -19,6 +19,7 @@ import org.qortal.repository.Repository; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.ChatTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.NTP; public class ChatTransaction extends Transaction { @@ -145,6 +146,11 @@ public class ChatTransaction extends Transaction { public ValidationResult isValid() throws DataException { // Nonce checking is done via isSignatureValid() as that method is only called once per import + // Disregard messages with timestamp too far in the future (we have stricter limits for CHAT transactions) + if (this.chatTransactionData.getTimestamp() > NTP.getTime() + (5 * 60 * 1000L)) { + return ValidationResult.TIMESTAMP_TOO_NEW; + } + // Check for blocked author by address ResourceListManager listManager = ResourceListManager.getInstance(); if (listManager.listContains("blockedAddresses", this.chatTransactionData.getSender(), true)) { From 80048208d1b12452167972652c5a15f6dc726673 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 15 Dec 2022 12:14:42 +0000 Subject: [PATCH 49/97] Moved some test sponsorship utility methods to AccountUtils, so they can be used in other test classes too. --- .../test/SelfSponsorshipAlgoV1Tests.java | 205 +++++++----------- .../org/qortal/test/common/AccountUtils.java | 57 ++++- 2 files changed, 131 insertions(+), 131 deletions(-) diff --git a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java index 91628dd3..397a1bbe 100644 --- a/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java +++ b/src/test/java/org/qortal/test/SelfSponsorshipAlgoV1Tests.java @@ -50,8 +50,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob self sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -73,7 +73,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -139,18 +139,18 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -172,7 +172,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -270,20 +270,20 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccountsAliceSigner.addAll(chloeSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccountsAliceSigner.addAll(dilbertSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(dilbertSponseesOnlineAccounts); @@ -306,7 +306,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccountsAliceSigner.addAll(bobSponseeSelfShares); onlineAccountsBobSigner.addAll(bobSponseeSelfShares); @@ -382,14 +382,14 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Alice sponsors 10 accounts - List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); - List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); onlineAccountsAliceSigner.addAll(aliceSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(aliceSponseesOnlineAccounts); // Bob sponsors 9 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccountsAliceSigner.addAll(bobSponseesOnlineAccounts); onlineAccountsBobSigner.addAll(bobSponseesOnlineAccounts); @@ -412,7 +412,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); onlineAccountsAliceSigner.addAll(aliceSponseeSelfShares); onlineAccountsBobSigner.addAll(aliceSponseeSelfShares); @@ -483,18 +483,18 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -516,7 +516,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -597,14 +597,14 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Alice sponsors 10 accounts - List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); - List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); onlineAccounts.addAll(aliceSponseesOnlineAccounts); onlineAccounts.addAll(aliceSponseesOnlineAccounts); // Bob sponsors 9 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 9); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 9); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); onlineAccounts.addAll(bobSponseesOnlineAccounts); @@ -627,7 +627,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, aliceSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); onlineAccounts.addAll(aliceSponseeSelfShares); // Mint blocks (Bob is the signer) @@ -706,13 +706,13 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount chloeAccount = Common.getTestAccount(repository, "chloe"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors 10 accounts - List chloeSponsees = generateSponsorshipRewardShares(repository, chloeAccount, 10); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponsees = AccountUtils.generateSponsorshipRewardShares(repository, chloeAccount, 10); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Mint blocks @@ -728,7 +728,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -744,22 +744,22 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertEquals(19, (int) block.getBlockData().getHeight()); // Bob creates a valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); // Mint a block, so the algo runs block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Bob can no longer create a reward share transaction - assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, AccountUtils.createRandomRewardShare(repository, bobAccount)); // ... but Chloe still can - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, chloeAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, chloeAccount)); // Orphan last block BlockUtils.orphanLastBlock(repository); // Bob creates another valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); // Run orphan check - this can't be in afterTest() because some tests access the live db Common.orphanCheck(); @@ -780,13 +780,13 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Alice sponsors 10 accounts - List aliceSponsees = generateSponsorshipRewardShares(repository, aliceAccount, 10); - List aliceSponseesOnlineAccounts = toRewardShares(repository, aliceAccount, aliceSponsees); + List aliceSponsees = AccountUtils.generateSponsorshipRewardShares(repository, aliceAccount, 10); + List aliceSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, aliceAccount, aliceSponsees); onlineAccounts.addAll(aliceSponseesOnlineAccounts); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -802,7 +802,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Generate self shares so the sponsees can start minting - List aliceSponseeSelfShares = generateSelfShares(repository, aliceSponsees); + List aliceSponseeSelfShares = AccountUtils.generateSelfShares(repository, aliceSponsees); onlineAccounts.addAll(aliceSponseeSelfShares); // Mint blocks @@ -818,7 +818,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertEquals(19, (int) block.getBlockData().getHeight()); // Alice creates a valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, aliceAccount)); // Mint a block, so the algo runs block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); @@ -830,16 +830,16 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertEquals(0, (int) new Account(repository, aliceAccount.getAddress()).getLevel()); // Alice can no longer create a reward share transaction - assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, createRandomRewardShare(repository, aliceAccount)); + assertEquals(Transaction.ValidationResult.ACCOUNT_CANNOT_REWARD_SHARE, AccountUtils.createRandomRewardShare(repository, aliceAccount)); // ... but Bob still can - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, bobAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, bobAccount)); // Orphan last block BlockUtils.orphanLastBlock(repository); // Alice creates another valid reward share transaction - assertEquals(Transaction.ValidationResult.OK, createRandomRewardShare(repository, aliceAccount)); + assertEquals(Transaction.ValidationResult.OK, AccountUtils.createRandomRewardShare(repository, aliceAccount)); // Run orphan check - this can't be in afterTest() because some tests access the live db Common.orphanCheck(); @@ -867,8 +867,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Dilbert sponsors 10 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -921,8 +921,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Dilbert sponsors 10 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 10); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 10); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -935,7 +935,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { block = BlockMinter.mintTestingBlock(repository, onlineAccounts.toArray(new PrivateKeyAccount[0])); // Generate self shares so the sponsees can start minting - List dilbertSponseeSelfShares = generateSelfShares(repository, dilbertSponsees); + List dilbertSponseeSelfShares = AccountUtils.generateSelfShares(repository, dilbertSponsees); onlineAccounts.addAll(dilbertSponseeSelfShares); // Mint blocks @@ -985,8 +985,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount dilbertAccount = Common.getTestAccount(repository, "dilbert"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Chloe sponsors THE SAME 10 accounts @@ -996,12 +996,12 @@ public class SelfSponsorshipAlgoV1Tests extends Common { TransactionUtils.signAndImportValid(repository, transactionData, chloeAccount); } List chloeSponsees = new ArrayList<>(bobSponsees); - List chloeSponseesOnlineAccounts = toRewardShares(repository, chloeAccount, chloeSponsees); + List chloeSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, chloeAccount, chloeSponsees); onlineAccounts.addAll(chloeSponseesOnlineAccounts); // Dilbert sponsors 5 accounts - List dilbertSponsees = generateSponsorshipRewardShares(repository, dilbertAccount, 5); - List dilbertSponseesOnlineAccounts = toRewardShares(repository, dilbertAccount, dilbertSponsees); + List dilbertSponsees = AccountUtils.generateSponsorshipRewardShares(repository, dilbertAccount, 5); + List dilbertSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, dilbertAccount, dilbertSponsees); onlineAccounts.addAll(dilbertSponseesOnlineAccounts); // Mint blocks @@ -1023,7 +1023,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1108,8 +1108,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1128,7 +1128,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1220,8 +1220,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1240,7 +1240,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1316,8 +1316,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1336,7 +1336,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1392,8 +1392,8 @@ public class SelfSponsorshipAlgoV1Tests extends Common { PrivateKeyAccount bobAccount = Common.getTestAccount(repository, "bob"); // Bob sponsors 10 accounts - List bobSponsees = generateSponsorshipRewardShares(repository, bobAccount, 10); - List bobSponseesOnlineAccounts = toRewardShares(repository, bobAccount, bobSponsees); + List bobSponsees = AccountUtils.generateSponsorshipRewardShares(repository, bobAccount, 10); + List bobSponseesOnlineAccounts = AccountUtils.toRewardShares(repository, bobAccount, bobSponsees); onlineAccounts.addAll(bobSponseesOnlineAccounts); // Mint blocks @@ -1412,7 +1412,7 @@ public class SelfSponsorshipAlgoV1Tests extends Common { assertTrue(new Account(repository, bobSponsee.getAddress()).getLevel() > 0); // Generate self shares so the sponsees can start minting - List bobSponseeSelfShares = generateSelfShares(repository, bobSponsees); + List bobSponseeSelfShares = AccountUtils.generateSelfShares(repository, bobSponsees); onlineAccounts.addAll(bobSponseeSelfShares); // Mint blocks @@ -1543,61 +1543,6 @@ public class SelfSponsorshipAlgoV1Tests extends Common { return new TransferPrivsTransaction(repository, transactionData); } - private static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { - final int sharePercent = 0; - Random random = new Random(); - - List sponsees = new ArrayList<>(); - for (int i = 0; i < accountsCount; i++) { - - // Generate random sponsee account - byte[] randomPrivateKey = new byte[32]; - random.nextBytes(randomPrivateKey); - PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); - sponsees.add(sponseeAccount); - - // Create reward-share - TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); - TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); - } - - return sponsees; - } - - private static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { - // Bob attempts to create a reward share transaction - byte[] randomPrivateKey = new byte[32]; - new Random().nextBytes(randomPrivateKey); - PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); - TransactionData transactionData = AccountUtils.createRewardShare(repository, account, sponseeAccount, 0, fee); - return TransactionUtils.signAndImport(repository, transactionData, account); - } - - private static List generateSelfShares(Repository repository, List accounts) throws DataException { - final int sharePercent = 0; - - for (PrivateKeyAccount account : accounts) { - // Create reward-share - TransactionData transactionData = AccountUtils.createRewardShare(repository, account, account, sharePercent, 0L); - TransactionUtils.signAndImportValid(repository, transactionData, account); - } - - return toRewardShares(repository, null, accounts); - } - - private static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { - List rewardShares = new ArrayList<>(); - - for (PrivateKeyAccount account : accounts) { - PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; - byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); - PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); - rewardShares.add(rewardShareAccount); - } - - return rewardShares; - } - private boolean areAllAccountsPresentInBlock(List accounts, Block block) throws DataException { for (PrivateKeyAccount bobSponsee : accounts) { boolean foundOnlineAccountInBlock = false; diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index c31cd85e..bdfd124b 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -8,7 +8,6 @@ import java.util.*; import com.google.common.primitives.Longs; import org.qortal.account.PrivateKeyAccount; -import org.qortal.block.BlockChain; import org.qortal.crypto.Crypto; import org.qortal.crypto.Qortal25519Extras; import org.qortal.data.network.OnlineAccountData; @@ -19,6 +18,7 @@ import org.qortal.data.transaction.TransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; +import org.qortal.transaction.Transaction; import org.qortal.transform.Transformer; import org.qortal.utils.Amounts; @@ -86,6 +86,61 @@ public class AccountUtils { return rewardSharePrivateKey; } + public static List generateSponsorshipRewardShares(Repository repository, PrivateKeyAccount sponsorAccount, int accountsCount) throws DataException { + final int sharePercent = 0; + Random random = new Random(); + + List sponsees = new ArrayList<>(); + for (int i = 0; i < accountsCount; i++) { + + // Generate random sponsee account + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + sponsees.add(sponseeAccount); + + // Create reward-share + TransactionData transactionData = AccountUtils.createRewardShare(repository, sponsorAccount, sponseeAccount, sharePercent, fee); + TransactionUtils.signAndImportValid(repository, transactionData, sponsorAccount); + } + + return sponsees; + } + + public static Transaction.ValidationResult createRandomRewardShare(Repository repository, PrivateKeyAccount account) throws DataException { + // Bob attempts to create a reward share transaction + byte[] randomPrivateKey = new byte[32]; + new Random().nextBytes(randomPrivateKey); + PrivateKeyAccount sponseeAccount = new PrivateKeyAccount(repository, randomPrivateKey); + TransactionData transactionData = createRewardShare(repository, account, sponseeAccount, 0, fee); + return TransactionUtils.signAndImport(repository, transactionData, account); + } + + public static List generateSelfShares(Repository repository, List accounts) throws DataException { + final int sharePercent = 0; + + for (PrivateKeyAccount account : accounts) { + // Create reward-share + TransactionData transactionData = createRewardShare(repository, account, account, sharePercent, 0L); + TransactionUtils.signAndImportValid(repository, transactionData, account); + } + + return toRewardShares(repository, null, accounts); + } + + public static List toRewardShares(Repository repository, PrivateKeyAccount parentAccount, List accounts) { + List rewardShares = new ArrayList<>(); + + for (PrivateKeyAccount account : accounts) { + PrivateKeyAccount sponsor = (parentAccount != null) ? parentAccount : account; + byte[] rewardSharePrivateKey = sponsor.getRewardSharePrivateKey(account.getPublicKey()); + PrivateKeyAccount rewardShareAccount = new PrivateKeyAccount(repository, rewardSharePrivateKey); + rewardShares.add(rewardShareAccount); + } + + return rewardShares; + } + public static Map> getBalances(Repository repository, long... assetIds) throws DataException { Map> balances = new HashMap<>(); From cf3195cb833772dccedc16f1dda2451d8823f848 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 18 Dec 2022 18:32:06 +0000 Subject: [PATCH 50/97] Set "minAccountsToActivateShareBin" to 0 for certain tests. --- src/test/resources/test-chain-v2-self-sponsorship-algo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 7712ceb1..36df9a62 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -44,7 +44,7 @@ { "height": 1000000, "share": 0.01 } ], "qoraPerQortReward": 250, - "minAccountsToActivateShareBin": 30, + "minAccountsToActivateShareBin": 0, "shareBinActivationMinLevel": 7, "blocksNeededByLevel": [ 5, 20, 30, 40, 50, 60, 18, 80, 90, 100 ], "blockTimingsByHeight": [ From e678ea22e0e9ce8933f39a39a107b149193a06ed Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 18 Dec 2022 18:33:51 +0000 Subject: [PATCH 51/97] Fixed NPE in unit tests. Still need to work out how/when this was introduced. --- src/test/java/org/qortal/test/common/Common.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/qortal/test/common/Common.java b/src/test/java/org/qortal/test/common/Common.java index 3270a795..bb6cc1cb 100644 --- a/src/test/java/org/qortal/test/common/Common.java +++ b/src/test/java/org/qortal/test/common/Common.java @@ -120,7 +120,9 @@ public class Common { } public static void useSettingsAndDb(String settingsFilename, boolean dbInMemory) throws DataException { - closeRepository(); + if (RepositoryManager.getRepositoryFactory() != null) { + closeRepository(); + } // Load/check settings, which potentially sets up blockchain config, etc. LOGGER.debug(String.format("Using setting file: %s", settingsFilename)); From e40dc4af59c5f301a935332a75a992e9e0b1b7b0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:16:57 +0000 Subject: [PATCH 52/97] Fixed group ban expiry. --- .../qortal/repository/GroupRepository.java | 9 +- .../hsqldb/HSQLDBGroupRepository.java | 4 +- .../CancelGroupBanTransaction.java | 2 +- .../transaction/GroupInviteTransaction.java | 2 +- .../transaction/JoinGroupTransaction.java | 2 +- .../transaction/UpdateGroupTransaction.java | 2 +- .../org/qortal/test/group/AdminTests.java | 154 +++++++++++++++++- 7 files changed, 161 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/repository/GroupRepository.java b/src/main/java/org/qortal/repository/GroupRepository.java index bcee7d25..94c97992 100644 --- a/src/main/java/org/qortal/repository/GroupRepository.java +++ b/src/main/java/org/qortal/repository/GroupRepository.java @@ -131,7 +131,14 @@ public interface GroupRepository { public GroupBanData getBan(int groupId, String member) throws DataException; - public boolean banExists(int groupId, String offender) throws DataException; + /** + * IMPORTANT: when using banExists() as part of validation, the timestamp must be that of the transaction that + * is calling banExists() as part of its validation. It must NOT be the current time, unless this is being + * called outside of validation, as part of an on demand check for a ban existing (such as via an API call). + * This is because we need to evaluate a ban's status based on the time of the subsequent transaction, as + * validation will not occur at a fixed time for every node. For some, it could be months into the future. + */ + public boolean banExists(int groupId, String offender, long timestamp) throws DataException; public List getGroupBans(int groupId, Integer limit, Integer offset, Boolean reverse) throws DataException; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java index 91db22f1..b1cd40a0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBGroupRepository.java @@ -777,9 +777,9 @@ public class HSQLDBGroupRepository implements GroupRepository { } @Override - public boolean banExists(int groupId, String offender) throws DataException { + public boolean banExists(int groupId, String offender, long timestamp) throws DataException { try { - return this.repository.exists("GroupBans", "group_id = ? AND offender = ?", groupId, offender); + return this.repository.exists("GroupBans", "group_id = ? AND offender = ? AND (expires_when IS NULL OR expires_when > ?)", groupId, offender, timestamp); } catch (SQLException e) { throw new DataException("Unable to check for group ban in repository", e); } diff --git a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java index 483dfc6f..08d9cb3e 100644 --- a/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java +++ b/src/main/java/org/qortal/transaction/CancelGroupBanTransaction.java @@ -73,7 +73,7 @@ public class CancelGroupBanTransaction extends Transaction { Account member = getMember(); // Check ban actually exists - if (!this.repository.getGroupRepository().banExists(groupId, member.getAddress())) + if (!this.repository.getGroupRepository().banExists(groupId, member.getAddress(), this.groupUnbanTransactionData.getTimestamp())) return ValidationResult.BAN_UNKNOWN; // Check admin has enough funds diff --git a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java index f3b08f59..fa5e7b85 100644 --- a/src/main/java/org/qortal/transaction/GroupInviteTransaction.java +++ b/src/main/java/org/qortal/transaction/GroupInviteTransaction.java @@ -78,7 +78,7 @@ public class GroupInviteTransaction extends Transaction { return ValidationResult.ALREADY_GROUP_MEMBER; // Check invitee is not banned - if (this.repository.getGroupRepository().banExists(groupId, invitee.getAddress())) + if (this.repository.getGroupRepository().banExists(groupId, invitee.getAddress(), this.groupInviteTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; // Check creator has enough funds diff --git a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java index bc62c629..3061a3fb 100644 --- a/src/main/java/org/qortal/transaction/JoinGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/JoinGroupTransaction.java @@ -53,7 +53,7 @@ public class JoinGroupTransaction extends Transaction { return ValidationResult.ALREADY_GROUP_MEMBER; // Check member is not banned - if (this.repository.getGroupRepository().banExists(groupId, joiner.getAddress())) + if (this.repository.getGroupRepository().banExists(groupId, joiner.getAddress(), this.joinGroupTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; // Check join request doesn't already exist diff --git a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java index 9664ccbf..27580430 100644 --- a/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java +++ b/src/main/java/org/qortal/transaction/UpdateGroupTransaction.java @@ -103,7 +103,7 @@ public class UpdateGroupTransaction extends Transaction { Account newOwner = getNewOwner(); // Check new owner is not banned - if (this.repository.getGroupRepository().banExists(this.updateGroupTransactionData.getGroupId(), newOwner.getAddress())) + if (this.repository.getGroupRepository().banExists(this.updateGroupTransactionData.getGroupId(), newOwner.getAddress(), this.updateGroupTransactionData.getTimestamp())) return ValidationResult.BANNED_FROM_GROUP; return ValidationResult.OK; diff --git a/src/test/java/org/qortal/test/group/AdminTests.java b/src/test/java/org/qortal/test/group/AdminTests.java index a39b23d7..8cf83c29 100644 --- a/src/test/java/org/qortal/test/group/AdminTests.java +++ b/src/test/java/org/qortal/test/group/AdminTests.java @@ -135,7 +135,8 @@ public class AdminTests extends Common { assertNotSame(ValidationResult.OK, result); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + int timeToLive = 0; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -158,7 +159,7 @@ public class AdminTests extends Common { assertTrue(isMember(repository, bob.getAddress(), groupId)); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -205,6 +206,144 @@ public class AdminTests extends Common { } } + @Test + public void testGroupBanMemberWithExpiry() throws DataException, InterruptedException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + PrivateKeyAccount bob = Common.getTestAccount(repository, "bob"); + + // Create group + int groupId = createGroup(repository, alice, "open-group", true); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Attempt to cancel non-existent Bob ban + ValidationResult result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Attempt to ban Bob for 2 seconds + int timeToLive = 2; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Wait for 2 seconds to pass + Thread.sleep(2000L); + + // Bob attempts to rejoin again + result = joinGroup(repository, bob, groupId); + // Should be OK, as the ban has expired + assertSame(ValidationResult.OK, result); + + // Confirm Bob is now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + + // Confirm Bob is not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob to join + result = joinGroup(repository, bob, groupId); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + + // Attempt to ban Bob for 2 seconds + result = groupBan(repository, alice, groupId, bob.getAddress(), 2); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Wait for 2 seconds to pass + Thread.sleep(2000L); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should NOT be OK, as ban has already expired + assertNotSame(ValidationResult.OK, result); + + // Confirm Bob still not a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK, as no longer banned + assertSame(ValidationResult.OK, result); + + // Confirm Bob is now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + + + // Attempt to ban Bob for 10 seconds + result = groupBan(repository, alice, groupId, bob.getAddress(), 10); + // Should be OK + assertEquals(ValidationResult.OK, result); + + // Confirm Bob no longer a member + assertFalse(isMember(repository, bob.getAddress(), groupId)); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK, as ban still exists + assertNotSame(ValidationResult.OK, result); + + // Cancel Bob's ban + result = cancelGroupBan(repository, alice, groupId, bob.getAddress()); + // Should be OK, as ban still exists + assertEquals(ValidationResult.OK, result); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should be OK, as no longer banned + assertEquals(ValidationResult.OK, result); + + // Orphan last block (Bob join) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed join-group transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Orphan last block (Cancel Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed cancel-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Bob attempts to rejoin + result = joinGroup(repository, bob, groupId); + // Should NOT be OK + assertNotSame(ValidationResult.OK, result); + + // Orphan last block (Bob ban) + BlockUtils.orphanLastBlock(repository); + // Delete unconfirmed group-ban transaction + TransactionUtils.deleteUnconfirmedTransactions(repository); + + // Confirm Bob now a member + assertTrue(isMember(repository, bob.getAddress(), groupId)); + } + } + @Test public void testGroupBanAdmin() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { @@ -226,7 +365,8 @@ public class AdminTests extends Common { assertTrue(isAdmin(repository, bob.getAddress(), groupId)); // Attempt to ban Bob - result = groupBan(repository, alice, groupId, bob.getAddress()); + int timeToLive = 0; + result = groupBan(repository, alice, groupId, bob.getAddress(), timeToLive); // Should be OK assertEquals(ValidationResult.OK, result); @@ -272,12 +412,12 @@ public class AdminTests extends Common { assertTrue(isAdmin(repository, bob.getAddress(), groupId)); // Have Alice (owner) try to ban herself! - result = groupBan(repository, alice, groupId, alice.getAddress()); + result = groupBan(repository, alice, groupId, alice.getAddress(), timeToLive); // Should NOT be OK assertNotSame(ValidationResult.OK, result); // Have Bob try to ban Alice (owner) - result = groupBan(repository, bob, groupId, alice.getAddress()); + result = groupBan(repository, bob, groupId, alice.getAddress(), timeToLive); // Should NOT be OK assertNotSame(ValidationResult.OK, result); } @@ -316,8 +456,8 @@ public class AdminTests extends Common { return result; } - private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member) throws DataException { - GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", 0); + private ValidationResult groupBan(Repository repository, PrivateKeyAccount admin, int groupId, String member, int timeToLive) throws DataException { + GroupBanTransactionData transactionData = new GroupBanTransactionData(TestTransaction.generateBase(admin), groupId, member, "testing", timeToLive); ValidationResult result = TransactionUtils.signAndImport(repository, transactionData, admin); if (result == ValidationResult.OK) From a75ed0e63480b198e965fb89c3db9cccfc718cd4 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:18:39 +0000 Subject: [PATCH 53/97] Bump additional expandedAccount level references held in memory. --- src/main/java/org/qortal/block/Block.java | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java index df0ca7cd..3f306b93 100644 --- a/src/main/java/org/qortal/block/Block.java +++ b/src/main/java/org/qortal/block/Block.java @@ -1522,6 +1522,9 @@ public class Block { // Batch update in repository repository.getAccountRepository().modifyMintedBlockCounts(allUniqueExpandedAccounts.stream().map(AccountData::getAddress).collect(Collectors.toList()), +1); + // Keep track of level bumps in case we need to apply to other entries + Map bumpedAccounts = new HashMap<>(); + // Local changes and also checks for level bump for (AccountData accountData : allUniqueExpandedAccounts) { // Adjust count locally (in Java) @@ -1535,6 +1538,7 @@ public class Block { if (newLevel > accountData.getLevel()) { // Account has increased in level! accountData.setLevel(newLevel); + bumpedAccounts.put(accountData.getAddress(), newLevel); repository.getAccountRepository().setLevel(accountData); LOGGER.trace(() -> String.format("Block minter %s bumped to level %d", accountData.getAddress(), accountData.getLevel())); } @@ -1542,6 +1546,25 @@ public class Block { break; } } + + // Also bump other entries if need be + if (!bumpedAccounts.isEmpty()) { + for (ExpandedAccount expandedAccount : expandedAccounts) { + Integer newLevel = bumpedAccounts.get(expandedAccount.mintingAccountData.getAddress()); + if (newLevel != null && expandedAccount.mintingAccountData.getLevel() != newLevel) { + expandedAccount.mintingAccountData.setLevel(newLevel); + LOGGER.trace("Also bumped {} to level {}", expandedAccount.mintingAccountData.getAddress(), newLevel); + } + + if (!expandedAccount.isRecipientAlsoMinter) { + newLevel = bumpedAccounts.get(expandedAccount.recipientAccountData.getAddress()); + if (newLevel != null && expandedAccount.recipientAccountData.getLevel() != newLevel) { + expandedAccount.recipientAccountData.setLevel(newLevel); + LOGGER.trace("Also bumped {} to level {}", expandedAccount.recipientAccountData.getAddress(), newLevel); + } + } + } + } } protected void processBlockRewards() throws DataException { From 7ae142fa641ec1209d2b8c046c05dfca6396723b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:20:42 +0000 Subject: [PATCH 54/97] Improved transaction validation. --- .../org/qortal/transaction/RewardShareTransaction.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index ed5029b2..3b9a251e 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -163,11 +163,9 @@ public class RewardShareTransaction extends Transaction { return ValidationResult.SELF_SHARE_EXISTS; } - // Fee checking needed if not setting up new self-share - if (!(isRecipientAlsoMinter && existingRewardShareData == null)) - // Check creator has enough funds - if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) - return ValidationResult.NO_BALANCE; + // Check creator has enough funds + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; return ValidationResult.OK; } From 758a02d71af15364a1b13acc3ac7d61528122e0a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:23:30 +0000 Subject: [PATCH 55/97] Log Pirate light client server address if the wallet unable to be initialized. --- src/main/java/org/qortal/crosschain/PirateWallet.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/PirateWallet.java b/src/main/java/org/qortal/crosschain/PirateWallet.java index 6c6ed2a9..4b95d3cc 100644 --- a/src/main/java/org/qortal/crosschain/PirateWallet.java +++ b/src/main/java/org/qortal/crosschain/PirateWallet.java @@ -117,7 +117,7 @@ public class PirateWallet { // Restore existing wallet String response = LiteWalletJni.initfromb64(serverUri, params, wallet, saplingOutput64, saplingSpend64); if (response != null && !response.contains("\"initalized\":true")) { - LOGGER.info("Unable to initialize Pirate Chain wallet: {}", response); + LOGGER.info("Unable to initialize Pirate Chain wallet at {}: {}", serverUri, response); return false; } this.seedPhrase = inputSeedPhrase; From bb74b2d4f607b6439594ef88bc6756d4f92a5d50 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 14:25:10 +0000 Subject: [PATCH 56/97] MAX_AVG_RESPONSE_TIME for ElectrumX servers increased from 0.5s to 1s. --- src/main/java/org/qortal/crosschain/ElectrumX.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index a2a42089..e1eb1963 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -40,7 +40,7 @@ public class ElectrumX extends BitcoinyBlockchainProvider { private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported"; private static final int RESPONSE_TIME_READINGS = 5; - private static final long MAX_AVG_RESPONSE_TIME = 500L; // ms + private static final long MAX_AVG_RESPONSE_TIME = 1000L; // ms public static class Server { String hostname; From 2a4ac1ed2432b2b3428b0b122c1d38d62b1ccdce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 15:09:04 +0000 Subject: [PATCH 57/97] Limit to 250 CHAT messages per hour per account. --- .../java/org/qortal/settings/Settings.java | 14 +++++++++ .../qortal/transaction/ChatTransaction.java | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 7372a7c9..0423f855 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -111,6 +111,12 @@ public class Settings { private int maxUnconfirmedPerAccount = 25; /** Max milliseconds into future for accepting new, unconfirmed transactions */ private int maxTransactionTimestampFuture = 30 * 60 * 1000; // milliseconds + + /** Maximum number of CHAT transactions allowed per account in recent timeframe */ + private int maxRecentChatMessagesPerAccount = 250; + /** Maximum age of a CHAT transaction to be considered 'recent' */ + private long recentChatMessagesMaxAge = 60 * 60 * 1000L; // milliseconds + /** Whether we check, fetch and install auto-updates */ private boolean autoUpdateEnabled = true; /** How long between repository backups (ms), or 0 if disabled. */ @@ -640,6 +646,14 @@ public class Settings { return this.maxTransactionTimestampFuture; } + public int getMaxRecentChatMessagesPerAccount() { + return this.maxRecentChatMessagesPerAccount; + } + + public long getRecentChatMessagesMaxAge() { + return recentChatMessagesMaxAge; + } + public int getBlockCacheSize() { return this.blockCacheSize; } diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index b4ae9f37..72fea7a1 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -1,7 +1,9 @@ package org.qortal.transaction; +import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.function.Predicate; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; @@ -16,6 +18,7 @@ import org.qortal.list.ResourceListManager; import org.qortal.repository.DataException; import org.qortal.repository.GroupRepository; import org.qortal.repository.Repository; +import org.qortal.settings.Settings; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.ChatTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; @@ -169,6 +172,14 @@ public class ChatTransaction extends Transaction { } } + PublicKeyAccount creator = this.getCreator(); + if (creator == null) + return ValidationResult.MISSING_CREATOR; + + // Reject if unconfirmed pile already has X recent CHAT transactions from same creator + if (countRecentChatTransactionsByCreator(creator) >= Settings.getInstance().getMaxRecentChatMessagesPerAccount()) + return ValidationResult.TOO_MANY_UNCONFIRMED; + // If we exist in the repository then we've been imported as unconfirmed, // but we don't want to make it into a block, so return fake non-OK result. if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature())) @@ -219,6 +230,26 @@ public class ChatTransaction extends Transaction { return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); } + private int countRecentChatTransactionsByCreator(PublicKeyAccount creator) throws DataException { + List unconfirmedTransactions = repository.getTransactionRepository().getUnconfirmedTransactions(); + final Long now = NTP.getTime(); + long recentThreshold = Settings.getInstance().getRecentChatMessagesMaxAge(); + + // We only care about chat transactions, and only those that are considered 'recent' + Predicate hasSameCreatorAndIsRecentChat = transactionData -> { + if (transactionData.getType() != TransactionType.CHAT) + return false; + + if (transactionData.getTimestamp() < now - recentThreshold) + return false; + + return Arrays.equals(creator.getPublicKey(), transactionData.getCreatorPublicKey()); + }; + + return (int) unconfirmedTransactions.stream().filter(hasSameCreatorAndIsRecentChat).count(); + } + + /** * Ensure there's at least a skeleton account so people * can retrieve sender's public key using address, even if all their messages From 0e81665a36b192af5957b3df2bb106f16ca8e38d Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 15:10:19 +0000 Subject: [PATCH 58/97] Revert "Filter out peers of divergent or significantly inferior chains when syncing." This reverts commit 1dc7f056f9cb9b08fb44ae50896844cfcd144ead. To be un-reverted in future when there is more time available for testing. --- src/main/java/org/qortal/controller/Controller.java | 13 ------------- .../java/org/qortal/controller/Synchronizer.java | 3 --- 2 files changed, 16 deletions(-) diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 182889f5..0a323cb2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -752,19 +752,6 @@ public class Controller extends Thread { return peerChainTipData == null || peerChainTipData.getSignature() == null || inferiorChainTips.contains(ByteArray.wrap(peerChainTipData.getSignature())); }; - /** - * If a peer has a recent block timestamp, but its height is more than 25 blocks behind ours, - * we can assume it has a significantly inferior chain, and is most likely too divergent. - * Early filtering of these peers prevents a lot of very expensive chain weight comparisons. - */ - public static final Predicate hasInferiorChain = peer -> { - final Long minLatestBlockTimestamp = getMinimumLatestBlockTimestamp(); - final int ourHeight = Controller.getInstance().getChainHeight(); - final BlockSummaryData peerChainTipData = peer.getChainTipData(); - boolean peerUpToDate = peerChainTipData != null && peerChainTipData.getTimestamp() != null && peerChainTipData.getTimestamp() >= minLatestBlockTimestamp; - return peerUpToDate && ourHeight - peerChainTipData.getHeight() > 25; - }; - public static final Predicate hasOldVersion = peer -> { final String minPeerVersion = Settings.getInstance().getMinPeerVersion(); return peer.isAtLeastVersion(minPeerVersion) == false; diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index 54b13580..e3ace9ed 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -247,9 +247,6 @@ public class Synchronizer extends Thread { // Disregard peers that are on the same block as last sync attempt and we didn't like their chain peers.removeIf(Controller.hasInferiorChainTip); - // Disregard peers that are on a very inferior chain, based on their heights and timestamps - peers.removeIf(Controller.hasInferiorChain); - // Disregard peers that have a block with an invalid signer peers.removeIf(Controller.hasInvalidSigner); From 4aea29a91b9120eaf51adf031b5c59a1a1041c3e Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 18:03:29 +0000 Subject: [PATCH 59/97] Improved PublicizeTransaction validation. --- .../java/org/qortal/transaction/PublicizeTransaction.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index c03c8283..7179576b 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -4,7 +4,9 @@ import java.util.Collections; import java.util.List; import org.qortal.account.Account; +import org.qortal.account.PublicKeyAccount; import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; +import org.qortal.asset.Asset; import org.qortal.crypto.MemoryPoW; import org.qortal.data.transaction.PublicizeTransactionData; import org.qortal.data.transaction.TransactionData; @@ -102,6 +104,12 @@ public class PublicizeTransaction extends Transaction { if (!verifyNonce()) return ValidationResult.INCORRECT_NONCE; + // Validate fee if one has been included + PublicKeyAccount creator = this.getCreator(); + if (this.transactionData.getFee() > 0) + if (creator.getConfirmedBalance(Asset.QORT) < this.transactionData.getFee()) + return ValidationResult.NO_BALANCE; + return ValidationResult.OK; } From c6d65a88dcb9e1fef3db4ce8b76f60de2b071463 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Thu, 22 Dec 2022 18:19:27 +0000 Subject: [PATCH 60/97] Increase mempow difficulty and threshold in ChatTransaction, to match the values in the UI. --- .../java/org/qortal/transaction/ChatTransaction.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index 72fea7a1..a248268c 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -32,8 +32,9 @@ public class ChatTransaction extends Transaction { // Other useful constants public static final int MAX_DATA_SIZE = 1024; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY_WITH_QORT = 8; // leading zero bits - public static final int POW_DIFFICULTY_NO_QORT = 12; // leading zero bits + public static final int POW_DIFFICULTY_ABOVE_QORT_THRESHOLD = 8; // leading zero bits + public static final int POW_DIFFICULTY_BELOW_QORT_THRESHOLD = 18; // leading zero bits + public static final long POW_QORT_THRESHOLD = 400000000L; // Constructors @@ -82,7 +83,7 @@ public class ChatTransaction extends Transaction { // Clear nonce from transactionBytes ChatTransactionTransformer.clearNonce(transactionBytes); - int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) >= POW_QORT_THRESHOLD ? POW_DIFFICULTY_ABOVE_QORT_THRESHOLD : POW_DIFFICULTY_BELOW_QORT_THRESHOLD; // Calculate nonce this.chatTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, difficulty)); @@ -221,7 +222,7 @@ public class ChatTransaction extends Transaction { int difficulty; try { - difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT; + difficulty = this.getSender().getConfirmedBalance(Asset.QORT) >= POW_QORT_THRESHOLD ? POW_DIFFICULTY_ABOVE_QORT_THRESHOLD : POW_DIFFICULTY_BELOW_QORT_THRESHOLD; } catch (DataException e) { return false; } From 9a77aff0a611deb9fc5034db0af3cead34d351a7 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Dec 2022 14:10:49 +0000 Subject: [PATCH 61/97] Reduced difficulty of PUBLICIZE transactions from 15 to 14 (it is now the same as ARBITRARY transactions) --- src/main/java/org/qortal/transaction/PublicizeTransaction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/transaction/PublicizeTransaction.java b/src/main/java/org/qortal/transaction/PublicizeTransaction.java index 7179576b..76fef00b 100644 --- a/src/main/java/org/qortal/transaction/PublicizeTransaction.java +++ b/src/main/java/org/qortal/transaction/PublicizeTransaction.java @@ -28,7 +28,7 @@ public class PublicizeTransaction extends Transaction { /** If time difference between transaction and now is greater than this then we don't verify proof-of-work. */ public static final long HISTORIC_THRESHOLD = 2 * 7 * 24 * 60 * 60 * 1000L; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes - public static final int POW_DIFFICULTY = 15; // leading zero bits + public static final int POW_DIFFICULTY = 14; // leading zero bits // Constructors From 166f9bd079fd95abcfbc8b52325332d3ea218ae1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 24 Dec 2022 21:28:02 +0000 Subject: [PATCH 62/97] Bump version to 3.8.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index da6d8e27..b66f016f 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.1 + 3.8.2 jar true From 6b45901c4769e8046cac76f5ac1c0f4c8cbe9840 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 31 Dec 2022 14:43:37 +0000 Subject: [PATCH 63/97] Fixed validation of existing reward share transactions. --- src/main/java/org/qortal/block/BlockChain.java | 7 ++++++- .../org/qortal/transaction/RewardShareTransaction.java | 9 +++++++-- src/main/resources/blockchain.json | 3 ++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 6182bd1d..a2fa8804 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -76,7 +76,8 @@ public class BlockChain { disableReferenceTimestamp, increaseOnlineAccountsDifficultyTimestamp, onlineAccountMinterLevelValidationHeight, - selfSponsorshipAlgoV1Height; + selfSponsorshipAlgoV1Height, + feeValidationFixTimestamp; } // Custom transaction fees @@ -501,6 +502,10 @@ public class BlockChain { return this.featureTriggers.get(FeatureTrigger.onlineAccountMinterLevelValidationHeight.name()).intValue(); } + public long getFeeValidationFixTimestamp() { + return this.featureTriggers.get(FeatureTrigger.feeValidationFixTimestamp.name()).longValue(); + } + // More complex getters for aspects that change by height or timestamp diff --git a/src/main/java/org/qortal/transaction/RewardShareTransaction.java b/src/main/java/org/qortal/transaction/RewardShareTransaction.java index 3b9a251e..d4d2434c 100644 --- a/src/main/java/org/qortal/transaction/RewardShareTransaction.java +++ b/src/main/java/org/qortal/transaction/RewardShareTransaction.java @@ -164,8 +164,13 @@ public class RewardShareTransaction extends Transaction { } // Check creator has enough funds - if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) - return ValidationResult.NO_BALANCE; + if (this.rewardShareTransactionData.getTimestamp() >= BlockChain.getInstance().getFeeValidationFixTimestamp()) + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; + + else if (!(isRecipientAlsoMinter && existingRewardShareData == null)) + if (creator.getConfirmedBalance(Asset.QORT) < this.rewardShareTransactionData.getFee()) + return ValidationResult.NO_BALANCE; return ValidationResult.OK; } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 7e4497fe..3969e944 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -83,7 +83,8 @@ "disableReferenceTimestamp": 1655222400000, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 1092000, - "selfSponsorshipAlgoV1Height": 1092400 + "selfSponsorshipAlgoV1Height": 1092400, + "feeValidationFixTimestamp": 1671918000000 }, "genesisInfo": { "version": 4, From 98b92a5bf10a0d9fbf4a647888f7bde7702466b0 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Jan 2023 16:58:50 +0000 Subject: [PATCH 64/97] Introduced "historic threshold" to ARBITRARY transactions in order to save on verification times of older transactions. This is based on the approach used for PUBLICIZE transactions. --- .../qortal/transaction/ArbitraryTransaction.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java index ca5ce517..50d8ccad 100644 --- a/src/main/java/org/qortal/transaction/ArbitraryTransaction.java +++ b/src/main/java/org/qortal/transaction/ArbitraryTransaction.java @@ -24,6 +24,7 @@ import org.qortal.transform.Transformer; import org.qortal.transform.transaction.ArbitraryTransactionTransformer; import org.qortal.transform.transaction.TransactionTransformer; import org.qortal.utils.ArbitraryTransactionUtils; +import org.qortal.utils.NTP; public class ArbitraryTransaction extends Transaction { @@ -34,9 +35,13 @@ public class ArbitraryTransaction extends Transaction { public static final int MAX_DATA_SIZE = 4000; public static final int MAX_METADATA_LENGTH = 32; public static final int HASH_LENGTH = TransactionTransformer.SHA256_LENGTH; - public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes public static final int MAX_IDENTIFIER_LENGTH = 64; + /** If time difference between transaction and now is greater than this then we don't verify proof-of-work. */ + public static final long HISTORIC_THRESHOLD = 2 * 7 * 24 * 60 * 60 * 1000L; + public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes + + // Constructors public ArbitraryTransaction(Repository repository, TransactionData transactionData) { @@ -202,9 +207,11 @@ public class ArbitraryTransaction extends Transaction { // Clear nonce from transactionBytes ArbitraryTransactionTransformer.clearNonce(transactionBytes); - // Check nonce - int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); - return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + // We only need to check nonce for recent transactions due to PoW verification overhead + if (NTP.getTime() - this.arbitraryTransactionData.getTimestamp() < HISTORIC_THRESHOLD) { + int difficulty = ArbitraryDataManager.getInstance().getPowDifficulty(); + return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce); + } } return true; From b0486f44bbda54eb8e6e215ccabf231990d27d7f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 2 Jan 2023 17:47:36 +0000 Subject: [PATCH 65/97] Added chat_reference index to speed up searches. --- .../org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c44c3d49..e72e5fab 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -984,6 +984,8 @@ public class HSQLDBDatabaseUpdates { // Add a chat reference, to allow one message to reference another, and for this to be easily // searchable. Null values are allowed as most transactions won't have a reference. stmt.execute("ALTER TABLE ChatTransactions ADD chat_reference Signature"); + // For finding chat messages by reference + stmt.execute("CREATE INDEX ChatTransactionsChatReferenceIndex ON ChatTransactions (chat_reference)"); break; default: From eb569304ba603f10ba752ee877a53df900b78caa Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 6 Jan 2023 10:38:25 +0000 Subject: [PATCH 66/97] Improved refund/refundAll HTLC code, to handle cases where there have been multiple purchase attempts for the same AT. --- .../api/resource/CrossChainHtlcResource.java | 163 +++++++++--------- 1 file changed, 83 insertions(+), 80 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java index 664b013a..45b92c7c 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -8,11 +8,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import java.io.IOException; import java.math.BigDecimal; import java.util.List; import java.util.Objects; -import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.*; @@ -25,7 +24,6 @@ import org.bitcoinj.core.*; import org.bitcoinj.script.Script; import org.qortal.api.*; import org.qortal.api.model.CrossChainBitcoinyHTLCStatus; -import org.qortal.controller.Controller; import org.qortal.crosschain.*; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -586,98 +584,103 @@ public class CrossChainHtlcResource { } List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); - TradeBotData tradeBotData = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).findFirst().orElse(null); - if (tradeBotData == null) + List tradeBotDataList = allTradeBotData.stream().filter(tradeBotDataItem -> tradeBotDataItem.getAtAddress().equals(atAddress)).collect(Collectors.toList()); + if (tradeBotDataList == null || tradeBotDataList.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); - int lockTime = tradeBotData.getLockTimeA(); + // Loop through all matching entries for this AT address, as there might be more than one + for (TradeBotData tradeBotData : tradeBotDataList) { - // We can't refund P2SH-A until lockTime-A has passed - if (NTP.getTime() <= lockTime * 1000L) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + if (tradeBotData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) - int medianBlockTime = bitcoiny.getMedianBlockTime(); - if (medianBlockTime <= lockTime) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + Bitcoiny bitcoiny = (Bitcoiny) acct.getBlockchain(); + int lockTime = tradeBotData.getLockTimeA(); - // Fee for redeem/refund is subtracted from P2SH-A balance. - long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); - long p2shFee = bitcoiny.getP2shFee(feeTimestamp); - long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= lockTime * 1000L) + continue; - // Create redeem script based on destination chain - byte[] redeemScriptA; - String p2shAddressA; - BitcoinyHTLC.Status htlcStatusA; - if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { - redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA); - htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); - } - else { - redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); - p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); - htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); - } - LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = bitcoiny.getMedianBlockTime(); + if (medianBlockTime <= lockTime) + continue; - switch (htlcStatusA) { - case UNFUNDED: - case FUNDING_IN_PROGRESS: - // Still waiting for P2SH-A to be funded... - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_TOO_SOON); + // Fee for redeem/refund is subtracted from P2SH-A balance. + long feeTimestamp = calcFeeTimestamp(lockTime, crossChainTradeData.tradeTimeout); + long p2shFee = bitcoiny.getP2shFee(feeTimestamp); + long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee; - case REDEEM_IN_PROGRESS: - case REDEEMED: - case REFUND_IN_PROGRESS: - case REFUNDED: - // Too late! - return false; + // Create redeem script based on destination chain + byte[] redeemScriptA; + String p2shAddressA; + BitcoinyHTLC.Status htlcStatusA; + if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { + redeemScriptA = PirateChainHTLC.buildScript(tradeBotData.getTradeForeignPublicKey(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + p2shAddressA = PirateChain.getInstance().deriveP2shAddressBPrefix(redeemScriptA); + htlcStatusA = PirateChainHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); + } else { + redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTime, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + p2shAddressA = bitcoiny.deriveP2shAddress(redeemScriptA); + htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), p2shAddressA, minimumAmountA); + } + LOGGER.info(String.format("Refunding P2SH address: %s", p2shAddressA)); - case FUNDED:{ - Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); + switch (htlcStatusA) { + case UNFUNDED: + case FUNDING_IN_PROGRESS: + // Still waiting for P2SH-A to be funded... + continue; - if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { - // Pirate Chain custom integration + case REDEEM_IN_PROGRESS: + case REDEEMED: + case REFUND_IN_PROGRESS: + case REFUNDED: + // Too late! + continue; - PirateChain pirateChain = PirateChain.getInstance(); - String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA); + case FUNDED: { + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount); - // Get funding txid - String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA); - if (fundingTxidHex == null) { - throw new ForeignBlockchainException("Missing funding txid when refunding P2SH"); + if (Objects.equals(bitcoiny.getCurrencyCode(), "ARRR")) { + // Pirate Chain custom integration + + PirateChain pirateChain = PirateChain.getInstance(); + String p2shAddressT3 = pirateChain.deriveP2shAddress(redeemScriptA); + + // Get funding txid + String fundingTxidHex = PirateChainHTLC.getUnspentFundingTxid(pirateChain.getBlockchainProvider(), p2shAddressA, minimumAmountA); + if (fundingTxidHex == null) { + throw new ForeignBlockchainException("Missing funding txid when refunding P2SH"); + } + String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes()); + + byte[] privateKey = tradeBotData.getTradePrivateKey(); + String privateKey58 = Base58.encode(privateKey); + String redeemScript58 = Base58.encode(redeemScriptA); + + String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3, + receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58); + LOGGER.info("Refund txid: {}", txid); + } else { + // ElectrumX coins + + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); + + // Validate the destination foreign blockchain address + Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); + if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); + + bitcoiny.broadcastTransaction(p2shRefundTransaction); } - String fundingTxid58 = Base58.encode(HashCode.fromString(fundingTxidHex).asBytes()); - byte[] privateKey = tradeBotData.getTradePrivateKey(); - String privateKey58 = Base58.encode(privateKey); - String redeemScript58 = Base58.encode(redeemScriptA); - - String txid = PirateChain.getInstance().refundP2sh(p2shAddressT3, - receiveAddress, refundAmount.value, redeemScript58, fundingTxid58, lockTime, privateKey58); - LOGGER.info("Refund txid: {}", txid); + return true; } - else { - // ElectrumX coins - - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddressA); - - // Validate the destination foreign blockchain address - Address receiving = Address.fromString(bitcoiny.getNetworkParameters(), receiveAddress); - if (receiving.getOutputScriptType() != Script.ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey, - fundingOutputs, redeemScriptA, lockTime, receiving.getHash()); - - bitcoiny.broadcastTransaction(p2shRefundTransaction); - } - - return true; } } From 8e97c05b56d215a4f217c74a275881a763f92d31 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:25:06 +0000 Subject: [PATCH 67/97] Added missing feature trigger from unit tests. --- src/test/resources/test-chain-v2-block-timestamps.json | 1 + src/test/resources/test-chain-v2-disable-reference.json | 1 + src/test/resources/test-chain-v2-founder-rewards.json | 1 + src/test/resources/test-chain-v2-leftover-reward.json | 1 + src/test/resources/test-chain-v2-minting.json | 1 + src/test/resources/test-chain-v2-qora-holder-extremes.json | 1 + src/test/resources/test-chain-v2-qora-holder-reduction.json | 1 + src/test/resources/test-chain-v2-qora-holder.json | 1 + src/test/resources/test-chain-v2-reward-levels.json | 1 + src/test/resources/test-chain-v2-reward-scaling.json | 1 + src/test/resources/test-chain-v2-reward-shares.json | 1 + src/test/resources/test-chain-v2-self-sponsorship-algo.json | 1 + src/test/resources/test-chain-v2.json | 1 + 13 files changed, 13 insertions(+) diff --git a/src/test/resources/test-chain-v2-block-timestamps.json b/src/test/resources/test-chain-v2-block-timestamps.json index 0a479a75..8c2e0503 100644 --- a/src/test/resources/test-chain-v2-block-timestamps.json +++ b/src/test/resources/test-chain-v2-block-timestamps.json @@ -74,6 +74,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-disable-reference.json b/src/test/resources/test-chain-v2-disable-reference.json index 15c4bedd..f7f8e7d8 100644 --- a/src/test/resources/test-chain-v2-disable-reference.json +++ b/src/test/resources/test-chain-v2-disable-reference.json @@ -77,6 +77,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-founder-rewards.json b/src/test/resources/test-chain-v2-founder-rewards.json index e17b6687..20d10233 100644 --- a/src/test/resources/test-chain-v2-founder-rewards.json +++ b/src/test/resources/test-chain-v2-founder-rewards.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-leftover-reward.json b/src/test/resources/test-chain-v2-leftover-reward.json index abb78528..e71ebab6 100644 --- a/src/test/resources/test-chain-v2-leftover-reward.json +++ b/src/test/resources/test-chain-v2-leftover-reward.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-minting.json b/src/test/resources/test-chain-v2-minting.json index 31f89916..2a388e1f 100644 --- a/src/test/resources/test-chain-v2-minting.json +++ b/src/test/resources/test-chain-v2-minting.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-qora-holder-extremes.json b/src/test/resources/test-chain-v2-qora-holder-extremes.json index 8d4351eb..cface0e7 100644 --- a/src/test/resources/test-chain-v2-qora-holder-extremes.json +++ b/src/test/resources/test-chain-v2-qora-holder-extremes.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-qora-holder-reduction.json b/src/test/resources/test-chain-v2-qora-holder-reduction.json index 20bd27c5..f233680b 100644 --- a/src/test/resources/test-chain-v2-qora-holder-reduction.json +++ b/src/test/resources/test-chain-v2-qora-holder-reduction.json @@ -79,6 +79,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-qora-holder.json b/src/test/resources/test-chain-v2-qora-holder.json index b638e759..4ea82290 100644 --- a/src/test/resources/test-chain-v2-qora-holder.json +++ b/src/test/resources/test-chain-v2-qora-holder.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-reward-levels.json b/src/test/resources/test-chain-v2-reward-levels.json index 7ba5c8b6..5de8d9ff 100644 --- a/src/test/resources/test-chain-v2-reward-levels.json +++ b/src/test/resources/test-chain-v2-reward-levels.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-reward-scaling.json b/src/test/resources/test-chain-v2-reward-scaling.json index 5aa9084f..c008ed42 100644 --- a/src/test/resources/test-chain-v2-reward-scaling.json +++ b/src/test/resources/test-chain-v2-reward-scaling.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-reward-shares.json b/src/test/resources/test-chain-v2-reward-shares.json index 70b746a8..2fc0151f 100644 --- a/src/test/resources/test-chain-v2-reward-shares.json +++ b/src/test/resources/test-chain-v2-reward-shares.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index 36df9a62..c13d55da 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -77,6 +77,7 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "feeValidationFixTimestamp": 0, "selfSponsorshipAlgoV1Height": 20 }, "genesisInfo": { diff --git a/src/test/resources/test-chain-v2.json b/src/test/resources/test-chain-v2.json index cd28d214..63abc695 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -78,6 +78,7 @@ "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, "selfSponsorshipAlgoV1Height": 999999999, + "feeValidationFixTimestamp": 0, "chatReferenceTimestamp": 0 }, "genesisInfo": { From ba95f8376f19cc76791aba451920bdabff0a8fb3 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:27:02 +0000 Subject: [PATCH 68/97] Increase CHAT transaction data limits to the maximum (4000 bytes) to allow for upcoming UI features. --- src/main/java/org/qortal/transaction/ChatTransaction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index a248268c..5ed96494 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -30,7 +30,7 @@ public class ChatTransaction extends Transaction { private ChatTransactionData chatTransactionData; // Other useful constants - public static final int MAX_DATA_SIZE = 1024; + public static final int MAX_DATA_SIZE = 4000; public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes public static final int POW_DIFFICULTY_ABOVE_QORT_THRESHOLD = 8; // leading zero bits public static final int POW_DIFFICULTY_BELOW_QORT_THRESHOLD = 18; // leading zero bits From 41f88be55eedae8b575f0be2ea220601f9d44819 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:27:38 +0000 Subject: [PATCH 69/97] Test serialization of CHAT transactions --- .../org/qortal/test/SerializationTests.java | 1 - .../transaction/ChatTestTransaction.java | 40 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/SerializationTests.java index d9fe978c..8422bd9c 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/SerializationTests.java @@ -47,7 +47,6 @@ public class SerializationTests extends Common { switch (txType) { case GENESIS: case ACCOUNT_FLAGS: - case CHAT: case PUBLICIZE: case AIRDROP: case ENABLE_FORGING: diff --git a/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java b/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java new file mode 100644 index 00000000..bab1f1a0 --- /dev/null +++ b/src/test/java/org/qortal/test/common/transaction/ChatTestTransaction.java @@ -0,0 +1,40 @@ +package org.qortal.test.common.transaction; + +import org.qortal.account.PrivateKeyAccount; +import org.qortal.crypto.Crypto; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; + +import java.util.Random; + +public class ChatTestTransaction extends TestTransaction { + + public static TransactionData randomTransaction(Repository repository, PrivateKeyAccount account, boolean wantValid) throws DataException { + Random random = new Random(); + byte[] orderId = new byte[64]; + random.nextBytes(orderId); + + String sender = Crypto.toAddress(account.getPublicKey()); + int nonce = 1234567; + + // Generate random recipient + byte[] randomPrivateKey = new byte[32]; + random.nextBytes(randomPrivateKey); + PrivateKeyAccount recipientAccount = new PrivateKeyAccount(repository, randomPrivateKey); + String recipient = Crypto.toAddress(recipientAccount.getPublicKey()); + + byte[] chatReference = new byte[64]; + random.nextBytes(chatReference); + + byte[] data = new byte[4000]; + random.nextBytes(data); + + boolean isText = true; + boolean isEncrypted = true; + + return new ChatTransactionData(generateBase(account), sender, nonce, recipient, chatReference, data, isText, isEncrypted); + } + +} From 6284a4691caa2aa21b47b0431cbdf168b5bb888b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:28:44 +0000 Subject: [PATCH 70/97] Import test transactions as part of the serialization tests, to catch any issues with db schema data lengths. --- src/test/java/org/qortal/test/SerializationTests.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/SerializationTests.java index 8422bd9c..d5c12c00 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/SerializationTests.java @@ -59,6 +59,7 @@ public class SerializationTests extends Common { TransactionData transactionData = TransactionUtils.randomTransaction(repository, signingAccount, txType, true); Transaction transaction = Transaction.fromData(repository, transactionData); transaction.sign(signingAccount); + transaction.importAsUnconfirmed(); final int claimedLength = TransactionTransformer.getDataLength(transactionData); byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); From 745cfe8ea15f31bde71a4f591065907f74d1aa00 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:45:38 +0000 Subject: [PATCH 71/97] chatReferenceTimestamp set to 1674316800000 (Sat, 21 Jan 2023 16:00:00 GMT) --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index 4ac40f62..aa6cd73b 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -85,7 +85,7 @@ "onlineAccountMinterLevelValidationHeight": 1092000, "selfSponsorshipAlgoV1Height": 1092400, "feeValidationFixTimestamp": 1671918000000, - "chatReferenceTimestamp": 9999999999999 + "chatReferenceTimestamp": 1674316800000 }, "genesisInfo": { "version": 4, From 4dc0033a5a76d6ba54cf96df12ce4c302eba8740 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 19:45:52 +0000 Subject: [PATCH 72/97] Added missing chatReferenceTimestamp in unit tests. --- src/test/resources/test-chain-v2-self-sponsorship-algo.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/resources/test-chain-v2-self-sponsorship-algo.json b/src/test/resources/test-chain-v2-self-sponsorship-algo.json index c13d55da..68b33cc3 100644 --- a/src/test/resources/test-chain-v2-self-sponsorship-algo.json +++ b/src/test/resources/test-chain-v2-self-sponsorship-algo.json @@ -77,8 +77,9 @@ "disableReferenceTimestamp": 9999999999999, "increaseOnlineAccountsDifficultyTimestamp": 9999999999999, "onlineAccountMinterLevelValidationHeight": 0, + "selfSponsorshipAlgoV1Height": 20, "feeValidationFixTimestamp": 0, - "selfSponsorshipAlgoV1Height": 20 + "chatReferenceTimestamp": 0 }, "genesisInfo": { "version": 4, From 0ad9e2f65bc4aedb26f63ea8144058f395dce800 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 20:08:47 +0000 Subject: [PATCH 73/97] Added QCHAT_ATTACHMENT service, with custom validation function. --- .../org/qortal/arbitrary/misc/Service.java | 32 ++++++- .../test/arbitrary/ArbitraryServiceTests.java | 91 ++++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 5dd8d94e..dc2deaeb 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -10,9 +10,7 @@ import java.io.File; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.List; -import java.util.Map; -import java.util.Objects; +import java.util.*; import static java.util.Arrays.stream; import static java.util.stream.Collectors.toMap; @@ -20,6 +18,31 @@ import static java.util.stream.Collectors.toMap; public enum Service { AUTO_UPDATE(1, false, null, null), ARBITRARY_DATA(100, false, null, null), + QCHAT_ATTACHMENT(120, true, 1024*1024L, null) { + @Override + public ValidationResult validate(Path path) { + // Custom validation function to require a single file, with a whitelisted extension + int fileCount = 0; + File[] files = path.toFile().listFiles(); + if (files != null) { + for (File file : files) { + if (file.isDirectory()) { + return ValidationResult.DIRECTORIES_NOT_ALLOWED; + } + final String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); + final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx"); + if (extension == null || !allowedExtensions.contains(extension)) { + return ValidationResult.INVALID_FILE_EXTENSION; + } + fileCount++; + } + } + if (fileCount != 1) { + return ValidationResult.INVALID_FILE_COUNT; + } + return ValidationResult.OK; + } + }, WEBSITE(200, true, null, null) { @Override public ValidationResult validate(Path path) { @@ -143,7 +166,8 @@ public enum Service { MISSING_INDEX_FILE(4), DIRECTORIES_NOT_ALLOWED(5), INVALID_FILE_EXTENSION(6), - MISSING_DATA(7); + MISSING_DATA(7), + INVALID_FILE_COUNT(8); public final int value; diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index e6a51776..f7738c45 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -175,4 +175,93 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } -} + @Test + public void testValidateQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "document.pdf"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.OK, service.validate(path)); + } + + @Test + public void testValidateInvalidQChatAttachmentFileExtension() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateInvalidQChatAttachmentFileExtension"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "application.exe"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); + } + + @Test + public void testValidateEmptyQChatAttachment() throws IOException { + Path path = Files.createTempDirectory("testValidateEmptyQChatAttachment"); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + + @Test + public void testValidateMultiLayerQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiLayerQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "file1.txt"), data, StandardOpenOption.CREATE); + + Path subdirectory = Paths.get(path.toString(), "subdirectory"); + Files.createDirectories(subdirectory); + Files.write(Paths.get(subdirectory.toString(), "file2.txt"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(subdirectory.toString(), "file3.txt"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); + } + + @Test + public void testValidateMultiFileQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateMultiFileQChatAttachment"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "file1.txt"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "file2.txt"), data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + // There is an index file in the root + assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); + } + +} \ No newline at end of file From 02d5043ef7900166af851d07bae76d00ed0d43db Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 13 Jan 2023 20:17:27 +0000 Subject: [PATCH 74/97] Added missing calls to electrumX.setBlockchain(instance); for DGB and RVN. Thanks to @QuickMythril for noticing this. --- src/main/java/org/qortal/crosschain/Digibyte.java | 2 ++ src/main/java/org/qortal/crosschain/Ravencoin.java | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/crosschain/Digibyte.java b/src/main/java/org/qortal/crosschain/Digibyte.java index 3ab5e78e..4358b3b3 100644 --- a/src/main/java/org/qortal/crosschain/Digibyte.java +++ b/src/main/java/org/qortal/crosschain/Digibyte.java @@ -134,6 +134,8 @@ public class Digibyte extends Bitcoiny { Context bitcoinjContext = new Context(digibyteNet.getParams()); instance = new Digibyte(digibyteNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; diff --git a/src/main/java/org/qortal/crosschain/Ravencoin.java b/src/main/java/org/qortal/crosschain/Ravencoin.java index d65c0a13..7bf5b20f 100644 --- a/src/main/java/org/qortal/crosschain/Ravencoin.java +++ b/src/main/java/org/qortal/crosschain/Ravencoin.java @@ -138,6 +138,8 @@ public class Ravencoin extends Bitcoiny { Context bitcoinjContext = new Context(ravencoinNet.getParams()); instance = new Ravencoin(ravencoinNet, electrumX, bitcoinjContext, CURRENCY_CODE); + + electrumX.setBlockchain(instance); } return instance; From 476fdcb31d442e49c5093911f71f0c44fff69edf Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 10:38:50 +0000 Subject: [PATCH 75/97] Added serialization tests for chatReference, and grouped with other serialization tests into a single package. --- .../data/transaction/ChatTransactionData.java | 4 + .../AtSerializationTests.java | 2 +- .../serialization/ChatSerializationTests.java | 102 ++++++++++++++++++ .../SerializationTests.java | 2 +- 4 files changed, 108 insertions(+), 2 deletions(-) rename src/test/java/org/qortal/test/{at => serialization}/AtSerializationTests.java (99%) create mode 100644 src/test/java/org/qortal/test/serialization/ChatSerializationTests.java rename src/test/java/org/qortal/test/{ => serialization}/SerializationTests.java (99%) diff --git a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java index 81bdb2b7..5a6adf7f 100644 --- a/src/main/java/org/qortal/data/transaction/ChatTransactionData.java +++ b/src/main/java/org/qortal/data/transaction/ChatTransactionData.java @@ -85,6 +85,10 @@ public class ChatTransactionData extends TransactionData { return this.chatReference; } + public void setChatReference(byte[] chatReference) { + this.chatReference = chatReference; + } + public byte[] getData() { return this.data; } diff --git a/src/test/java/org/qortal/test/at/AtSerializationTests.java b/src/test/java/org/qortal/test/serialization/AtSerializationTests.java similarity index 99% rename from src/test/java/org/qortal/test/at/AtSerializationTests.java rename to src/test/java/org/qortal/test/serialization/AtSerializationTests.java index 3953bcdf..ea8d6bcd 100644 --- a/src/test/java/org/qortal/test/at/AtSerializationTests.java +++ b/src/test/java/org/qortal/test/serialization/AtSerializationTests.java @@ -1,4 +1,4 @@ -package org.qortal.test.at; +package org.qortal.test.serialization; import com.google.common.hash.HashCode; import org.junit.After; diff --git a/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java b/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java new file mode 100644 index 00000000..983896db --- /dev/null +++ b/src/test/java/org/qortal/test/serialization/ChatSerializationTests.java @@ -0,0 +1,102 @@ +package org.qortal.test.serialization; + +import com.google.common.hash.HashCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.data.transaction.ChatTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.Common; +import org.qortal.test.common.transaction.ChatTestTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.transform.TransformationException; +import org.qortal.transform.transaction.TransactionTransformer; +import org.qortal.utils.Base58; + +import static org.junit.Assert.*; + +public class ChatSerializationTests { + + @Before + public void beforeTest() throws DataException { + Common.useDefaultSettings(); + } + + + @Test + public void testChatSerializationWithChatReference() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction with chatReference + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ChatTransactionData transactionData = (ChatTransactionData) ChatTestTransaction.randomTransaction(repository, signingAccount, true); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + assertNotNull(transactionData.getChatReference()); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized CHAT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized CHAT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + + // Deserialized chat reference must match initial chat reference + ChatTransactionData deserializedChatTransactionData = (ChatTransactionData) deserializedTransactionData; + assertNotNull(deserializedChatTransactionData.getChatReference()); + assertArrayEquals(deserializedChatTransactionData.getChatReference(), transactionData.getChatReference()); + } + } + + @Test + public void testChatSerializationWithoutChatReference() throws DataException, TransformationException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Build MESSAGE-type AT transaction without chatReference + PrivateKeyAccount signingAccount = Common.getTestAccount(repository, "alice"); + ChatTransactionData transactionData = (ChatTransactionData) ChatTestTransaction.randomTransaction(repository, signingAccount, true); + transactionData.setChatReference(null); + Transaction transaction = Transaction.fromData(repository, transactionData); + transaction.sign(signingAccount); + + assertNull(transactionData.getChatReference()); + + final int claimedLength = TransactionTransformer.getDataLength(transactionData); + byte[] serializedTransaction = TransactionTransformer.toBytes(transactionData); + assertEquals("Serialized CHAT transaction length differs from declared length", claimedLength, serializedTransaction.length); + + TransactionData deserializedTransactionData = TransactionTransformer.fromBytes(serializedTransaction); + // Re-sign + Transaction deserializedTransaction = Transaction.fromData(repository, deserializedTransactionData); + deserializedTransaction.sign(signingAccount); + assertEquals("Deserialized CHAT transaction signature differs", Base58.encode(transactionData.getSignature()), Base58.encode(deserializedTransactionData.getSignature())); + + // Re-serialize to check new length and bytes + final int reclaimedLength = TransactionTransformer.getDataLength(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction declared length differs", claimedLength, reclaimedLength); + + byte[] reserializedTransaction = TransactionTransformer.toBytes(deserializedTransactionData); + assertEquals("Reserialized CHAT transaction bytes differ", HashCode.fromBytes(serializedTransaction).toString(), HashCode.fromBytes(reserializedTransaction).toString()); + + // Deserialized chat reference must match initial chat reference + ChatTransactionData deserializedChatTransactionData = (ChatTransactionData) deserializedTransactionData; + assertNull(deserializedChatTransactionData.getChatReference()); + assertArrayEquals(deserializedChatTransactionData.getChatReference(), transactionData.getChatReference()); + } + } + +} diff --git a/src/test/java/org/qortal/test/SerializationTests.java b/src/test/java/org/qortal/test/serialization/SerializationTests.java similarity index 99% rename from src/test/java/org/qortal/test/SerializationTests.java rename to src/test/java/org/qortal/test/serialization/SerializationTests.java index d5c12c00..e9767909 100644 --- a/src/test/java/org/qortal/test/SerializationTests.java +++ b/src/test/java/org/qortal/test/serialization/SerializationTests.java @@ -1,4 +1,4 @@ -package org.qortal.test; +package org.qortal.test.serialization; import org.junit.Ignore; import org.junit.Test; From f78101e9cc68ab8ec199f1269f282ee5dc484d37 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 11:07:54 +0000 Subject: [PATCH 76/97] Updated a default bootstrap host to use a domain instead of its IP. --- src/main/java/org/qortal/settings/Settings.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 0423f855..546bd936 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -273,7 +273,7 @@ public class Settings { private String[] bootstrapHosts = new String[] { "http://bootstrap.qortal.org", "http://bootstrap2.qortal.org", - "http://62.171.190.193" + "http://bootstrap.qortal.online" }; // Auto-update sources From c62c59b44571d54410109fa756dfa50a9972e3ce Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 12:57:44 +0000 Subject: [PATCH 77/97] Use correct timeout (12s) when sending arbitrary data to a peer, and improved logging. --- .../arbitrary/ArbitraryDataFileManager.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 30b0fcca..807704dd 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -288,7 +288,7 @@ public class ArbitraryDataFileManager extends Thread { // The ID needs to match that of the original request message.setId(originalMessage.getId()); - if (!requestingPeer.sendMessage(message)) { + if (!requestingPeer.sendMessageWithTimeout(message, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { LOGGER.debug("Failed to forward arbitrary data file to peer {}", requestingPeer); requestingPeer.disconnect("failed to forward arbitrary data file"); } @@ -564,13 +564,16 @@ public class ArbitraryDataFileManager extends Thread { LOGGER.trace("Hash {} exists", hash58); // We can serve the file directly as we already have it + LOGGER.debug("Sending file {}...", arbitraryDataFile); ArbitraryDataFileMessage arbitraryDataFileMessage = new ArbitraryDataFileMessage(signature, arbitraryDataFile); arbitraryDataFileMessage.setId(message.getId()); - if (!peer.sendMessage(arbitraryDataFileMessage)) { - LOGGER.debug("Couldn't sent file"); + if (!peer.sendMessageWithTimeout(arbitraryDataFileMessage, (int) ArbitraryDataManager.ARBITRARY_REQUEST_TIMEOUT)) { + LOGGER.debug("Couldn't send file {}", arbitraryDataFile); peer.disconnect("failed to send file"); } - LOGGER.debug("Sent file {}", arbitraryDataFile); + else { + LOGGER.debug("Sent file {}", arbitraryDataFile); + } } else if (relayInfo != null) { LOGGER.debug("We have relay info for hash {}", Base58.encode(hash)); From 0596a07c7de7cfe36e9b1aade9f07b149c3fec28 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 12:58:35 +0000 Subject: [PATCH 78/97] Reduced ArbitraryDataFileRequestThread count from 10 to 5, to reduce network flooding. --- .../qortal/controller/arbitrary/ArbitraryDataFileManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java index 807704dd..e2de1ae0 100644 --- a/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java +++ b/src/main/java/org/qortal/controller/arbitrary/ArbitraryDataFileManager.java @@ -82,7 +82,7 @@ public class ArbitraryDataFileManager extends Thread { try { // Use a fixed thread pool to execute the arbitrary data file requests - int threadCount = 10; + int threadCount = 5; ExecutorService arbitraryDataFileRequestExecutor = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < threadCount; i++) { arbitraryDataFileRequestExecutor.execute(new ArbitraryDataFileRequestThread()); From 016191bdb0887c20df91f859e6821b0342503772 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 15:15:48 +0000 Subject: [PATCH 79/97] Reduce log spam when a QDN resource can't be found due to it not being published. --- .../arbitrary/ArbitraryDataBuilder.java | 3 ++- .../qortal/arbitrary/ArbitraryDataReader.java | 11 +++++++++- .../arbitrary/ArbitraryDataResource.java | 3 ++- .../exception/DataNotPublishedException.java | 22 +++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java index 4f0e3835..b6b17ea5 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataBuilder.java @@ -2,6 +2,7 @@ package org.qortal.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.metadata.ArbitraryDataMetadataCache; import org.qortal.arbitrary.misc.Service; @@ -88,7 +89,7 @@ public class ArbitraryDataBuilder { if (latestPut == null) { String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", this.name, this.service, this.identifierString()); - throw new DataException(message); + throw new DataNotPublishedException(message); } this.latestPutTransaction = latestPut; diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java index 5d4b015c..d1a8b4f5 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataReader.java @@ -4,6 +4,7 @@ import org.apache.commons.io.FileUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; @@ -169,10 +170,18 @@ public class ArbitraryDataReader { this.uncompress(); this.validate(); + } catch (DataNotPublishedException e) { + if (e.getMessage() != null) { + // Log the message only, to avoid spamming the logs with a full stack trace + LOGGER.debug("DataNotPublishedException when trying to load QDN resource: {}", e.getMessage()); + } + this.deleteWorkingDirectory(); + throw e; + } catch (DataException e) { LOGGER.info("DataException when trying to load QDN resource", e); this.deleteWorkingDirectory(); - throw new DataException(e.getMessage()); + throw e; } finally { this.postExecute(); diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java index 616c9b03..2720e4b2 100644 --- a/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java +++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataResource.java @@ -3,6 +3,7 @@ package org.qortal.arbitrary; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.qortal.arbitrary.ArbitraryDataFile.ResourceIdType; +import org.qortal.arbitrary.exception.DataNotPublishedException; import org.qortal.arbitrary.metadata.ArbitraryDataTransactionMetadata; import org.qortal.arbitrary.misc.Service; import org.qortal.controller.arbitrary.ArbitraryDataBuildManager; @@ -325,7 +326,7 @@ public class ArbitraryDataResource { if (latestPut == null) { String message = String.format("Couldn't find PUT transaction for name %s, service %s and identifier %s", this.resourceId, this.service, this.identifierString()); - throw new DataException(message); + throw new DataNotPublishedException(message); } this.latestPutTransaction = latestPut; diff --git a/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java b/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java new file mode 100644 index 00000000..4782826b --- /dev/null +++ b/src/main/java/org/qortal/arbitrary/exception/DataNotPublishedException.java @@ -0,0 +1,22 @@ +package org.qortal.arbitrary.exception; + +import org.qortal.repository.DataException; + +public class DataNotPublishedException extends DataException { + + public DataNotPublishedException() { + } + + public DataNotPublishedException(String message) { + super(message); + } + + public DataNotPublishedException(String message, Throwable cause) { + super(message, cause); + } + + public DataNotPublishedException(Throwable cause) { + super(cause); + } + +} From 39e59cbcf812ded42cc4d144997cfec153194bb1 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sat, 14 Jan 2023 18:47:46 +0000 Subject: [PATCH 80/97] Bump version to 3.8.3 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index b66f016f..7a82ad37 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.2 + 3.8.3 jar true From 2a55eba1f7b695f34c82bf52fd4407d7c387325f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 11:28:37 +0000 Subject: [PATCH 81/97] Updated AdvancedInstaller project for v3.8.3 --- WindowsInstaller/Qortal.aip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/WindowsInstaller/Qortal.aip b/WindowsInstaller/Qortal.aip index 1f579a9c..7af02485 100755 --- a/WindowsInstaller/Qortal.aip +++ b/WindowsInstaller/Qortal.aip @@ -17,10 +17,10 @@ - + - + @@ -212,7 +212,7 @@ - + From e91e612b55e5cbbbf781bad317e2f2244952d13c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 11:33:16 +0000 Subject: [PATCH 82/97] Added checkpoint lookup on startup. Currently enabled for topOnly nodes only. This will detect if the node is on a divergent chain, and will force a bootstrap or resync (depending on settings) in order to rejoin the main chain. --- .../java/org/qortal/block/BlockChain.java | 60 +++++++++++++++---- src/main/resources/blockchain.json | 3 + 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index bacd7825..437a48ab 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -100,6 +100,13 @@ public class BlockChain { /** Whether only one registered name is allowed per account. */ private boolean oneNamePerAccount = false; + /** Checkpoints */ + public static class Checkpoint { + public int height; + public String signature; + } + private List checkpoints; + /** Block rewards by block height */ public static class RewardByHeight { public int height; @@ -381,6 +388,10 @@ public class BlockChain { return this.oneNamePerAccount; } + public List getCheckpoints() { + return this.checkpoints; + } + public List getBlockRewardsByHeight() { return this.rewardsByHeight; } @@ -679,6 +690,7 @@ public class BlockChain { boolean isTopOnly = Settings.getInstance().isTopOnly(); boolean archiveEnabled = Settings.getInstance().isArchiveEnabled(); + boolean isLite = Settings.getInstance().isLite(); boolean canBootstrap = Settings.getInstance().getBootstrap(); boolean needsArchiveRebuild = false; BlockData chainTip; @@ -699,22 +711,44 @@ public class BlockChain { } } } + + // Validate checkpoints + // Limited to topOnly nodes for now, in order to reduce risk, and to solve a real-world problem with divergent topOnly nodes + // TODO: remove the isTopOnly conditional below once this feature has had more testing time + if (isTopOnly && !isLite) { + List checkpoints = BlockChain.getInstance().getCheckpoints(); + for (Checkpoint checkpoint : checkpoints) { + BlockData blockData = repository.getBlockRepository().fromHeight(checkpoint.height); + if (blockData == null) { + // Try the archive + blockData = repository.getBlockArchiveRepository().fromHeight(checkpoint.height); + } + if (blockData == null) { + LOGGER.trace("Couldn't find block for height {}", checkpoint.height); + // This is likely due to the block being pruned, so is safe to ignore. + // Continue, as there might be other blocks we can check more definitively. + continue; + } + + byte[] signature = Base58.decode(checkpoint.signature); + if (!Arrays.equals(signature, blockData.getSignature())) { + LOGGER.info("Error: block at height {} with signature {} doesn't match checkpoint sig: {}. Bootstrapping...", checkpoint.height, Base58.encode(blockData.getSignature()), checkpoint.signature); + needsArchiveRebuild = true; + break; + } + LOGGER.info("Block at height {} matches checkpoint signature", blockData.getHeight()); + } + } + } - boolean hasBlocks = (chainTip != null && chainTip.getHeight() > 1); + // Check first block is Genesis Block + if (!isGenesisBlockValid() || needsArchiveRebuild) { + try { + rebuildBlockchain(); - if (isTopOnly && hasBlocks) { - // Top-only mode is enabled and we have blocks, so it's possible that the genesis block has been pruned - // It's best not to validate it, and there's no real need to - } else { - // Check first block is Genesis Block - if (!isGenesisBlockValid() || needsArchiveRebuild) { - try { - rebuildBlockchain(); - - } catch (InterruptedException e) { - throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); - } + } catch (InterruptedException e) { + throw new DataException(String.format("Interrupted when trying to rebuild blockchain: %s", e.getMessage())); } } diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index aa6cd73b..f48958eb 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -87,6 +87,9 @@ "feeValidationFixTimestamp": 1671918000000, "chatReferenceTimestamp": 1674316800000 }, + "checkpoints": [ + { "height": 1131800, "signature": "EpRam4PLdKzULMp7xNU7XG964AKfioG3g1k7cxwxWXnXspPwnjfF6UncEz4feuSA9mr1vW5d3YQPGruXYjj4vciSh4SPj5iWRxkHRWFeRpQnmVUyaVumuBTwM8nnLKJTdtkZnd6d8Mc5mVFdHs6EwLBTY4HECoRcbo4e4FwkfqVon4M" } + ], "genesisInfo": { "version": 4, "timestamp": "1593450000000", From 30105199a2de58b949b3bdac97bde8d5a83ee5a2 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 12:00:32 +0000 Subject: [PATCH 83/97] Default pruneBlockLimit increased from 1450 to 6000 (approx 5 days), to be more similar to the AT states retention time of full nodes. --- src/main/java/org/qortal/block/BlockChain.java | 4 +--- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java index 437a48ab..b96350e6 100644 --- a/src/main/java/org/qortal/block/BlockChain.java +++ b/src/main/java/org/qortal/block/BlockChain.java @@ -757,9 +757,7 @@ public class BlockChain { try (final Repository repository = RepositoryManager.getRepository()) { repository.checkConsistency(); - // Set the number of blocks to validate based on the pruned state of the chain - // If pruned, subtract an extra 10 to allow room for error - int blocksToValidate = (isTopOnly || archiveEnabled) ? Settings.getInstance().getPruneBlockLimit() - 10 : 1440; + int blocksToValidate = Math.min(Settings.getInstance().getPruneBlockLimit() - 10, 1440); int startHeight = Math.max(repository.getBlockRepository().getBlockchainHeight() - blocksToValidate, 1); BlockData detachedBlockData = repository.getBlockRepository().getDetachedBlockSignature(startHeight); diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 546bd936..d51737a3 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -159,7 +159,7 @@ public class Settings { * This prevents the node from being able to serve older blocks */ private boolean topOnly = false; /** The amount of recent blocks we should keep when pruning */ - private int pruneBlockLimit = 1450; + private int pruneBlockLimit = 6000; /** How often to attempt AT state pruning (ms). */ private long atStatesPruneInterval = 3219L; // milliseconds From dfe3754afc3d3ede3e7f4722a1baae9f4432c324 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 12:07:27 +0000 Subject: [PATCH 84/97] Block connections with peers older than 3.8.2, as those versions are nonfunctional due to recent feature triggers. --- src/main/java/org/qortal/network/Handshake.java | 2 +- src/main/java/org/qortal/settings/Settings.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/network/Handshake.java b/src/main/java/org/qortal/network/Handshake.java index b2e5f829..47752767 100644 --- a/src/main/java/org/qortal/network/Handshake.java +++ b/src/main/java/org/qortal/network/Handshake.java @@ -265,7 +265,7 @@ public enum Handshake { private static final long PEER_VERSION_131 = 0x0100030001L; /** Minimum peer version that we are allowed to communicate with */ - private static final String MIN_PEER_VERSION = "3.7.0"; + private static final String MIN_PEER_VERSION = "3.8.2"; private static final int POW_BUFFER_SIZE_PRE_131 = 8 * 1024 * 1024; // bytes private static final int POW_DIFFICULTY_PRE_131 = 8; // leading zero bits diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index d51737a3..5799bd26 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -215,7 +215,7 @@ public class Settings { public long recoveryModeTimeout = 10 * 60 * 1000L; /** Minimum peer version number required in order to sync with them */ - private String minPeerVersion = "3.8.0"; + private String minPeerVersion = "3.8.2"; /** Whether to allow connections with peers below minPeerVersion * If true, we won't sync with them but they can still sync with us, and will show in the peers list * If false, sync will be blocked both ways, and they will not appear in the peers list */ From c03f271825595cc1350b2a2274047a548cc52a73 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 12:44:19 +0000 Subject: [PATCH 85/97] Keep track of peers which are too divergent, and return an `isTooDivergent` boolean in /peers APIs. isTooDivergent will be true or false if a definitive decision has been made, or missing from the response if not yet known. Therefore it should be safe to treat `"isTooDivergent": false` as a peer that is on the same chain. --- .../java/org/qortal/api/model/ConnectedPeer.java | 7 +++++++ src/main/java/org/qortal/controller/Controller.java | 10 ++++++++++ .../java/org/qortal/controller/Synchronizer.java | 4 ++++ src/main/java/org/qortal/network/Peer.java | 13 +++++++++++++ 4 files changed, 34 insertions(+) diff --git a/src/main/java/org/qortal/api/model/ConnectedPeer.java b/src/main/java/org/qortal/api/model/ConnectedPeer.java index 3d383321..c4198654 100644 --- a/src/main/java/org/qortal/api/model/ConnectedPeer.java +++ b/src/main/java/org/qortal/api/model/ConnectedPeer.java @@ -1,6 +1,7 @@ package org.qortal.api.model; import io.swagger.v3.oas.annotations.media.Schema; +import org.qortal.controller.Controller; import org.qortal.data.block.BlockSummaryData; import org.qortal.data.network.PeerData; import org.qortal.network.Handshake; @@ -36,6 +37,7 @@ public class ConnectedPeer { public Long lastBlockTimestamp; public UUID connectionId; public String age; + public Boolean isTooDivergent; protected ConnectedPeer() { } @@ -69,6 +71,11 @@ public class ConnectedPeer { this.lastBlockSignature = peerChainTipData.getSignature(); this.lastBlockTimestamp = peerChainTipData.getTimestamp(); } + + // Only include isTooDivergent decision if we've had the opportunity to request block summaries this peer + if (peer.getLastTooDivergentTime() != null) { + this.isTooDivergent = Controller.wasRecentlyTooDivergent.test(peer); + } } } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0a323cb2..e9e1fcc2 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -769,6 +769,16 @@ public class Controller extends Thread { } }; + public static final Predicate wasRecentlyTooDivergent = peer -> { + Long now = NTP.getTime(); + Long peerLastTooDivergentTime = peer.getLastTooDivergentTime(); + if (now == null || peerLastTooDivergentTime == null) + return false; + + // Exclude any peers that were TOO_DIVERGENT in the last 5 mins + return (now - peerLastTooDivergentTime < 5 * 60 * 1000L); + }; + private long getRandomRepositoryMaintenanceInterval() { final long minInterval = Settings.getInstance().getRepositoryMaintenanceMinInterval(); final long maxInterval = Settings.getInstance().getRepositoryMaintenanceMaxInterval(); diff --git a/src/main/java/org/qortal/controller/Synchronizer.java b/src/main/java/org/qortal/controller/Synchronizer.java index e3ace9ed..2dad62e7 100644 --- a/src/main/java/org/qortal/controller/Synchronizer.java +++ b/src/main/java/org/qortal/controller/Synchronizer.java @@ -1121,6 +1121,7 @@ public class Synchronizer extends Thread { // If common block is too far behind us then we're on massively different forks so give up. if (!force && testHeight < ourHeight - MAXIMUM_COMMON_DELTA) { LOGGER.info(String.format("Blockchain too divergent with peer %s", peer)); + peer.setLastTooDivergentTime(NTP.getTime()); return SynchronizationResult.TOO_DIVERGENT; } @@ -1130,6 +1131,9 @@ public class Synchronizer extends Thread { testHeight = Math.max(testHeight - step, 1); } + // Peer not considered too divergent + peer.setLastTooDivergentTime(0L); + // Prepend test block's summary as first block summary, as summaries returned are *after* test block BlockSummaryData testBlockSummary = new BlockSummaryData(testBlockData); blockSummariesFromCommon.add(0, testBlockSummary); diff --git a/src/main/java/org/qortal/network/Peer.java b/src/main/java/org/qortal/network/Peer.java index a187d29b..4c05d5b9 100644 --- a/src/main/java/org/qortal/network/Peer.java +++ b/src/main/java/org/qortal/network/Peer.java @@ -155,6 +155,11 @@ public class Peer { */ private CommonBlockData commonBlockData; + /** + * Last time we detected this peer as TOO_DIVERGENT + */ + private Long lastTooDivergentTime; + // Message stats private static class MessageStats { @@ -383,6 +388,14 @@ public class Peer { this.commonBlockData = commonBlockData; } + public Long getLastTooDivergentTime() { + return this.lastTooDivergentTime; + } + + public void setLastTooDivergentTime(Long lastTooDivergentTime) { + this.lastTooDivergentTime = lastTooDivergentTime; + } + public boolean isSyncInProgress() { return this.syncInProgress; } From 4c52d6f0fcf1205c7bc7a47faca83845bbf4216c Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 15 Jan 2023 15:51:10 +0000 Subject: [PATCH 86/97] Fixed bug causing initial latestATStates data to be discarded. --- .../java/org/qortal/controller/repository/AtStatesPruner.java | 1 + .../java/org/qortal/controller/repository/AtStatesTrimmer.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java index bd12f784..064fe0ea 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesPruner.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesPruner.java @@ -42,6 +42,7 @@ public class AtStatesPruner implements Runnable { repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); diff --git a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java index 69fa347c..6c026385 100644 --- a/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java +++ b/src/main/java/org/qortal/controller/repository/AtStatesTrimmer.java @@ -29,6 +29,7 @@ public class AtStatesTrimmer implements Runnable { repository.discardChanges(); repository.getATRepository().rebuildLatestAtStates(); + repository.saveChanges(); while (!Controller.isStopping()) { repository.discardChanges(); From 81cf46f5dd3102c1159717a381d2ff42ee44a993 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 16 Jan 2023 20:18:23 +0000 Subject: [PATCH 87/97] Disable block signing on topOnly nodes. Minting rewards are still earned on topOnly for now. --- src/main/java/org/qortal/controller/BlockMinter.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index e2d01147..185dd7cd 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -63,8 +63,8 @@ public class BlockMinter extends Thread { public void run() { Thread.currentThread().setName("BlockMinter"); - if (Settings.getInstance().isLite()) { - // Lite nodes do not mint + if (Settings.getInstance().isTopOnly() || Settings.getInstance().isLite()) { + // Top only and lite nodes do not sign blocks return; } if (Settings.getInstance().getWipeUnconfirmedOnStart()) { From 688acd466c902a219afd92eb78b941a94a0acec6 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 16 Jan 2023 20:23:43 +0000 Subject: [PATCH 88/97] Set checkpoint to block 1136300 --- src/main/resources/blockchain.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/blockchain.json b/src/main/resources/blockchain.json index f48958eb..46b4b4f9 100644 --- a/src/main/resources/blockchain.json +++ b/src/main/resources/blockchain.json @@ -88,7 +88,7 @@ "chatReferenceTimestamp": 1674316800000 }, "checkpoints": [ - { "height": 1131800, "signature": "EpRam4PLdKzULMp7xNU7XG964AKfioG3g1k7cxwxWXnXspPwnjfF6UncEz4feuSA9mr1vW5d3YQPGruXYjj4vciSh4SPj5iWRxkHRWFeRpQnmVUyaVumuBTwM8nnLKJTdtkZnd6d8Mc5mVFdHs6EwLBTY4HECoRcbo4e4FwkfqVon4M" } + { "height": 1136300, "signature": "3BbwawEF2uN8Ni5ofpJXkukoU8ctAPxYoFB7whq9pKfBnjfZcpfEJT4R95NvBDoTP8WDyWvsUvbfHbcr9qSZuYpSKZjUQTvdFf6eqznHGEwhZApWfvXu6zjGCxYCp65F4jsVYYJjkzbjmkCg5WAwN5voudngA23kMK6PpTNygapCzXt" } ], "genesisInfo": { "version": 4, From 9d81ea7744c2edbf527b5db9a59039977dc9fc9a Mon Sep 17 00:00:00 2001 From: CalDescent Date: Mon, 16 Jan 2023 20:26:00 +0000 Subject: [PATCH 89/97] Bump version to 3.8.4 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7a82ad37..12f8472c 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.qortal qortal - 3.8.3 + 3.8.4 jar true From 64529e8abfb6a60125a634e8829fb74eab412c65 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 18 Jan 2023 19:04:54 +0000 Subject: [PATCH 90/97] Added "reverse" and "includeOnlineSignatures" params to `GET /blocks/range/{height}` endpoint. --- .../org/qortal/api/resource/BlocksResource.java | 17 +++++++++++++---- .../java/org/qortal/test/api/BlockApiTests.java | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/BlocksResource.java b/src/main/java/org/qortal/api/resource/BlocksResource.java index 195b2ca4..15541802 100644 --- a/src/main/java/org/qortal/api/resource/BlocksResource.java +++ b/src/main/java/org/qortal/api/resource/BlocksResource.java @@ -634,13 +634,16 @@ public class BlocksResource { @ApiErrors({ ApiError.REPOSITORY_ISSUE }) - public List getBlockRange(@PathParam("height") int height, @Parameter( - ref = "count" - ) @QueryParam("count") int count) { + public List getBlockRange(@PathParam("height") int height, + @Parameter(ref = "count") @QueryParam("count") int count, + @Parameter(ref = "reverse") @QueryParam("reverse") Boolean reverse, + @QueryParam("includeOnlineSignatures") Boolean includeOnlineSignatures) { try (final Repository repository = RepositoryManager.getRepository()) { List blocks = new ArrayList<>(); + boolean shouldReverse = (reverse != null && reverse == true); - for (/* count already set */; count > 0; --count, ++height) { + int i = 0; + while (i < count) { BlockData blockData = repository.getBlockRepository().fromHeight(height); if (blockData == null) { // Not found - try the archive @@ -650,8 +653,14 @@ public class BlocksResource { break; } } + if (includeOnlineSignatures == null || includeOnlineSignatures == false) { + blockData.setOnlineAccountsSignatures(null); + } blocks.add(blockData); + + height = shouldReverse ? height - 1 : height + 1; + i++; } return blocks; diff --git a/src/test/java/org/qortal/test/api/BlockApiTests.java b/src/test/java/org/qortal/test/api/BlockApiTests.java index 47d5318a..23e7b007 100644 --- a/src/test/java/org/qortal/test/api/BlockApiTests.java +++ b/src/test/java/org/qortal/test/api/BlockApiTests.java @@ -84,7 +84,7 @@ public class BlockApiTests extends ApiCommon { @Test public void testGetBlockRange() { - assertNotNull(this.blocksResource.getBlockRange(1, 1)); + assertNotNull(this.blocksResource.getBlockRange(1, 1, false, false)); List testValues = Arrays.asList(null, Integer.valueOf(1)); From 2f7912abce09763f3dd1600828f31bcbecb31909 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Wed, 18 Jan 2023 19:30:43 +0000 Subject: [PATCH 91/97] Compute balances for Bitcoin-like coins using unspent outputs. Should fix occasional incorrect balance issue, and speed up loading time. --- .../resource/CrossChainBitcoinResource.java | 2 +- .../resource/CrossChainDigibyteResource.java | 2 +- .../resource/CrossChainDogecoinResource.java | 2 +- .../resource/CrossChainLitecoinResource.java | 2 +- .../resource/CrossChainRavencoinResource.java | 2 +- .../java/org/qortal/crosschain/Bitcoiny.java | 96 ++++++++++++++++--- 6 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java index 80d19804..dd967451 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -68,7 +68,7 @@ public class CrossChainBitcoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = bitcoin.getWalletBalanceFromTransactions(key58); + Long balance = bitcoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java index 57049639..31d51c73 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java @@ -68,7 +68,7 @@ public class CrossChainDigibyteResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = digibyte.getWalletBalanceFromTransactions(key58); + Long balance = digibyte.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java index 189a53d3..28bebfb8 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java @@ -66,7 +66,7 @@ public class CrossChainDogecoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = dogecoin.getWalletBalanceFromTransactions(key58); + Long balance = dogecoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java index 8ac0f9a0..d12dd94c 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -68,7 +68,7 @@ public class CrossChainLitecoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = litecoin.getWalletBalanceFromTransactions(key58); + Long balance = litecoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java index 756b0bb5..97550392 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java @@ -68,7 +68,7 @@ public class CrossChainRavencoinResource { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); try { - Long balance = ravencoin.getWalletBalanceFromTransactions(key58); + Long balance = ravencoin.getWalletBalance(key58); if (balance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE); diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java index 350779bc..c08bd91e 100644 --- a/src/main/java/org/qortal/crosschain/Bitcoiny.java +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -357,19 +357,33 @@ public abstract class Bitcoiny implements ForeignBlockchain { * @return unspent BTC balance, or null if unable to determine balance */ public Long getWalletBalance(String key58) throws ForeignBlockchainException { - // It's more accurate to calculate the balance from the transactions, rather than asking Bitcoinj - return this.getWalletBalanceFromTransactions(key58); + Long balance = 0L; -// Context.propagate(bitcoinjContext); -// -// Wallet wallet = walletFromDeterministicKey58(key58); -// wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); -// -// Coin balance = wallet.getBalance(); -// if (balance == null) -// return null; -// -// return balance.value; + List allUnspentOutputs = new ArrayList<>(); + Set walletAddresses = this.getWalletAddresses(key58); + for (String address : walletAddresses) { + allUnspentOutputs.addAll(this.getUnspentOutputs(address)); + } + for (TransactionOutput output : allUnspentOutputs) { + if (!output.isAvailableForSpending()) { + continue; + } + balance += output.getValue().value; + } + return balance; + } + + public Long getWalletBalanceFromBitcoinj(String key58) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet)); + + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; } public Long getWalletBalanceFromTransactions(String key58) throws ForeignBlockchainException { @@ -464,6 +478,64 @@ public abstract class Bitcoiny implements ForeignBlockchain { } } + public Set getWalletAddresses(String key58) throws ForeignBlockchainException { + synchronized (this) { + Context.propagate(bitcoinjContext); + + Wallet wallet = walletFromDeterministicKey58(key58); + DeterministicKeyChain keyChain = wallet.getActiveKeyChain(); + + keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT); + keyChain.maybeLookAhead(); + + List keys = new ArrayList<>(keyChain.getLeafKeys()); + + Set keySet = new HashSet<>(); + + int unusedCounter = 0; + int ki = 0; + do { + boolean areAllKeysUnused = true; + + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + + // Check for transactions + Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH); + keySet.add(address.toString()); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + // Ask for transaction history - if it's empty then key has never been used + List historicTransactionHashes = this.getAddressTransactions(script, false); + + if (!historicTransactionHashes.isEmpty()) { + areAllKeysUnused = false; + } + } + + if (areAllKeysUnused) { + // No transactions + if (unusedCounter >= Settings.getInstance().getGapLimit()) { + // ... and we've hit our search limit + break; + } + // We haven't hit our search limit yet so increment the counter and keep looking + unusedCounter += WALLET_KEY_LOOKAHEAD_INCREMENT; + } else { + // Some keys in this batch were used, so reset the counter + unusedCounter = 0; + } + + // Generate some more keys + keys.addAll(generateMoreKeys(keyChain)); + + // Process new keys + } while (true); + + return keySet; + } + } + protected SimpleTransaction convertToSimpleTransaction(BitcoinyTransaction t, Set keySet) { long amount = 0; long total = 0L; From 8ad46b6344277a7b6869fc71d205c93280f60412 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 09:58:28 +0000 Subject: [PATCH 92/97] Fixed/removed incorrect comments --- .../test/arbitrary/ArbitraryServiceTests.java | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index f7738c45..acd86eaa 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -117,7 +117,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.OK, service.validate(path)); } @@ -140,7 +139,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); } @@ -151,7 +149,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.MISSING_DATA, service.validate(path)); } @@ -171,7 +168,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.GIF_REPOSITORY; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } @@ -181,7 +177,7 @@ public class ArbitraryServiceTests extends Common { byte[] data = new byte[1024]; new Random().nextBytes(data); - // Write the data to several files in a temp path + // Write the data a single file in a temp path Path path = Files.createTempDirectory("testValidateQChatAttachment"); path.toFile().deleteOnExit(); Files.write(Paths.get(path.toString(), "document.pdf"), data, StandardOpenOption.CREATE); @@ -189,7 +185,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.OK, service.validate(path)); } @@ -199,7 +194,7 @@ public class ArbitraryServiceTests extends Common { byte[] data = new byte[1024]; new Random().nextBytes(data); - // Write the data to several files in a temp path + // Write the data a single file in a temp path Path path = Files.createTempDirectory("testValidateInvalidQChatAttachmentFileExtension"); path.toFile().deleteOnExit(); Files.write(Paths.get(path.toString(), "application.exe"), data, StandardOpenOption.CREATE); @@ -207,7 +202,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } @@ -218,7 +212,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } @@ -241,7 +234,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.DIRECTORIES_NOT_ALLOWED, service.validate(path)); } @@ -260,7 +252,6 @@ public class ArbitraryServiceTests extends Common { Service service = Service.QCHAT_ATTACHMENT; assertTrue(service.isValidationRequired()); - // There is an index file in the root assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } From e31515b4a297283374dd026b61f085732729715b Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 10:14:42 +0000 Subject: [PATCH 93/97] Fixed bugs preventing single file GIF repositories and QCHAT attachments from passing validation. --- .../org/qortal/arbitrary/misc/Service.java | 8 +++++ .../test/arbitrary/ArbitraryServiceTests.java | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index dc2deaeb..96934de2 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -24,6 +24,10 @@ public enum Service { // Custom validation function to require a single file, with a whitelisted extension int fileCount = 0; File[] files = path.toFile().listFiles(); + // If already a single file, replace the list with one that contains that file only + if (files == null && path.toFile().isFile()) { + files = new File[] { path.toFile() }; + } if (files != null) { for (File file : files) { if (file.isDirectory()) { @@ -80,6 +84,10 @@ public enum Service { // Custom validation function to require .gif files only, and at least 1 int gifCount = 0; File[] files = path.toFile().listFiles(); + // If already a single file, replace the list with one that contains that file only + if (files == null && path.toFile().isFile()) { + files = new File[] { path.toFile() }; + } if (files != null) { for (File file : files) { if (file.isDirectory()) { diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index acd86eaa..bbd17ab7 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -120,6 +120,24 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.OK, service.validate(path)); } + @Test + public void testValidateSingleFileGifRepository() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileGifRepository"); + path.toFile().deleteOnExit(); + Path imagePath = Paths.get(path.toString(), "image1.gif"); + Files.write(imagePath, data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(imagePath)); + } + @Test public void testValidateMultiLayerGifRepository() throws IOException { // Generate some random data @@ -188,6 +206,24 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.OK, service.validate(path)); } + @Test + public void testValidateSingleFileQChatAttachment() throws IOException { + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileQChatAttachment"); + path.toFile().deleteOnExit(); + Path filePath = Paths.get(path.toString(), "document.pdf"); + Files.write(filePath, data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + } + @Test public void testValidateInvalidQChatAttachmentFileExtension() throws IOException { // Generate some random data From c3f19ea0c1c52507dbaa0872de506c3d408cabd9 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 10:21:05 +0000 Subject: [PATCH 94/97] Don't allow the custom validation methods to evade superclass validation. --- .../org/qortal/arbitrary/misc/Service.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 96934de2..0aeb99ed 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -20,7 +20,12 @@ public enum Service { ARBITRARY_DATA(100, false, null, null), QCHAT_ATTACHMENT(120, true, 1024*1024L, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require a single file, with a whitelisted extension int fileCount = 0; File[] files = path.toFile().listFiles(); @@ -49,7 +54,12 @@ public enum Service { }, WEBSITE(200, true, null, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require an index HTML file in the root directory List fileNames = ArbitraryDataRenderer.indexFiles(); String[] files = path.toFile().list(); @@ -80,7 +90,12 @@ public enum Service { METADATA(1100, false, null, null), GIF_REPOSITORY(1200, true, 25*1024*1024L, null) { @Override - public ValidationResult validate(Path path) { + public ValidationResult validate(Path path) throws IOException { + ValidationResult superclassResult = super.validate(path); + if (superclassResult != ValidationResult.OK) { + return superclassResult; + } + // Custom validation function to require .gif files only, and at least 1 int gifCount = 0; File[] files = path.toFile().listFiles(); From 1f7fec6251d095519fa26d6ae65b6e72995d4e43 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Fri, 20 Jan 2023 10:40:20 +0000 Subject: [PATCH 95/97] Exclude .qortal directory in validation functions, as it was incorrectly failing with "DIRECTORIES_NOT_ALLOWED". --- .../org/qortal/arbitrary/misc/Service.java | 6 ++ .../test/arbitrary/ArbitraryServiceTests.java | 98 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 0aeb99ed..5ddccbe5 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -35,6 +35,9 @@ public enum Service { } if (files != null) { for (File file : files) { + if (file.getName().equals(".qortal")) { + continue; + } if (file.isDirectory()) { return ValidationResult.DIRECTORIES_NOT_ALLOWED; } @@ -105,6 +108,9 @@ public enum Service { } if (files != null) { for (File file : files) { + if (file.getName().equals(".qortal")) { + continue; + } if (file.isDirectory()) { return ValidationResult.DIRECTORIES_NOT_ALLOWED; } diff --git a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java index bbd17ab7..96843876 100644 --- a/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java +++ b/src/test/java/org/qortal/test/arbitrary/ArbitraryServiceTests.java @@ -1,11 +1,26 @@ package org.qortal.test.arbitrary; +import org.apache.commons.lang3.reflect.FieldUtils; import org.junit.Before; import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.arbitrary.ArbitraryDataFile; +import org.qortal.arbitrary.ArbitraryDataReader; +import org.qortal.arbitrary.exception.MissingDataException; import org.qortal.arbitrary.misc.Service; import org.qortal.arbitrary.misc.Service.ValidationResult; +import org.qortal.controller.arbitrary.ArbitraryDataManager; +import org.qortal.data.transaction.ArbitraryTransactionData; +import org.qortal.data.transaction.RegisterNameTransactionData; import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.ArbitraryUtils; import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.test.common.transaction.TestTransaction; +import org.qortal.transaction.RegisterNameTransaction; +import org.qortal.utils.Base58; import java.io.IOException; import java.nio.file.Files; @@ -189,6 +204,48 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_EXTENSION, service.validate(path)); } + @Test + public void testValidatePublishedGifRepository() throws IOException, DataException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data to several files in a temp path + Path path = Files.createTempDirectory("testValidateGifRepository"); + path.toFile().deleteOnExit(); + Files.write(Paths.get(path.toString(), "image1.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image2.gif"), data, StandardOpenOption.CREATE); + Files.write(Paths.get(path.toString(), "image3.gif"), data, StandardOpenOption.CREATE); + + Service service = Service.GIF_REPOSITORY; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(path)); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = "test_identifier"; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction + ArbitraryUtils.createAndMintTxn(repository, publicKey58, path, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice); + + // Build the latest data state for this name, and no exceptions should be thrown because validation passes + ArbitraryDataReader arbitraryDataReader1a = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader1a.loadSynchronously(true); + } + } + @Test public void testValidateQChatAttachment() throws IOException { // Generate some random data @@ -291,4 +348,45 @@ public class ArbitraryServiceTests extends Common { assertEquals(ValidationResult.INVALID_FILE_COUNT, service.validate(path)); } + @Test + public void testValidatePublishedQChatAttachment() throws IOException, DataException, MissingDataException, IllegalAccessException { + try (final Repository repository = RepositoryManager.getRepository()) { + + // Generate some random data + byte[] data = new byte[1024]; + new Random().nextBytes(data); + + // Write the data a single file in a temp path + Path path = Files.createTempDirectory("testValidateSingleFileQChatAttachment"); + path.toFile().deleteOnExit(); + Path filePath = Paths.get(path.toString(), "document.pdf"); + Files.write(filePath, data, StandardOpenOption.CREATE); + + Service service = Service.QCHAT_ATTACHMENT; + assertTrue(service.isValidationRequired()); + + assertEquals(ValidationResult.OK, service.validate(filePath)); + + PrivateKeyAccount alice = Common.getTestAccount(repository, "alice"); + String publicKey58 = Base58.encode(alice.getPublicKey()); + String name = "TEST"; // Can be anything for this test + String identifier = "test_identifier"; + + // Register the name to Alice + RegisterNameTransactionData transactionData = new RegisterNameTransactionData(TestTransaction.generateBase(alice), name, ""); + transactionData.setFee(new RegisterNameTransaction(null, null).getUnitFee(transactionData.getTimestamp())); + TransactionUtils.signAndMint(repository, transactionData, alice); + + // Set difficulty to 1 + FieldUtils.writeField(ArbitraryDataManager.getInstance(), "powDifficulty", 1, true); + + // Create PUT transaction + ArbitraryUtils.createAndMintTxn(repository, publicKey58, filePath, name, identifier, ArbitraryTransactionData.Method.PUT, service, alice); + + // Build the latest data state for this name, and no exceptions should be thrown because validation passes + ArbitraryDataReader arbitraryDataReader1a = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier); + arbitraryDataReader1a.loadSynchronously(true); + } + } + } \ No newline at end of file From 9f30571b12a3463465547286b47083ee1417b271 Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 15:58:53 +0000 Subject: [PATCH 96/97] Use a filename without an extension when publishing data from a string (instead of .tmp) --- src/main/java/org/qortal/api/resource/ArbitraryResource.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java index 978183c0..25b968f1 100644 --- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java +++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java @@ -1128,7 +1128,7 @@ public class ArbitraryResource { if (path == null) { // See if we have a string instead if (string != null) { - File tempFile = File.createTempFile("qortal-", ".tmp"); + File tempFile = File.createTempFile("qortal-", ""); tempFile.deleteOnExit(); BufferedWriter writer = new BufferedWriter(new FileWriter(tempFile.toPath().toString())); writer.write(string); @@ -1138,7 +1138,7 @@ public class ArbitraryResource { } // ... or base64 encoded raw data else if (base64 != null) { - File tempFile = File.createTempFile("qortal-", ".tmp"); + File tempFile = File.createTempFile("qortal-", ""); tempFile.deleteOnExit(); Files.write(tempFile.toPath(), Base64.decode(base64)); path = tempFile.toPath().toString(); From 6196841609ce1b11bc10ae653928e0d40e07f11f Mon Sep 17 00:00:00 2001 From: CalDescent Date: Sun, 22 Jan 2023 15:59:16 +0000 Subject: [PATCH 97/97] Allow files without extensions in QCHAT_ATTACHMENT validation. --- src/main/java/org/qortal/arbitrary/misc/Service.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java index 5ddccbe5..01419d2f 100644 --- a/src/main/java/org/qortal/arbitrary/misc/Service.java +++ b/src/main/java/org/qortal/arbitrary/misc/Service.java @@ -42,7 +42,8 @@ public enum Service { return ValidationResult.DIRECTORIES_NOT_ALLOWED; } final String extension = FilenameUtils.getExtension(file.getName()).toLowerCase(); - final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx"); + // We must allow blank file extensions because these are used by data published from a plaintext or base64-encoded string + final List allowedExtensions = Arrays.asList("zip", "pdf", "txt", "odt", "ods", "doc", "docx", "xls", "xlsx", "ppt", "pptx", ""); if (extension == null || !allowedExtensions.contains(extension)) { return ValidationResult.INVALID_FILE_EXTENSION; }