forked from Qortal/qortal
Initial commit with PRESENCE transaction type -- untested
This commit is contained in:
parent
fc7a7a1549
commit
9b7c2c50fb
@ -0,0 +1,94 @@
|
|||||||
|
package org.qortal.data.transaction;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.stream.Collectors.toMap;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import javax.xml.bind.Unmarshaller;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
|
||||||
|
import org.qortal.transaction.Transaction.TransactionType;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
|
||||||
|
|
||||||
|
// All properties to be converted to JSON via JAXB
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
@Schema(allOf = { TransactionData.class })
|
||||||
|
public class PresenceTransactionData extends TransactionData {
|
||||||
|
|
||||||
|
// Properties
|
||||||
|
@Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP")
|
||||||
|
private byte[] senderPublicKey;
|
||||||
|
|
||||||
|
@Schema(accessMode = AccessMode.READ_ONLY)
|
||||||
|
private String sender;
|
||||||
|
|
||||||
|
@Schema(accessMode = AccessMode.READ_ONLY)
|
||||||
|
private int nonce;
|
||||||
|
|
||||||
|
public enum PresenceType {
|
||||||
|
REWARD_SHARE(0), TRADE_BOT(1);
|
||||||
|
|
||||||
|
public final int value;
|
||||||
|
private static final Map<Integer, PresenceType> map = stream(PresenceType.values()).collect(toMap(type -> type.value, type -> type));
|
||||||
|
|
||||||
|
PresenceType(int value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PresenceType valueOf(int value) {
|
||||||
|
return map.get(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private PresenceType presenceType;
|
||||||
|
|
||||||
|
@Schema(description = "timestamp signature", example = "2yGEbwRFyhPZZckKA")
|
||||||
|
private byte[] timestampSignature;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
// For JAXB
|
||||||
|
protected PresenceTransactionData() {
|
||||||
|
super(TransactionType.PRESENCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void afterUnmarshal(Unmarshaller u, Object parent) {
|
||||||
|
this.creatorPublicKey = this.senderPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceTransactionData(BaseTransactionData baseTransactionData,
|
||||||
|
int nonce, PresenceType presenceType, byte[] timestampSignature) {
|
||||||
|
super(TransactionType.PRESENCE, baseTransactionData);
|
||||||
|
|
||||||
|
this.senderPublicKey = baseTransactionData.creatorPublicKey;
|
||||||
|
this.nonce = nonce;
|
||||||
|
this.presenceType = presenceType;
|
||||||
|
this.timestampSignature = timestampSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters/Setters
|
||||||
|
|
||||||
|
public byte[] getSenderPublicKey() {
|
||||||
|
return this.senderPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNonce() {
|
||||||
|
return this.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNonce(int nonce) {
|
||||||
|
this.nonce = nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceType getPresenceType() {
|
||||||
|
return this.presenceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getTimestampSignature() {
|
||||||
|
return this.timestampSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -141,7 +141,7 @@ public class ChatTransaction extends Transaction {
|
|||||||
// If we exist in the repository then we've been imported as 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.
|
// 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()))
|
if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature()))
|
||||||
return ValidationResult.CHAT;
|
return ValidationResult.INVALID_BUT_OK;
|
||||||
|
|
||||||
// If we have a recipient, check it is a valid address
|
// If we have a recipient, check it is a valid address
|
||||||
String recipientAddress = chatTransactionData.getRecipient();
|
String recipientAddress = chatTransactionData.getRecipient();
|
||||||
|
159
src/main/java/org/qortal/transaction/PresenceTransaction.java
Normal file
159
src/main/java/org/qortal/transaction/PresenceTransaction.java
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
package org.qortal.transaction;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.qortal.account.Account;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.crypto.MemoryPoW;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.TransactionData;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.transform.transaction.PresenceTransactionTransformer;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
public class PresenceTransaction extends Transaction {
|
||||||
|
|
||||||
|
// Properties
|
||||||
|
private PresenceTransactionData presenceTransactionData;
|
||||||
|
|
||||||
|
// Other useful constants
|
||||||
|
public static final int MAX_DATA_SIZE = 256;
|
||||||
|
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 = 14; // leading zero bits
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
public PresenceTransaction(Repository repository, TransactionData transactionData) {
|
||||||
|
super(repository, transactionData);
|
||||||
|
|
||||||
|
this.presenceTransactionData = (PresenceTransactionData) this.transactionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// More information
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getRecipientAddresses() throws DataException {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
|
||||||
|
public Account getSender() {
|
||||||
|
return this.getCreator();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processing
|
||||||
|
|
||||||
|
public void computeNonce() throws DataException {
|
||||||
|
byte[] transactionBytes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionBytes = TransactionTransformer.toBytesForSigning(this.transactionData);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw new RuntimeException("Unable to transform transaction to byte array for verification", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear nonce from transactionBytes
|
||||||
|
PresenceTransactionTransformer.clearNonce(transactionBytes);
|
||||||
|
|
||||||
|
int difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT;
|
||||||
|
|
||||||
|
// Calculate nonce
|
||||||
|
this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, difficulty));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether PRESENCE transaction has valid txGroupId.
|
||||||
|
* <p>
|
||||||
|
* We insist on NO_GROUP.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean isValidTxGroupId() throws DataException {
|
||||||
|
int txGroupId = this.transactionData.getTxGroupId();
|
||||||
|
|
||||||
|
return txGroupId == Group.NO_GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ValidationResult isFeeValid() throws DataException {
|
||||||
|
if (this.transactionData.getFee() < 0)
|
||||||
|
return ValidationResult.NEGATIVE_FEE;
|
||||||
|
|
||||||
|
return ValidationResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasValidReference() throws DataException {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ValidationResult isValid() throws DataException {
|
||||||
|
// Nonce checking is done via isSignatureValid() as that method is only called once per import
|
||||||
|
|
||||||
|
// 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.presenceTransactionData.getSignature()))
|
||||||
|
return ValidationResult.INVALID_BUT_OK;
|
||||||
|
|
||||||
|
// Check timestamp signature
|
||||||
|
byte[] timestampSignature = this.presenceTransactionData.getTimestampSignature();
|
||||||
|
byte[] timestampBytes = Longs.toByteArray(this.presenceTransactionData.getTimestamp());
|
||||||
|
if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), timestampSignature, timestampBytes))
|
||||||
|
return ValidationResult.INVALID_TIMESTAMP_SIGNATURE;
|
||||||
|
|
||||||
|
return ValidationResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isSignatureValid() {
|
||||||
|
byte[] signature = this.transactionData.getSignature();
|
||||||
|
if (signature == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
byte[] transactionBytes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionBytes = PresenceTransactionTransformer.toBytesForSigning(this.transactionData);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw new RuntimeException("Unable to transform transaction to byte array for verification", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), signature, transactionBytes))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int nonce = this.presenceTransactionData.getNonce();
|
||||||
|
|
||||||
|
// Clear nonce from transactionBytes
|
||||||
|
PresenceTransactionTransformer.clearNonce(transactionBytes);
|
||||||
|
|
||||||
|
int difficulty;
|
||||||
|
try {
|
||||||
|
difficulty = this.getSender().getConfirmedBalance(Asset.QORT) > 0 ? POW_DIFFICULTY_WITH_QORT : POW_DIFFICULTY_NO_QORT;
|
||||||
|
} catch (DataException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check nonce
|
||||||
|
return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void process() throws DataException {
|
||||||
|
throw new DataException("PRESENCE transactions should never be processed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void orphan() throws DataException {
|
||||||
|
throw new DataException("PRESENCE transactions should never be orphaned");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -83,7 +83,8 @@ public abstract class Transaction {
|
|||||||
ENABLE_FORGING(37, false),
|
ENABLE_FORGING(37, false),
|
||||||
REWARD_SHARE(38, false),
|
REWARD_SHARE(38, false),
|
||||||
ACCOUNT_LEVEL(39, false),
|
ACCOUNT_LEVEL(39, false),
|
||||||
TRANSFER_PRIVS(40, false);
|
TRANSFER_PRIVS(40, false),
|
||||||
|
PRESENCE(41, false);
|
||||||
|
|
||||||
public final int value;
|
public final int value;
|
||||||
public final boolean needsApproval;
|
public final boolean needsApproval;
|
||||||
@ -244,7 +245,8 @@ public abstract class Transaction {
|
|||||||
ACCOUNT_ALREADY_EXISTS(92),
|
ACCOUNT_ALREADY_EXISTS(92),
|
||||||
INVALID_GROUP_BLOCK_DELAY(93),
|
INVALID_GROUP_BLOCK_DELAY(93),
|
||||||
INCORRECT_NONCE(94),
|
INCORRECT_NONCE(94),
|
||||||
CHAT(999),
|
INVALID_TIMESTAMP_SIGNATURE(95),
|
||||||
|
INVALID_BUT_OK(999),
|
||||||
NOT_YET_RELEASED(1000);
|
NOT_YET_RELEASED(1000);
|
||||||
|
|
||||||
public final int value;
|
public final int value;
|
||||||
|
@ -0,0 +1,108 @@
|
|||||||
|
package org.qortal.transform.transaction;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
import org.qortal.data.transaction.BaseTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData.PresenceType;
|
||||||
|
import org.qortal.data.transaction.TransactionData;
|
||||||
|
import org.qortal.transaction.Transaction.TransactionType;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.utils.Serialization;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Ints;
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
public class PresenceTransactionTransformer extends TransactionTransformer {
|
||||||
|
|
||||||
|
// Property lengths
|
||||||
|
private static final int NONCE_LENGTH = INT_LENGTH;
|
||||||
|
private static final int PRESENCE_TYPE_LENGTH = BYTE_LENGTH;
|
||||||
|
private static final int TIMESTAMP_SIGNATURE_LENGTH = SIGNATURE_LENGTH;
|
||||||
|
|
||||||
|
private static final int EXTRAS_LENGTH = NONCE_LENGTH + PRESENCE_TYPE_LENGTH + TIMESTAMP_SIGNATURE_LENGTH;
|
||||||
|
|
||||||
|
protected static final TransactionLayout layout;
|
||||||
|
|
||||||
|
static {
|
||||||
|
layout = new TransactionLayout();
|
||||||
|
layout.add("txType: " + TransactionType.PRESENCE.valueString, TransformationType.INT);
|
||||||
|
layout.add("timestamp", TransformationType.TIMESTAMP);
|
||||||
|
layout.add("transaction's groupID", TransformationType.INT);
|
||||||
|
layout.add("reference", TransformationType.SIGNATURE);
|
||||||
|
layout.add("sender's public key", TransformationType.PUBLIC_KEY);
|
||||||
|
layout.add("proof-of-work nonce", TransformationType.INT);
|
||||||
|
layout.add("presence type (reward-share=0, trade-bot=1)", TransformationType.BYTE);
|
||||||
|
layout.add("timestamp-signature", TransformationType.SIGNATURE);
|
||||||
|
layout.add("fee", TransformationType.AMOUNT);
|
||||||
|
layout.add("signature", TransformationType.SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException {
|
||||||
|
long timestamp = byteBuffer.getLong();
|
||||||
|
|
||||||
|
int txGroupId = byteBuffer.getInt();
|
||||||
|
|
||||||
|
byte[] reference = new byte[REFERENCE_LENGTH];
|
||||||
|
byteBuffer.get(reference);
|
||||||
|
|
||||||
|
byte[] senderPublicKey = Serialization.deserializePublicKey(byteBuffer);
|
||||||
|
|
||||||
|
int nonce = byteBuffer.getInt();
|
||||||
|
|
||||||
|
PresenceType presenceType = PresenceType.valueOf(byteBuffer.get());
|
||||||
|
|
||||||
|
byte[] timestampSignature = new byte[SIGNATURE_LENGTH];
|
||||||
|
byteBuffer.get(timestampSignature);
|
||||||
|
|
||||||
|
long fee = byteBuffer.getLong();
|
||||||
|
|
||||||
|
byte[] signature = new byte[SIGNATURE_LENGTH];
|
||||||
|
byteBuffer.get(signature);
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature);
|
||||||
|
|
||||||
|
return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int getDataLength(TransactionData transactionData) {
|
||||||
|
return getBaseLength(transactionData) + EXTRAS_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] toBytes(TransactionData transactionData) throws TransformationException {
|
||||||
|
try {
|
||||||
|
PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData;
|
||||||
|
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
transformCommonBytes(transactionData, bytes);
|
||||||
|
|
||||||
|
bytes.write(Ints.toByteArray(presenceTransactionData.getNonce()));
|
||||||
|
|
||||||
|
bytes.write(presenceTransactionData.getPresenceType().value);
|
||||||
|
|
||||||
|
bytes.write(presenceTransactionData.getTimestampSignature());
|
||||||
|
|
||||||
|
bytes.write(Longs.toByteArray(presenceTransactionData.getFee()));
|
||||||
|
|
||||||
|
if (presenceTransactionData.getSignature() != null)
|
||||||
|
bytes.write(presenceTransactionData.getSignature());
|
||||||
|
|
||||||
|
return bytes.toByteArray();
|
||||||
|
} catch (IOException | ClassCastException e) {
|
||||||
|
throw new TransformationException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clearNonce(byte[] transactionBytes) {
|
||||||
|
int nonceIndex = TYPE_LENGTH + TIMESTAMP_LENGTH + GROUPID_LENGTH + REFERENCE_LENGTH + PUBLIC_KEY_LENGTH;
|
||||||
|
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
69
src/test/java/org/qortal/test/PresenceTests.java
Normal file
69
src/test/java/org/qortal/test/PresenceTests.java
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package org.qortal.test;
|
||||||
|
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.data.transaction.BaseTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData.PresenceType;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.test.common.Common;
|
||||||
|
import org.qortal.transaction.PresenceTransaction;
|
||||||
|
import org.qortal.transaction.Transaction;
|
||||||
|
import org.qortal.transaction.Transaction.ValidationResult;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
public class PresenceTests extends Common {
|
||||||
|
|
||||||
|
private PrivateKeyAccount signer;
|
||||||
|
private Repository repository;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void beforeTest() throws DataException {
|
||||||
|
Common.useDefaultSettings();
|
||||||
|
|
||||||
|
this.repository = RepositoryManager.getRepository();
|
||||||
|
this.signer = Common.getTestAccount(this.repository, "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void afterTest() throws DataException {
|
||||||
|
if (this.repository != null)
|
||||||
|
this.repository.close();
|
||||||
|
|
||||||
|
this.repository = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void validityTests() throws DataException {
|
||||||
|
long timestamp = System.currentTimeMillis();
|
||||||
|
byte[] timestampBytes = Longs.toByteArray(timestamp);
|
||||||
|
|
||||||
|
byte[] timestampSignature = this.signer.sign(timestampBytes);
|
||||||
|
|
||||||
|
assertTrue(isValid(Group.NO_GROUP, this.signer, timestamp, timestampSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValid(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException {
|
||||||
|
int nonce = 0;
|
||||||
|
|
||||||
|
byte[] reference = signer.getLastReference();
|
||||||
|
byte[] creatorPublicKey = signer.getPublicKey();
|
||||||
|
long fee = 0L;
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null);
|
||||||
|
PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.REWARD_SHARE, timestampSignature);
|
||||||
|
|
||||||
|
Transaction transaction = new PresenceTransaction(this.repository, transactionData);
|
||||||
|
|
||||||
|
return transaction.isValidUnconfirmed() == ValidationResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user