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..f38638c5 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,15 +65,24 @@ 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 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 3e5f1e6d..043b5423 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,11 +66,21 @@ 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; + // 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 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 c0fb9861..84c692d5 100644 --- a/src/test/resources/test-chain-v2.json +++ b/src/test/resources/test-chain-v2.json @@ -91,6 +91,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 }, 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)],