diff --git a/src/main/java/org/qora/api/ApiError.java b/src/main/java/org/qora/api/ApiError.java index 9a01126d..9634363e 100644 --- a/src/main/java/org/qora/api/ApiError.java +++ b/src/main/java/org/qora/api/ApiError.java @@ -110,7 +110,10 @@ public enum ApiError { MESSAGE_FORMAT_NOT_HEX(1001, 400), MESSAGE_BLANK(1002, 400), NO_PUBLIC_KEY(1003, 422), - MESSAGESIZE_EXCEEDED(1004, 400); + MESSAGESIZE_EXCEEDED(1004, 400), + + // Groups + GROUP_UNKNOWN(1101, 404); private final static Map map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); diff --git a/src/main/java/org/qora/api/model/GroupWithMemberInfo.java b/src/main/java/org/qora/api/model/GroupWithMemberInfo.java new file mode 100644 index 00000000..4846a0da --- /dev/null +++ b/src/main/java/org/qora/api/model/GroupWithMemberInfo.java @@ -0,0 +1,40 @@ +package org.qora.api.model; + +import java.util.List; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; + +import org.qora.data.group.GroupAdminData; +import org.qora.data.group.GroupData; +import org.qora.data.group.GroupMemberData; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Group info, maybe including members") +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) +public class GroupWithMemberInfo { + + @Schema(implementation = GroupData.class, name = "group", title = "group info") + @XmlElement(name = "group") + public GroupData groupData; + + Integer memberCount; + + public List groupAdmins; + public List groupMembers; + + // For JAX-RS + protected GroupWithMemberInfo() { + } + + public GroupWithMemberInfo(GroupData groupData, List groupAdmins, List groupMembers, Integer memberCount) { + this.groupData = groupData; + this.groupAdmins = groupAdmins; + this.groupMembers = groupMembers; + this.memberCount = memberCount; + } + +} diff --git a/src/main/java/org/qora/api/resource/GroupsResource.java b/src/main/java/org/qora/api/resource/GroupsResource.java index bdaa3319..7eaf175c 100644 --- a/src/main/java/org/qora/api/resource/GroupsResource.java +++ b/src/main/java/org/qora/api/resource/GroupsResource.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; +import java.util.stream.Collectors; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.GET; @@ -24,9 +25,13 @@ import javax.ws.rs.core.MediaType; import org.qora.api.ApiError; import org.qora.api.ApiErrors; import org.qora.api.ApiExceptionFactory; +import org.qora.api.model.GroupWithMemberInfo; import org.qora.crypto.Crypto; +import org.qora.data.group.GroupAdminData; import org.qora.data.group.GroupData; +import org.qora.data.group.GroupMemberData; import org.qora.data.transaction.CreateGroupTransactionData; +import org.qora.data.transaction.JoinGroupTransactionData; import org.qora.data.transaction.UpdateGroupTransactionData; import org.qora.repository.DataException; import org.qora.repository.Repository; @@ -35,6 +40,7 @@ import org.qora.transaction.Transaction; import org.qora.transaction.Transaction.ValidationResult; import org.qora.transform.TransformationException; import org.qora.transform.transaction.CreateGroupTransactionTransformer; +import org.qora.transform.transaction.JoinGroupTransactionTransformer; import org.qora.transform.transaction.UpdateGroupTransactionTransformer; import org.qora.utils.Base58; @@ -112,15 +118,40 @@ public class GroupsResource { description = "group info", content = @Content( mediaType = MediaType.APPLICATION_JSON, - schema = @Schema(implementation = GroupData.class) + schema = @Schema(implementation = GroupWithMemberInfo.class) ) ) } ) @ApiErrors({ApiError.REPOSITORY_ISSUE}) - public GroupData getGroup(@PathParam("groupname") String groupName) { + public GroupWithMemberInfo getGroup(@PathParam("groupname") String groupName, @QueryParam("includeMembers") boolean includeMembers) { try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getGroupRepository().fromGroupName(groupName); + GroupData groupData = repository.getGroupRepository().fromGroupName(groupName); + if (groupData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.GROUP_UNKNOWN); + + List groupMembers = null; + Integer memberCount = null; + + if (includeMembers) { + groupMembers = repository.getGroupRepository().getAllGroupMembers(groupData.getGroupName()); + + // Strip groupName from member info + groupMembers = groupMembers.stream().map(groupMemberData -> new GroupMemberData(null, groupMemberData.getMember(), groupMemberData.getJoined())).collect(Collectors.toList()); + + memberCount = groupMembers.size(); + } else { + // Just count members instead + memberCount = repository.getGroupRepository().countGroupMembers(groupData.getGroupName()); + } + + // Always include admins + List groupAdmins = repository.getGroupRepository().getAllGroupAdmins(groupData.getGroupName()); + + // Strip groupName from admin info + groupAdmins = groupAdmins.stream().map(groupAdminData -> new GroupAdminData(null, groupAdminData.getAdmin())).collect(Collectors.toList()); + + return new GroupWithMemberInfo(groupData, groupAdmins, groupMembers, memberCount); } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -213,4 +244,47 @@ public class GroupsResource { } } + @POST + @Path("/join") + @Operation( + summary = "Build raw, unsigned, JOIN_GROUP transaction", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = JoinGroupTransactionData.class + ) + ) + ), + responses = { + @ApiResponse( + description = "raw, unsigned, JOIN_GROUP transaction encoded in Base58", + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.TRANSACTION_INVALID, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) + public String joinGroup(JoinGroupTransactionData transactionData) { + try (final Repository repository = RepositoryManager.getRepository()) { + Transaction transaction = Transaction.fromData(repository, transactionData); + + ValidationResult result = transaction.isValidUnconfirmed(); + if (result != ValidationResult.OK) + throw TransactionsResource.createTransactionInvalidException(request, result); + + byte[] bytes = JoinGroupTransactionTransformer.toBytes(transactionData); + return Base58.encode(bytes); + } catch (TransformationException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + } \ No newline at end of file diff --git a/src/main/java/org/qora/data/group/GroupAdminData.java b/src/main/java/org/qora/data/group/GroupAdminData.java new file mode 100644 index 00000000..2e513350 --- /dev/null +++ b/src/main/java/org/qora/data/group/GroupAdminData.java @@ -0,0 +1,35 @@ +package org.qora.data.group; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) +public class GroupAdminData { + + // Properties + private String groupName; + private String admin; + + // Constructors + + // necessary for JAX-RS serialization + protected GroupAdminData() { + } + + public GroupAdminData(String groupName, String admin) { + this.groupName = groupName; + this.admin = admin; + } + + // Getters / setters + + public String getGroupName() { + return this.groupName; + } + + public String getAdmin() { + return this.admin; + } + +} diff --git a/src/main/java/org/qora/data/group/GroupMemberData.java b/src/main/java/org/qora/data/group/GroupMemberData.java new file mode 100644 index 00000000..1e38c4f1 --- /dev/null +++ b/src/main/java/org/qora/data/group/GroupMemberData.java @@ -0,0 +1,41 @@ +package org.qora.data.group; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) +public class GroupMemberData { + + // Properties + private String groupName; + private String member; + private long joined; + + // Constructors + + // necessary for JAX-RS serialization + protected GroupMemberData() { + } + + public GroupMemberData(String groupName, String member, long joined) { + this.groupName = groupName; + this.member = member; + this.joined = joined; + } + + // Getters / setters + + public String getGroupName() { + return this.groupName; + } + + public String getMember() { + return this.member; + } + + public long getJoined() { + return this.joined; + } + +} diff --git a/src/main/java/org/qora/data/transaction/JoinGroupTransactionData.java b/src/main/java/org/qora/data/transaction/JoinGroupTransactionData.java new file mode 100644 index 00000000..cf6e27bd --- /dev/null +++ b/src/main/java/org/qora/data/transaction/JoinGroupTransactionData.java @@ -0,0 +1,56 @@ +package org.qora.data.transaction; + +import java.math.BigDecimal; + +import javax.xml.bind.Unmarshaller; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; + +import org.qora.transaction.Transaction.TransactionType; + +import io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAX-RS +@XmlAccessorType(XmlAccessType.FIELD) +@Schema(allOf = { TransactionData.class }) +public class JoinGroupTransactionData extends TransactionData { + + // Properties + @Schema(description = "joiner's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP") + private byte[] joinerPublicKey; + @Schema(description = "which group to update", example = "my-group") + private String groupName; + + // Constructors + + // For JAX-RS + protected JoinGroupTransactionData() { + super(TransactionType.JOIN_GROUP); + } + + public void afterUnmarshal(Unmarshaller u, Object parent) { + this.creatorPublicKey = this.joinerPublicKey; + } + + public JoinGroupTransactionData(byte[] joinerPublicKey, String groupName, BigDecimal fee, long timestamp, byte[] reference, byte[] signature) { + super(TransactionType.JOIN_GROUP, fee, joinerPublicKey, timestamp, reference, signature); + + this.joinerPublicKey = joinerPublicKey; + this.groupName = groupName; + } + + public JoinGroupTransactionData(byte[] joinerPublicKey, String groupName, BigDecimal fee, long timestamp, byte[] reference) { + this(joinerPublicKey, groupName, fee, timestamp, reference, null); + } + + // Getters / setters + + public byte[] getJoinerPublicKey() { + return this.joinerPublicKey; + } + + public String getGroupName() { + return this.groupName; + } + +} diff --git a/src/main/java/org/qora/data/transaction/TransactionData.java b/src/main/java/org/qora/data/transaction/TransactionData.java index 7e9051f9..49164a27 100644 --- a/src/main/java/org/qora/data/transaction/TransactionData.java +++ b/src/main/java/org/qora/data/transaction/TransactionData.java @@ -33,7 +33,9 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode; IssueAssetTransactionData.class, TransferAssetTransactionData.class, CreateOrderTransactionData.class, CancelOrderTransactionData.class, MultiPaymentTransactionData.class, DeployATTransactionData.class, MessageTransactionData.class, ATTransactionData.class, - CreateGroupTransactionData.class, UpdateGroupTransactionData.class}) + CreateGroupTransactionData.class, UpdateGroupTransactionData.class, + JoinGroupTransactionData.class +}) //All properties to be converted to JSON via JAX-RS @XmlAccessorType(XmlAccessType.FIELD) public abstract class TransactionData { diff --git a/src/main/java/org/qora/group/Group.java b/src/main/java/org/qora/group/Group.java index 39b4c9e8..14b575ac 100644 --- a/src/main/java/org/qora/group/Group.java +++ b/src/main/java/org/qora/group/Group.java @@ -1,7 +1,12 @@ package org.qora.group; +import org.qora.account.Account; +import org.qora.account.PublicKeyAccount; +import org.qora.data.group.GroupAdminData; import org.qora.data.group.GroupData; +import org.qora.data.group.GroupMemberData; import org.qora.data.transaction.CreateGroupTransactionData; +import org.qora.data.transaction.JoinGroupTransactionData; import org.qora.data.transaction.TransactionData; import org.qora.data.transaction.UpdateGroupTransactionData; import org.qora.repository.DataException; @@ -48,9 +53,16 @@ public class Group { public void create() throws DataException { this.repository.getGroupRepository().save(this.groupData); + + // Add owner as admin too + this.repository.getGroupRepository().save(new GroupAdminData(this.groupData.getGroupName(), this.groupData.getOwner())); + + // Add owner as member too + this.repository.getGroupRepository().save(new GroupMemberData(this.groupData.getGroupName(), this.groupData.getOwner(), this.groupData.getCreated())); } public void uncreate() throws DataException { + // Repository takes care of cleaning up ancilliary data! this.repository.getGroupRepository().delete(this.groupData.getGroupName()); } @@ -59,6 +71,8 @@ public class Group { if (previousTransactionData == null) throw new DataException("Unable to revert group transaction as referenced transaction not found in repository"); + // XXX needs code to reinstate owner as admin and member + switch (previousTransactionData.getType()) { case CREATE_GROUP: CreateGroupTransactionData previousCreateGroupTransactionData = (CreateGroupTransactionData) previousTransactionData; @@ -96,6 +110,11 @@ public class Group { // Save updated group data this.repository.getGroupRepository().save(this.groupData); + + // XXX new owner should be an admin if not already + // XXX new owner should be a member if not already + + // XXX what happens to previous owner? retained as admin? } public void revert(UpdateGroupTransactionData updateGroupTransactionData) throws DataException { @@ -109,4 +128,18 @@ public class Group { this.repository.getGroupRepository().save(this.groupData); } + public void join(JoinGroupTransactionData joinGroupTransactionData) throws DataException { + Account joiner = new PublicKeyAccount(this.repository, joinGroupTransactionData.getJoinerPublicKey()); + + GroupMemberData groupMemberData = new GroupMemberData(joinGroupTransactionData.getGroupName(), joiner.getAddress(), joinGroupTransactionData.getTimestamp()); + this.repository.getGroupRepository().save(groupMemberData); + } + + public void unjoin(JoinGroupTransactionData joinGroupTransactionData) throws DataException { + Account joiner = new PublicKeyAccount(this.repository, joinGroupTransactionData.getJoinerPublicKey()); + + GroupMemberData groupMemberData = new GroupMemberData(joinGroupTransactionData.getGroupName(), joiner.getAddress(), joinGroupTransactionData.getTimestamp()); + this.repository.getGroupRepository().delete(groupMemberData); + } + } diff --git a/src/main/java/org/qora/repository/GroupRepository.java b/src/main/java/org/qora/repository/GroupRepository.java index 57d5a34f..2be3ea2d 100644 --- a/src/main/java/org/qora/repository/GroupRepository.java +++ b/src/main/java/org/qora/repository/GroupRepository.java @@ -2,10 +2,14 @@ package org.qora.repository; import java.util.List; +import org.qora.data.group.GroupAdminData; import org.qora.data.group.GroupData; +import org.qora.data.group.GroupMemberData; public interface GroupRepository { + // Groups + public GroupData fromGroupName(String groupName) throws DataException; public boolean groupExists(String groupName) throws DataException; @@ -18,4 +22,25 @@ public interface GroupRepository { public void delete(String groupName) throws DataException; -} + // Group Admins + + public List getAllGroupAdmins(String groupName) throws DataException; + + public void save(GroupAdminData groupAdminData) throws DataException; + + public void delete(GroupAdminData groupAdminData) throws DataException; + + // Group Members + + public boolean memberExists(String groupName, String member) throws DataException; + + public List getAllGroupMembers(String groupName) throws DataException; + + /** Returns number of group members, or null if group doesn't exist */ + public Integer countGroupMembers(String groupName) throws DataException; + + public void save(GroupMemberData groupMemberData) throws DataException; + + public void delete(GroupMemberData groupMemberData) throws DataException; + +} \ No newline at end of file diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java index 474c79d9..4b66790a 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -414,10 +414,12 @@ public class HSQLDBDatabaseUpdates { + "reference Signature, PRIMARY KEY (group_name))"); // For finding groups by owner stmt.execute("CREATE INDEX AccountGroupOwnerIndex on AccountGroups (owner)"); + // Admins stmt.execute("CREATE TABLE AccountGroupAdmins (group_name GroupName, admin QoraAddress, PRIMARY KEY (group_name, admin))"); // For finding groups that address administrates stmt.execute("CREATE INDEX AccountGroupAdminIndex on AccountGroupAdmins (admin)"); + // Members stmt.execute("CREATE TABLE AccountGroupMembers (group_name GroupName, address QoraAddress, joined TIMESTAMP WITH TIME ZONE NOT NULL, PRIMARY KEY (group_name, address))"); // For finding groups that address is member @@ -450,6 +452,14 @@ public class HSQLDBDatabaseUpdates { + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); break; + case 32: + // Account group join/leave transactions + stmt.execute("CREATE TABLE JoinGroupTransactions (signature Signature, joiner QoraPublicKey NOT NULL, group_name GroupName NOT NULL, " + + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); + stmt.execute("CREATE TABLE LeaveGroupTransactions (signature Signature, leaver QoraPublicKey NOT NULL, group_name GroupName NOT NULL, " + + "PRIMARY KEY (signature), FOREIGN KEY (signature) REFERENCES Transactions (signature) ON DELETE CASCADE)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBGroupRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBGroupRepository.java index ff498e9a..4325484c 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBGroupRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBGroupRepository.java @@ -7,7 +7,9 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import org.qora.data.group.GroupAdminData; import org.qora.data.group.GroupData; +import org.qora.data.group.GroupMemberData; import org.qora.repository.DataException; import org.qora.repository.GroupRepository; @@ -19,6 +21,8 @@ public class HSQLDBGroupRepository implements GroupRepository { this.repository = repository; } + // Groups + @Override public GroupData fromGroupName(String groupName) throws DataException { try (ResultSet resultSet = this.repository @@ -135,10 +139,129 @@ public class HSQLDBGroupRepository implements GroupRepository { @Override public void delete(String groupName) throws DataException { try { + // Remove invites + this.repository.delete("AccountGroupInvites", "group_name = ?", groupName); + // Remove bans + this.repository.delete("AccountGroupBans", "group_name = ?", groupName); + // Remove members + this.repository.delete("AccountGroupMembers", "group_name = ?", groupName); + // Remove admins + this.repository.delete("AccountGroupAdmins", "group_name = ?", groupName); + // Remove group this.repository.delete("AccountGroups", "group_name = ?", groupName); } catch (SQLException e) { throw new DataException("Unable to delete group info from repository", e); } } + // Group Admins + + @Override + public List getAllGroupAdmins(String groupName) throws DataException { + List admins = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute("SELECT admin FROM AccountGroupAdmins WHERE group_name = ?", groupName)) { + if (resultSet == null) + return admins; + + do { + String admin = resultSet.getString(1); + + admins.add(new GroupAdminData(groupName, admin)); + } while (resultSet.next()); + + return admins; + } catch (SQLException e) { + throw new DataException("Unable to fetch group admins from repository", e); + } + } + + @Override + public void save(GroupAdminData groupAdminData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("AccountGroupAdmins"); + + saveHelper.bind("group_name", groupAdminData.getGroupName()).bind("admin", groupAdminData.getAdmin()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save group admin info into repository", e); + } + } + + @Override + public void delete(GroupAdminData groupAdminData) throws DataException { + try { + this.repository.delete("AccountGroupAdmins", "group_name = ? AND admin = ?", groupAdminData.getGroupName(), groupAdminData.getAdmin()); + } catch (SQLException e) { + throw new DataException("Unable to delete group admin info from repository", e); + } + } + + // Group Members + + @Override + public boolean memberExists(String groupName, String member) throws DataException { + try { + return this.repository.exists("AccountGroupMembers", "group_name = ? AND address = ?", groupName, member); + } catch (SQLException e) { + throw new DataException("Unable to check for group member in repository", e); + } + } + + @Override + public List getAllGroupMembers(String groupName) throws DataException { + List members = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute("SELECT address, joined FROM AccountGroupMembers WHERE group_name = ?", groupName)) { + if (resultSet == null) + return members; + + do { + String member = resultSet.getString(1); + long joined = resultSet.getTimestamp(2, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + + members.add(new GroupMemberData(groupName, member, joined)); + } while (resultSet.next()); + + return members; + } catch (SQLException e) { + throw new DataException("Unable to fetch group members from repository", e); + } + } + + @Override + public Integer countGroupMembers(String groupName) throws DataException { + try (ResultSet resultSet = this.repository.checkedExecute("SELECT group_name, COUNT(*) FROM AccountGroupMembers WHERE group_name = ? GROUP BY group_name", groupName)) { + if (resultSet == null) + return null; + + return resultSet.getInt(2); + } catch (SQLException e) { + throw new DataException("Unable to fetch group member count from repository", e); + } + } + + @Override + public void save(GroupMemberData groupMemberData) throws DataException { + HSQLDBSaver saveHelper = new HSQLDBSaver("AccountGroupMembers"); + + saveHelper.bind("group_name", groupMemberData.getGroupName()).bind("address", groupMemberData.getMember()).bind("joined", new Timestamp(groupMemberData.getJoined())); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save group member info into repository", e); + } + } + + @Override + public void delete(GroupMemberData groupMemberData) throws DataException { + try { + this.repository.delete("AccountGroupMembers", "group_name = ? AND address = ?", groupMemberData.getGroupName(), groupMemberData.getMember()); + } catch (SQLException e) { + throw new DataException("Unable to delete group member info from repository", e); + } + } + } diff --git a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBJoinGroupTransactionRepository.java b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBJoinGroupTransactionRepository.java new file mode 100644 index 00000000..3db60d36 --- /dev/null +++ b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBJoinGroupTransactionRepository.java @@ -0,0 +1,48 @@ +package org.qora.repository.hsqldb.transaction; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; + +import org.qora.data.transaction.JoinGroupTransactionData; +import org.qora.data.transaction.TransactionData; +import org.qora.repository.DataException; +import org.qora.repository.hsqldb.HSQLDBRepository; +import org.qora.repository.hsqldb.HSQLDBSaver; + +public class HSQLDBJoinGroupTransactionRepository extends HSQLDBTransactionRepository { + + public HSQLDBJoinGroupTransactionRepository(HSQLDBRepository repository) { + this.repository = repository; + } + + TransactionData fromBase(byte[] signature, byte[] reference, byte[] creatorPublicKey, long timestamp, BigDecimal fee) throws DataException { + try (ResultSet resultSet = this.repository.checkedExecute("SELECT group_name FROM JoinGroupTransactions WHERE signature = ?", signature)) { + if (resultSet == null) + return null; + + String groupName = resultSet.getString(1); + + return new JoinGroupTransactionData(creatorPublicKey, groupName, fee, timestamp, reference, signature); + } catch (SQLException e) { + throw new DataException("Unable to fetch join group transaction from repository", e); + } + } + + @Override + public void save(TransactionData transactionData) throws DataException { + JoinGroupTransactionData joinGroupTransactionData = (JoinGroupTransactionData) transactionData; + + HSQLDBSaver saveHelper = new HSQLDBSaver("JoinGroupTransactions"); + + saveHelper.bind("signature", joinGroupTransactionData.getSignature()).bind("joiner", joinGroupTransactionData.getJoinerPublicKey()) + .bind("group_name", joinGroupTransactionData.getGroupName()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save join group transaction into repository", e); + } + } + +} diff --git a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 68d3e0cd..6f666a5c 100644 --- a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -43,6 +43,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { private HSQLDBATTransactionRepository atTransactionRepository; private HSQLDBCreateGroupTransactionRepository createGroupTransactionRepository; private HSQLDBUpdateGroupTransactionRepository updateGroupTransactionRepository; + private HSQLDBJoinGroupTransactionRepository joinGroupTransactionRepository; public HSQLDBTransactionRepository(HSQLDBRepository repository) { this.repository = repository; @@ -66,6 +67,7 @@ public class HSQLDBTransactionRepository implements TransactionRepository { this.atTransactionRepository = new HSQLDBATTransactionRepository(repository); this.createGroupTransactionRepository = new HSQLDBCreateGroupTransactionRepository(repository); this.updateGroupTransactionRepository = new HSQLDBUpdateGroupTransactionRepository(repository); + this.joinGroupTransactionRepository = new HSQLDBJoinGroupTransactionRepository(repository); } protected HSQLDBTransactionRepository() { @@ -198,6 +200,9 @@ public class HSQLDBTransactionRepository implements TransactionRepository { case UPDATE_GROUP: return this.updateGroupTransactionRepository.fromBase(signature, reference, creatorPublicKey, timestamp, fee); + case JOIN_GROUP: + return this.joinGroupTransactionRepository.fromBase(signature, reference, creatorPublicKey, timestamp, fee); + default: throw new DataException("Unsupported transaction type [" + type.name() + "] during fetch from HSQLDB repository"); } @@ -526,6 +531,10 @@ public class HSQLDBTransactionRepository implements TransactionRepository { this.updateGroupTransactionRepository.save(transactionData); break; + case JOIN_GROUP: + this.joinGroupTransactionRepository.save(transactionData); + break; + default: throw new DataException("Unsupported transaction type [" + transactionData.getType().name() + "] during save into HSQLDB repository"); } diff --git a/src/main/java/org/qora/transaction/JoinGroupTransaction.java b/src/main/java/org/qora/transaction/JoinGroupTransaction.java new file mode 100644 index 00000000..e4fbad14 --- /dev/null +++ b/src/main/java/org/qora/transaction/JoinGroupTransaction.java @@ -0,0 +1,139 @@ +package org.qora.transaction; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.qora.account.Account; +import org.qora.account.PublicKeyAccount; +import org.qora.asset.Asset; +import org.qora.data.transaction.JoinGroupTransactionData; +import org.qora.data.group.GroupData; +import org.qora.data.transaction.TransactionData; +import org.qora.group.Group; +import org.qora.repository.DataException; +import org.qora.repository.Repository; + +import com.google.common.base.Utf8; + +public class JoinGroupTransaction extends Transaction { + + // Properties + private JoinGroupTransactionData joinGroupTransactionData; + + // Constructors + + public JoinGroupTransaction(Repository repository, TransactionData transactionData) { + super(repository, transactionData); + + this.joinGroupTransactionData = (JoinGroupTransactionData) this.transactionData; + } + + // More information + + @Override + public List getRecipientAccounts() throws DataException { + return Collections.emptyList(); + } + + @Override + public boolean isInvolved(Account account) throws DataException { + String address = account.getAddress(); + + if (address.equals(this.getJoiner().getAddress())) + return true; + + return false; + } + + @Override + public BigDecimal getAmount(Account account) throws DataException { + String address = account.getAddress(); + BigDecimal amount = BigDecimal.ZERO.setScale(8); + + if (address.equals(this.getJoiner().getAddress())) + amount = amount.subtract(this.transactionData.getFee()); + + return amount; + } + + // Navigation + + public Account getJoiner() throws DataException { + return new PublicKeyAccount(this.repository, this.joinGroupTransactionData.getJoinerPublicKey()); + } + + // Processing + + @Override + public ValidationResult isValid() throws DataException { + // Check group name size bounds + int groupNameLength = Utf8.encodedLength(joinGroupTransactionData.getGroupName()); + if (groupNameLength < 1 || groupNameLength > Group.MAX_NAME_SIZE) + return ValidationResult.INVALID_NAME_LENGTH; + + // Check group name is lowercase + if (!joinGroupTransactionData.getGroupName().equals(joinGroupTransactionData.getGroupName().toLowerCase())) + return ValidationResult.NAME_NOT_LOWER_CASE; + + GroupData groupData = this.repository.getGroupRepository().fromGroupName(joinGroupTransactionData.getGroupName()); + + // Check group exists + if (groupData == null) + return ValidationResult.GROUP_DOES_NOT_EXIST; + + Account joiner = getJoiner(); + + if (this.repository.getGroupRepository().memberExists(joinGroupTransactionData.getGroupName(), joiner.getAddress())) + return ValidationResult.ALREADY_GROUP_MEMBER; + + // Check fee is positive + if (joinGroupTransactionData.getFee().compareTo(BigDecimal.ZERO) <= 0) + return ValidationResult.NEGATIVE_FEE; + + if (!Arrays.equals(joiner.getLastReference(), joinGroupTransactionData.getReference())) + return ValidationResult.INVALID_REFERENCE; + + // Check creator has enough funds + if (joiner.getConfirmedBalance(Asset.QORA).compareTo(joinGroupTransactionData.getFee()) < 0) + return ValidationResult.NO_BALANCE; + + return ValidationResult.OK; + } + + @Override + public void process() throws DataException { + // Update Group Membership + Group group = new Group(this.repository, joinGroupTransactionData.getGroupName()); + group.join(joinGroupTransactionData); + + // Save this transaction + this.repository.getTransactionRepository().save(joinGroupTransactionData); + + // Update joiner's balance + Account joiner = getJoiner(); + joiner.setConfirmedBalance(Asset.QORA, joiner.getConfirmedBalance(Asset.QORA).subtract(joinGroupTransactionData.getFee())); + + // Update joiner's reference + joiner.setLastReference(joinGroupTransactionData.getSignature()); + } + + @Override + public void orphan() throws DataException { + // Revert group membership + Group group = new Group(this.repository, joinGroupTransactionData.getGroupName()); + group.unjoin(joinGroupTransactionData); + + // Delete this transaction itself + this.repository.getTransactionRepository().delete(joinGroupTransactionData); + + // Update joiner's balance + Account joiner = getJoiner(); + joiner.setConfirmedBalance(Asset.QORA, joiner.getConfirmedBalance(Asset.QORA).add(joinGroupTransactionData.getFee())); + + // Update joiner's reference + joiner.setLastReference(joinGroupTransactionData.getReference()); + } + +} diff --git a/src/main/java/org/qora/transaction/Transaction.java b/src/main/java/org/qora/transaction/Transaction.java index caa459ce..0705a203 100644 --- a/src/main/java/org/qora/transaction/Transaction.java +++ b/src/main/java/org/qora/transaction/Transaction.java @@ -128,6 +128,7 @@ public abstract class Transaction { GROUP_ALREADY_EXISTS(48), GROUP_DOES_NOT_EXIST(49), INVALID_GROUP_OWNER(50), + ALREADY_GROUP_MEMBER(51), NOT_YET_RELEASED(1000); public final int value; @@ -233,6 +234,9 @@ public abstract class Transaction { case UPDATE_GROUP: return new UpdateGroupTransaction(repository, transactionData); + case JOIN_GROUP: + return new JoinGroupTransaction(repository, transactionData); + default: throw new IllegalStateException("Unsupported transaction type [" + transactionData.getType().value + "] during fetch from repository"); } diff --git a/src/main/java/org/qora/transaction/UpdateGroupTransaction.java b/src/main/java/org/qora/transaction/UpdateGroupTransaction.java index 94e1031b..a5f5e7d1 100644 --- a/src/main/java/org/qora/transaction/UpdateGroupTransaction.java +++ b/src/main/java/org/qora/transaction/UpdateGroupTransaction.java @@ -141,7 +141,7 @@ public class UpdateGroupTransaction extends Transaction { @Override public void orphan() throws DataException { - // Revert name + // Revert Group update Group group = new Group(this.repository, updateGroupTransactionData.getGroupName()); group.revert(updateGroupTransactionData); diff --git a/src/main/java/org/qora/transform/transaction/JoinGroupTransactionTransformer.java b/src/main/java/org/qora/transform/transaction/JoinGroupTransactionTransformer.java new file mode 100644 index 00000000..4705cc62 --- /dev/null +++ b/src/main/java/org/qora/transform/transaction/JoinGroupTransactionTransformer.java @@ -0,0 +1,99 @@ +package org.qora.transform.transaction; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigDecimal; +import java.nio.ByteBuffer; + +import org.json.simple.JSONObject; +import org.qora.account.PublicKeyAccount; +import org.qora.data.transaction.JoinGroupTransactionData; +import org.qora.data.transaction.TransactionData; +import org.qora.group.Group; +import org.qora.transform.TransformationException; +import org.qora.utils.Serialization; + +import com.google.common.base.Utf8; +import com.google.common.hash.HashCode; +import com.google.common.primitives.Ints; +import com.google.common.primitives.Longs; + +public class JoinGroupTransactionTransformer extends TransactionTransformer { + + // Property lengths + private static final int JOINER_LENGTH = PUBLIC_KEY_LENGTH; + private static final int NAME_SIZE_LENGTH = INT_LENGTH; + + private static final int TYPELESS_DATALESS_LENGTH = BASE_TYPELESS_LENGTH + JOINER_LENGTH + NAME_SIZE_LENGTH; + + static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException { + long timestamp = byteBuffer.getLong(); + + byte[] reference = new byte[REFERENCE_LENGTH]; + byteBuffer.get(reference); + + byte[] joinerPublicKey = Serialization.deserializePublicKey(byteBuffer); + + String groupName = Serialization.deserializeSizedString(byteBuffer, Group.MAX_NAME_SIZE); + + BigDecimal fee = Serialization.deserializeBigDecimal(byteBuffer); + + byte[] signature = new byte[SIGNATURE_LENGTH]; + byteBuffer.get(signature); + + return new JoinGroupTransactionData(joinerPublicKey, groupName, fee, timestamp, reference, signature); + } + + public static int getDataLength(TransactionData transactionData) throws TransformationException { + JoinGroupTransactionData joinGroupTransactionData = (JoinGroupTransactionData) transactionData; + + int dataLength = TYPE_LENGTH + TYPELESS_DATALESS_LENGTH + Utf8.encodedLength(joinGroupTransactionData.getGroupName()); + + return dataLength; + } + + public static byte[] toBytes(TransactionData transactionData) throws TransformationException { + try { + JoinGroupTransactionData joinGroupTransactionData = (JoinGroupTransactionData) transactionData; + + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + bytes.write(Ints.toByteArray(joinGroupTransactionData.getType().value)); + bytes.write(Longs.toByteArray(joinGroupTransactionData.getTimestamp())); + bytes.write(joinGroupTransactionData.getReference()); + + bytes.write(joinGroupTransactionData.getCreatorPublicKey()); + Serialization.serializeSizedString(bytes, joinGroupTransactionData.getGroupName()); + + Serialization.serializeBigDecimal(bytes, joinGroupTransactionData.getFee()); + + if (joinGroupTransactionData.getSignature() != null) + bytes.write(joinGroupTransactionData.getSignature()); + + return bytes.toByteArray(); + } catch (IOException | ClassCastException e) { + throw new TransformationException(e); + } + } + + @SuppressWarnings("unchecked") + public static JSONObject toJSON(TransactionData transactionData) throws TransformationException { + JSONObject json = TransactionTransformer.getBaseJSON(transactionData); + + try { + JoinGroupTransactionData joinGroupTransactionData = (JoinGroupTransactionData) transactionData; + + byte[] joinerPublicKey = joinGroupTransactionData.getJoinerPublicKey(); + + json.put("joiner", PublicKeyAccount.getAddress(joinerPublicKey)); + json.put("joinerPublicKey", HashCode.fromBytes(joinerPublicKey).toString()); + + json.put("groupName", joinGroupTransactionData.getGroupName()); + } catch (ClassCastException e) { + throw new TransformationException(e); + } + + return json; + } + +} diff --git a/src/main/java/org/qora/transform/transaction/TransactionTransformer.java b/src/main/java/org/qora/transform/transaction/TransactionTransformer.java index 1c2c3c29..8ffda93b 100644 --- a/src/main/java/org/qora/transform/transaction/TransactionTransformer.java +++ b/src/main/java/org/qora/transform/transaction/TransactionTransformer.java @@ -100,6 +100,9 @@ public class TransactionTransformer extends Transformer { case UPDATE_GROUP: return UpdateGroupTransactionTransformer.fromByteBuffer(byteBuffer); + case JOIN_GROUP: + return JoinGroupTransactionTransformer.fromByteBuffer(byteBuffer); + default: throw new TransformationException("Unsupported transaction type [" + type.value + "] during conversion from bytes"); } @@ -167,6 +170,9 @@ public class TransactionTransformer extends Transformer { case UPDATE_GROUP: return UpdateGroupTransactionTransformer.getDataLength(transactionData); + case JOIN_GROUP: + return JoinGroupTransactionTransformer.getDataLength(transactionData); + default: throw new TransformationException("Unsupported transaction type [" + transactionData.getType().value + "] when requesting byte length"); } @@ -231,6 +237,9 @@ public class TransactionTransformer extends Transformer { case UPDATE_GROUP: return UpdateGroupTransactionTransformer.toBytes(transactionData); + case JOIN_GROUP: + return JoinGroupTransactionTransformer.toBytes(transactionData); + default: throw new TransformationException("Unsupported transaction type [" + transactionData.getType().value + "] during conversion to bytes"); } @@ -304,6 +313,9 @@ public class TransactionTransformer extends Transformer { case UPDATE_GROUP: return UpdateGroupTransactionTransformer.toBytesForSigningImpl(transactionData); + case JOIN_GROUP: + return JoinGroupTransactionTransformer.toBytesForSigningImpl(transactionData); + default: throw new TransformationException( "Unsupported transaction type [" + transactionData.getType().value + "] during conversion to bytes for signing"); @@ -389,6 +401,9 @@ public class TransactionTransformer extends Transformer { case UPDATE_GROUP: return UpdateGroupTransactionTransformer.toJSON(transactionData); + case JOIN_GROUP: + return JoinGroupTransactionTransformer.toJSON(transactionData); + default: throw new TransformationException("Unsupported transaction type [" + transactionData.getType().value + "] during conversion to JSON"); }