diff --git a/src/main/java/org/qora/api/model/NameSummary.java b/src/main/java/org/qora/api/model/NameSummary.java new file mode 100644 index 00000000..dd3c267d --- /dev/null +++ b/src/main/java/org/qora/api/model/NameSummary.java @@ -0,0 +1,31 @@ +package org.qora.api.model; + +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlElement; + +import org.qora.data.naming.NameData; + +@XmlAccessorType(XmlAccessType.NONE) +public class NameSummary { + + private NameData nameData; + + protected NameSummary() { + } + + public NameSummary(NameData nameData) { + this.nameData = nameData; + } + + @XmlElement(name = "name") + public String getName() { + return this.nameData.getName(); + } + + @XmlElement(name = "owner") + public String getOwner() { + return this.nameData.getOwner(); + } + +} diff --git a/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java b/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java index 9d6ff0fc..ee5d6a13 100644 --- a/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java +++ b/src/main/java/org/qora/api/resource/AnnotationPostProcessor.java @@ -79,7 +79,7 @@ public class AnnotationPostProcessor implements ReaderListener { private PathItem getPathItemFromMethod(OpenAPI openAPI, String classPathString, Method method) { Path path = method.getAnnotation(Path.class); if (path == null) - throw new RuntimeException("API method has no @Path annotation?"); + return openAPI.getPaths().get(classPathString); String pathString = path.value(); return openAPI.getPaths().get(classPathString + pathString); diff --git a/src/main/java/org/qora/api/resource/AssetsResource.java b/src/main/java/org/qora/api/resource/AssetsResource.java index b168247d..840f6e35 100644 --- a/src/main/java/org/qora/api/resource/AssetsResource.java +++ b/src/main/java/org/qora/api/resource/AssetsResource.java @@ -55,7 +55,6 @@ public class AssetsResource { HttpServletRequest request; @GET - @Path("/all") @Operation( summary = "List all known assets", responses = { diff --git a/src/main/java/org/qora/api/resource/NamesResource.java b/src/main/java/org/qora/api/resource/NamesResource.java index b6332567..47aff78d 100644 --- a/src/main/java/org/qora/api/resource/NamesResource.java +++ b/src/main/java/org/qora/api/resource/NamesResource.java @@ -1,22 +1,33 @@ package org.qora.api.resource; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.parameters.RequestBody; 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; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.Produces; +import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; 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.NameSummary; +import org.qora.crypto.Crypto; +import org.qora.data.naming.NameData; import org.qora.data.transaction.RegisterNameTransactionData; import org.qora.repository.DataException; import org.qora.repository.Repository; @@ -28,13 +39,94 @@ import org.qora.transform.transaction.RegisterNameTransactionTransformer; import org.qora.utils.Base58; @Path("/names") -@Produces({ MediaType.TEXT_PLAIN}) +@Produces({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) @Tag(name = "Names") public class NamesResource { @Context HttpServletRequest request; + @GET + @Operation( + summary = "List all registered names", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema(schema = @Schema(implementation = NameSummary.class)) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List getAllNames(@Parameter(ref = "limit") @QueryParam("limit") int limit, @Parameter(ref = "offset") @QueryParam("offset") int offset) { + try (final Repository repository = RepositoryManager.getRepository()) { + List names = repository.getNameRepository().getAllNames(); + + // Pagination would take effect here (or as part of the repository access) + int fromIndex = Integer.min(offset, names.size()); + int toIndex = limit == 0 ? names.size() : Integer.min(fromIndex + limit, names.size()); + names = names.subList(fromIndex, toIndex); + + return names.stream().map(nameData -> new NameSummary(nameData)).collect(Collectors.toList()); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/address/{address}") + @Operation( + summary = "List all names owned by address", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema(schema = @Schema(implementation = NameSummary.class)) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public List getNamesByAddress(@PathParam("address") String address) { + if (!Crypto.isValidAddress(address)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + List names = repository.getNameRepository().getNamesByOwner(address); + + return names.stream().map(nameData -> new NameSummary(nameData)).collect(Collectors.toList()); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @GET + @Path("/{name}") + @Operation( + summary = "Info on registered name", + responses = { + @ApiResponse( + description = "registered name info", + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema(implementation = NameData.class) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public NameData getName(@PathParam("name") String name) { + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getNameRepository().fromName(name); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST @Path("/register") @Operation( diff --git a/src/main/java/org/qora/data/naming/NameData.java b/src/main/java/org/qora/data/naming/NameData.java index 5f857bb8..c8dedf11 100644 --- a/src/main/java/org/qora/data/naming/NameData.java +++ b/src/main/java/org/qora/data/naming/NameData.java @@ -2,6 +2,11 @@ package org.qora.data.naming; import java.math.BigDecimal; +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 NameData { // Properties @@ -17,6 +22,10 @@ public class NameData { // Constructors + // necessary for JAX-RS serialization + protected NameData() { + } + public NameData(byte[] registrantPublicKey, String owner, String name, String data, long registered, Long updated, byte[] reference, boolean isForSale, BigDecimal salePrice) { this.registrantPublicKey = registrantPublicKey; diff --git a/src/main/java/org/qora/data/transaction/TransactionData.java b/src/main/java/org/qora/data/transaction/TransactionData.java index 76a760f1..98bc9029 100644 --- a/src/main/java/org/qora/data/transaction/TransactionData.java +++ b/src/main/java/org/qora/data/transaction/TransactionData.java @@ -41,7 +41,7 @@ public abstract class TransactionData { @XmlTransient // represented in transaction-specific properties @Schema(hidden = true) protected byte[] creatorPublicKey; - @Schema(description = "timestamp when transaction created, in milliseconds since unix epoch", example = "1545062012000") + @Schema(description = "timestamp when transaction created, in milliseconds since unix epoch", example = "__unix_epoch_time_milliseconds__") protected long timestamp; @Schema(description = "sender's last transaction ID", example = "real_transaction_reference_in_base58") protected byte[] reference; @@ -51,7 +51,7 @@ public abstract class TransactionData { protected byte[] signature; // For JAX-RS use - @Schema(accessMode = AccessMode.READ_ONLY, description = "height of block containing transaction") + @Schema(accessMode = AccessMode.READ_ONLY, hidden = true, description = "height of block containing transaction") protected Integer blockHeight; // Constructors @@ -120,6 +120,7 @@ public abstract class TransactionData { this.creatorPublicKey = creatorPublicKey; } + @XmlTransient public void setBlockHeight(int blockHeight) { this.blockHeight = blockHeight; } diff --git a/src/main/java/org/qora/repository/NameRepository.java b/src/main/java/org/qora/repository/NameRepository.java index 23ae5408..78494792 100644 --- a/src/main/java/org/qora/repository/NameRepository.java +++ b/src/main/java/org/qora/repository/NameRepository.java @@ -1,5 +1,7 @@ package org.qora.repository; +import java.util.List; + import org.qora.data.naming.NameData; public interface NameRepository { @@ -8,6 +10,10 @@ public interface NameRepository { public boolean nameExists(String name) throws DataException; + public List getAllNames() throws DataException; + + public List getNamesByOwner(String address) throws DataException; + public void save(NameData nameData) throws DataException; public void delete(String name) throws DataException; diff --git a/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java b/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java index 3ddcf4ff..bf183d49 100644 --- a/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/HSQLDBNameRepository.java @@ -4,7 +4,9 @@ import java.math.BigDecimal; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; +import java.util.ArrayList; import java.util.Calendar; +import java.util.List; import org.qora.data.naming.NameData; import org.qora.repository.DataException; @@ -53,6 +55,71 @@ public class HSQLDBNameRepository implements NameRepository { } } + @Override + public List getAllNames() throws DataException { + List names = new ArrayList<>(); + + try (ResultSet resultSet = this.repository + .checkedExecute("SELECT name, data, registrant, owner, registered, updated, reference, is_for_sale, sale_price FROM Names")) { + if (resultSet == null) + return names; + + do { + String name = resultSet.getString(1); + String data = resultSet.getString(2); + byte[] registrantPublicKey = resultSet.getBytes(3); + String owner = resultSet.getString(4); + long registered = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + + // Special handling for possibly-NULL "updated" column + Timestamp updatedTimestamp = resultSet.getTimestamp(6, Calendar.getInstance(HSQLDBRepository.UTC)); + Long updated = updatedTimestamp == null ? null : updatedTimestamp.getTime(); + + byte[] reference = resultSet.getBytes(7); + boolean isForSale = resultSet.getBoolean(8); + BigDecimal salePrice = resultSet.getBigDecimal(9); + + names.add(new NameData(registrantPublicKey, owner, name, data, registered, updated, reference, isForSale, salePrice)); + } while (resultSet.next()); + + return names; + } catch (SQLException e) { + throw new DataException("Unable to fetch names from repository", e); + } + } + + @Override + public List getNamesByOwner(String owner) throws DataException { + List names = new ArrayList<>(); + + try (ResultSet resultSet = this.repository + .checkedExecute("SELECT name, data, registrant, registered, updated, reference, is_for_sale, sale_price FROM Names WHERE owner = ?", owner)) { + if (resultSet == null) + return names; + + do { + String name = resultSet.getString(1); + String data = resultSet.getString(2); + byte[] registrantPublicKey = resultSet.getBytes(3); + long registered = resultSet.getTimestamp(4, Calendar.getInstance(HSQLDBRepository.UTC)).getTime(); + + // Special handling for possibly-NULL "updated" column + Timestamp updatedTimestamp = resultSet.getTimestamp(5, Calendar.getInstance(HSQLDBRepository.UTC)); + Long updated = updatedTimestamp == null ? null : updatedTimestamp.getTime(); + + byte[] reference = resultSet.getBytes(6); + boolean isForSale = resultSet.getBoolean(7); + BigDecimal salePrice = resultSet.getBigDecimal(8); + + names.add(new NameData(registrantPublicKey, owner, name, data, registered, updated, reference, isForSale, salePrice)); + } while (resultSet.next()); + + return names; + } catch (SQLException e) { + throw new DataException("Unable to fetch account's names from repository", e); + } + } + @Override public void save(NameData nameData) throws DataException { HSQLDBSaver saveHelper = new HSQLDBSaver("Names"); 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 25854ba4..565be0b3 100644 --- a/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qora/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -8,6 +8,8 @@ import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.qora.data.PaymentData; import org.qora.data.transaction.TransactionData; import org.qora.repository.DataException; @@ -18,6 +20,8 @@ import org.qora.transaction.Transaction.TransactionType; public class HSQLDBTransactionRepository implements TransactionRepository { + private static final Logger LOGGER = LogManager.getLogger(HSQLDBTransactionRepository.class); + protected HSQLDBRepository repository; private HSQLDBGenesisTransactionRepository genesisTransactionRepository; private HSQLDBPaymentTransactionRepository paymentTransactionRepository; @@ -314,34 +318,24 @@ public class HSQLDBTransactionRepository implements TransactionRepository { String signatureColumn = "NULL"; List bindParams = new ArrayList(); + String groupBy = ""; // Table JOINs first List tableJoins = new ArrayList(); - if (hasHeightRange) { - tableJoins.add("Blocks"); - tableJoins.add("BlockTransactions ON BlockTransactions.block_signature = Blocks.signature"); - signatureColumn = "BlockTransactions.transaction_signature"; - } + // Always JOIN BlockTransactions as we only ever want confirmed transactions + tableJoins.add("Blocks"); + tableJoins.add("BlockTransactions ON BlockTransactions.block_signature = Blocks.signature"); + signatureColumn = "BlockTransactions.transaction_signature"; - if (hasTxType) { - if (hasHeightRange) - tableJoins.add("Transactions ON Transactions.signature = BlockTransactions.transaction_signature"); - else - tableJoins.add("Transactions"); - - signatureColumn = "Transactions.signature"; - } + // Always JOIN Transactions as we want to order by timestamp + tableJoins.add("Transactions ON Transactions.signature = BlockTransactions.transaction_signature"); + signatureColumn = "Transactions.signature"; if (hasAddress) { - if (hasTxType) - tableJoins.add("TransactionParticipants ON TransactionParticipants.signature = Transactions.signature"); - else if (hasHeightRange) - tableJoins.add("TransactionParticipants ON TransactionParticipants.signature = BlockTransactions.transaction_signature"); - else - tableJoins.add("TransactionParticipants"); - + tableJoins.add("TransactionParticipants ON TransactionParticipants.signature = Transactions.signature"); signatureColumn = "TransactionParticipants.signature"; + groupBy = " GROUP BY TransactionParticipants.signature, Transactions.creation"; } // WHERE clauses next @@ -362,7 +356,8 @@ public class HSQLDBTransactionRepository implements TransactionRepository { bindParams.add(address); } - String sql = "SELECT " + signatureColumn + " FROM " + String.join(" JOIN ", tableJoins) + " WHERE " + String.join(" AND ", whereClauses); + String sql = "SELECT " + signatureColumn + " FROM " + String.join(" JOIN ", tableJoins) + " WHERE " + String.join(" AND ", whereClauses) + groupBy + " ORDER BY Transactions.creation ASC"; + LOGGER.trace(sql); try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams.toArray())) { if (resultSet == null) diff --git a/src/main/java/org/qora/settings/Settings.java b/src/main/java/org/qora/settings/Settings.java index 9221a7b5..cefc19f7 100644 --- a/src/main/java/org/qora/settings/Settings.java +++ b/src/main/java/org/qora/settings/Settings.java @@ -28,6 +28,10 @@ public class Settings { private boolean useBitcoinTestNet = false; private boolean wipeUnconfirmedOnStart = true; private String blockchainConfigPath = "blockchain.json"; + /** Maximum number of unconfirmed transactions allowed per account */ + private int maxUnconfirmedPerAccount = 100; + /** Max milliseconds into future for accepting new, unconfirmed transactions */ + private long maxTransactionTimestampFuture = 24 * 60 * 60 * 1000; // milliseconds // RPC private int rpcPort = 9085; @@ -131,11 +135,19 @@ public class Settings { if (json.containsKey("rpcenabled")) this.rpcEnabled = ((Boolean) json.get("rpcenabled")).booleanValue(); - // Blockchain config + // Node-specific behaviour if (json.containsKey("wipeUnconfirmedOnStart")) this.wipeUnconfirmedOnStart = (Boolean) getTypedJson(json, "wipeUnconfirmedOnStart", Boolean.class); + if (json.containsKey("maxUnconfirmedPerAccount")) + this.maxUnconfirmedPerAccount = ((Long) getTypedJson(json, "maxUnconfirmedPerAccount", Long.class)).intValue(); + + if (json.containsKey("maxTransactionTimestampFuture")) + this.maxTransactionTimestampFuture = (Long) getTypedJson(json, "maxTransactionTimestampFuture", Long.class); + + // Blockchain config + if (json.containsKey("blockchainConfig")) blockchainConfigPath = (String) getTypedJson(json, "blockchainConfig", String.class); @@ -182,6 +194,14 @@ public class Settings { return this.wipeUnconfirmedOnStart; } + public int getMaxUnconfirmedPerAccount() { + return this.maxUnconfirmedPerAccount; + } + + public long getMaxTransactionTimestampFuture() { + return this.maxTransactionTimestampFuture; + } + // Config parsing public static Object getTypedJson(JSONObject json, String key, Class clazz) { diff --git a/src/main/java/org/qora/transaction/CancelOrderTransaction.java b/src/main/java/org/qora/transaction/CancelOrderTransaction.java index 281aff0c..847adb3f 100644 --- a/src/main/java/org/qora/transaction/CancelOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CancelOrderTransaction.java @@ -55,7 +55,7 @@ public class CancelOrderTransaction extends Transaction { // Navigation @Override - public Account getCreator() throws DataException { + public PublicKeyAccount getCreator() throws DataException { return new PublicKeyAccount(this.repository, cancelOrderTransactionData.getCreatorPublicKey()); } diff --git a/src/main/java/org/qora/transaction/CreateOrderTransaction.java b/src/main/java/org/qora/transaction/CreateOrderTransaction.java index eed5a0f3..c7843654 100644 --- a/src/main/java/org/qora/transaction/CreateOrderTransaction.java +++ b/src/main/java/org/qora/transaction/CreateOrderTransaction.java @@ -56,7 +56,7 @@ public class CreateOrderTransaction extends Transaction { // Navigation @Override - public Account getCreator() throws DataException { + public PublicKeyAccount getCreator() throws DataException { return new PublicKeyAccount(this.repository, createOrderTransactionData.getCreatorPublicKey()); } diff --git a/src/main/java/org/qora/transaction/CreatePollTransaction.java b/src/main/java/org/qora/transaction/CreatePollTransaction.java index 8f02bb74..24b25978 100644 --- a/src/main/java/org/qora/transaction/CreatePollTransaction.java +++ b/src/main/java/org/qora/transaction/CreatePollTransaction.java @@ -67,7 +67,7 @@ public class CreatePollTransaction extends Transaction { // Navigation @Override - public Account getCreator() throws DataException { + public PublicKeyAccount getCreator() throws DataException { return new PublicKeyAccount(this.repository, this.createPollTransactionData.getCreatorPublicKey()); } diff --git a/src/main/java/org/qora/transaction/Transaction.java b/src/main/java/org/qora/transaction/Transaction.java index d51a777a..ae975cea 100644 --- a/src/main/java/org/qora/transaction/Transaction.java +++ b/src/main/java/org/qora/transaction/Transaction.java @@ -4,6 +4,7 @@ import java.math.BigDecimal; import java.math.BigInteger; import java.math.MathContext; import java.util.ArrayList; +import java.util.Arrays; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -18,6 +19,7 @@ import org.qora.data.block.BlockData; import org.qora.data.transaction.TransactionData; import org.qora.repository.DataException; import org.qora.repository.Repository; +import org.qora.settings.Settings; import org.qora.transform.TransformationException; import org.qora.transform.transaction.TransactionTransformer; import org.qora.utils.Base58; @@ -109,6 +111,9 @@ public abstract class Transaction { ASSET_DOES_NOT_MATCH_AT(41), ASSET_ALREADY_EXISTS(43), MISSING_CREATOR(44), + TIMESTAMP_TOO_OLD(45), + TIMESTAMP_TOO_NEW(46), + TOO_MANY_UNCONFIRMED(47), NOT_YET_RELEASED(1000); public final int value; @@ -364,7 +369,7 @@ public abstract class Transaction { * @return creator * @throws DataException */ - protected Account getCreator() throws DataException { + protected PublicKeyAccount getCreator() throws DataException { if (this.transactionData.getCreatorPublicKey() == null) return null; @@ -434,18 +439,49 @@ public abstract class Transaction { * @throws DataException */ public ValidationResult isValidUnconfirmed() throws DataException { + // Transactions with a timestamp prior to latest block's timestamp are too old + BlockData latestBlock = repository.getBlockRepository().getLastBlock(); + if (this.transactionData.getTimestamp() <= latestBlock.getTimestamp()) + return ValidationResult.TIMESTAMP_TOO_OLD; + + // Transactions with a timestamp too far into future are too new + long maxTimestamp = NTP.getTime() + Settings.getInstance().getMaxTransactionTimestampFuture(); + if (this.transactionData.getTimestamp() > maxTimestamp) + return ValidationResult.TIMESTAMP_TOO_NEW; + try { - Account creator = this.getCreator(); + PublicKeyAccount creator = this.getCreator(); if (creator == null) return ValidationResult.MISSING_CREATOR; creator.setLastReference(creator.getUnconfirmedLastReference()); - return this.isValid(); + ValidationResult result = this.isValid(); + + // Reject if unconfirmed pile already has X transactions from same creator + if (result == ValidationResult.OK && countUnconfirmedByCreator(creator) >= Settings.getInstance().getMaxUnconfirmedPerAccount()) + return ValidationResult.TOO_MANY_UNCONFIRMED; + + return result; } finally { repository.discardChanges(); } } + private int countUnconfirmedByCreator(PublicKeyAccount creator) throws DataException { + List unconfirmedTransactions = repository.getTransactionRepository().getAllUnconfirmedTransactions(); + + int count = 0; + for (TransactionData transactionData : unconfirmedTransactions) { + Transaction transaction = Transaction.fromData(repository, transactionData); + PublicKeyAccount otherCreator = transaction.getCreator(); + + if (Arrays.equals(creator.getPublicKey(), otherCreator.getPublicKey())) + ++count; + } + + return count; + } + /** * Returns sorted, unconfirmed transactions, deleting invalid. *

diff --git a/src/main/java/org/qora/utils/NTP.java b/src/main/java/org/qora/utils/NTP.java index c0a96baa..9fd6f20e 100644 --- a/src/main/java/org/qora/utils/NTP.java +++ b/src/main/java/org/qora/utils/NTP.java @@ -16,6 +16,7 @@ public final class NTP { private static long lastUpdate = 0; private static long offset = 0; + /** Returns NTP-synced current time from unix epoch, in milliseconds. */ public static long getTime() { // Every so often use NTP to find out offset between this system's time and internet time if (System.currentTimeMillis() > lastUpdate + TIME_TILL_UPDATE) {