diff --git a/pom.xml b/pom.xml
index abc6b85c..b7bff221 100644
--- a/pom.xml
+++ b/pom.xml
@@ -796,5 +796,10 @@
jaxb-runtime
${jaxb-runtime.version}
+
+org.apache.tika
+tika-core
+3.1.0
+
diff --git a/src/main/java/org/qortal/account/Account.java b/src/main/java/org/qortal/account/Account.java
index 722e70da..f741b166 100644
--- a/src/main/java/org/qortal/account/Account.java
+++ b/src/main/java/org/qortal/account/Account.java
@@ -2,12 +2,14 @@ package org.qortal.account;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
+import org.qortal.api.resource.TransactionsResource;
import org.qortal.block.BlockChain;
import org.qortal.controller.LiteNode;
import org.qortal.data.account.AccountBalanceData;
import org.qortal.data.account.AccountData;
import org.qortal.data.account.RewardShareData;
import org.qortal.data.naming.NameData;
+import org.qortal.data.transaction.TransactionData;
import org.qortal.repository.DataException;
import org.qortal.repository.GroupRepository;
import org.qortal.repository.NameRepository;
@@ -19,7 +21,11 @@ import org.qortal.utils.Groups;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
+import java.util.Comparator;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
import static org.qortal.utils.Amounts.prettyAmount;
@@ -361,6 +367,142 @@ public class Account {
return accountData.getLevel();
}
+ /**
+ * Get Primary Name
+ *
+ * @return the primary name for this address if present, otherwise empty
+ *
+ * @throws DataException
+ */
+ public Optional getPrimaryName() throws DataException {
+
+ return this.repository.getNameRepository().getPrimaryName(this.address);
+ }
+
+ /**
+ * Remove Primary Name
+ *
+ * @throws DataException
+ */
+ public void removePrimaryName() throws DataException {
+ this.repository.getNameRepository().removePrimaryName(this.address);
+ }
+
+ /**
+ * Reset Primary Name
+ *
+ * Set primary name based on the names (and their history) this account owns.
+ *
+ * @param confirmationStatus the status of the transactions for the determining the primary name
+ *
+ * @return the primary name, empty if their isn't one
+ *
+ * @throws DataException
+ */
+ public Optional resetPrimaryName(TransactionsResource.ConfirmationStatus confirmationStatus) throws DataException {
+ Optional primaryName = determinePrimaryName(confirmationStatus);
+
+ if(primaryName.isPresent()) {
+ return setPrimaryName(primaryName.get());
+ }
+ else {
+ return primaryName;
+ }
+ }
+
+ /**
+ * Determine Primary Name
+ *
+ * Determine primary name based on a list of registered names.
+ *
+ * @param confirmationStatus the status of the transactions for this determination
+ *
+ * @return the primary name, empty if there is no primary name
+ *
+ * @throws DataException
+ */
+ public Optional determinePrimaryName(TransactionsResource.ConfirmationStatus confirmationStatus) throws DataException {
+
+ // all registered names for the owner
+ List names = this.repository.getNameRepository().getNamesByOwner(this.address);
+
+ Optional primaryName;
+
+ // if no registered names, the no primary name possible
+ if (names.isEmpty()) {
+ primaryName = Optional.empty();
+ }
+ // if names
+ else {
+ // if one name, then that is the primary name
+ if (names.size() == 1) {
+ primaryName = Optional.of( names.get(0).getName() );
+ }
+ // if more than one name, then seek the earliest name acquisition that was never released
+ else {
+ Map txByName = new HashMap<>(names.size());
+
+ // for each name, get the latest transaction
+ for (NameData nameData : names) {
+
+ // since the name is currently registered to the owner,
+ // we assume the latest transaction involving this name was the transaction that the acquired
+ // name through registration, purchase or update
+ Optional latestTransaction
+ = this.repository
+ .getTransactionRepository()
+ .getTransactionsInvolvingName(
+ nameData.getName(),
+ confirmationStatus
+ )
+ .stream()
+ .sorted(Comparator.comparing(
+ TransactionData::getTimestamp).reversed()
+ )
+ .findFirst(); // first is the last, since it was reversed
+
+ // if there is a latest transaction, expected for all registered names
+ if (latestTransaction.isPresent()) {
+ txByName.put(nameData.getName(), latestTransaction.get());
+ }
+ // if there is no latest transaction, then
+ else {
+ LOGGER.warn("No matching transaction for name: " + nameData.getName());
+ }
+ }
+
+ // get the first name aqcuistion for this address
+ Optional> firstNameEntry
+ = txByName.entrySet().stream().sorted(Comparator.comparing(entry -> entry.getValue().getTimestamp())).findFirst();
+
+ // if their is a name acquisition, then the first one is the primary name
+ if (firstNameEntry.isPresent()) {
+ primaryName = Optional.of( firstNameEntry.get().getKey() );
+ }
+ // if there is no nameacquistion, then there is no primary name
+ else {
+ primaryName = Optional.empty();
+ }
+ }
+ }
+ return primaryName;
+ }
+
+ /**
+ * Set Primary Name
+ *
+ * @param primaryName the primary to set to this address
+ *
+ * @return the primary name if successful, empty if unsuccessful
+ *
+ * @throws DataException
+ */
+ public Optional setPrimaryName( String primaryName ) throws DataException {
+ int changed = this.repository.getNameRepository().setPrimaryName(this.address, primaryName);
+
+ return changed > 0 ? Optional.of(primaryName) : Optional.empty();
+ }
+
/**
* Returns reward-share minting address, or unknown if reward-share does not exist.
*
diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java
index 2cebe8e5..1cfab1da 100644
--- a/src/main/java/org/qortal/api/ApiService.java
+++ b/src/main/java/org/qortal/api/ApiService.java
@@ -46,6 +46,7 @@ public class ApiService {
private ApiService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.resource", "org.qortal.api.restricted.resource");
+ this.config.register(org.glassfish.jersey.media.multipart.MultiPartFeature.class);
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
@@ -197,6 +198,7 @@ public class ApiService {
context.addServlet(DataMonitorSocket.class, "/websockets/datamonitor");
context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*");
context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages");
+ context.addServlet(UnsignedFeesSocket.class, "/websockets/crosschain/unsignedfees");
context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers");
context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot");
context.addServlet(TradePresenceWebSocket.class, "/websockets/crosschain/tradepresence");
diff --git a/src/main/java/org/qortal/api/DevProxyService.java b/src/main/java/org/qortal/api/DevProxyService.java
index e0bf02db..c0c4e224 100644
--- a/src/main/java/org/qortal/api/DevProxyService.java
+++ b/src/main/java/org/qortal/api/DevProxyService.java
@@ -40,6 +40,7 @@ public class DevProxyService {
private DevProxyService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource");
+ this.config.register(org.glassfish.jersey.media.multipart.MultiPartFeature.class);
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
diff --git a/src/main/java/org/qortal/api/DomainMapService.java b/src/main/java/org/qortal/api/DomainMapService.java
index 8b791121..5b4a6bbe 100644
--- a/src/main/java/org/qortal/api/DomainMapService.java
+++ b/src/main/java/org/qortal/api/DomainMapService.java
@@ -39,6 +39,7 @@ public class DomainMapService {
private DomainMapService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.resource", "org.qortal.api.domainmap.resource");
+ this.config.register(org.glassfish.jersey.media.multipart.MultiPartFeature.class);
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
diff --git a/src/main/java/org/qortal/api/GatewayService.java b/src/main/java/org/qortal/api/GatewayService.java
index 24a7b7c9..3ac77799 100644
--- a/src/main/java/org/qortal/api/GatewayService.java
+++ b/src/main/java/org/qortal/api/GatewayService.java
@@ -39,6 +39,7 @@ public class GatewayService {
private GatewayService() {
this.config = new ResourceConfig();
this.config.packages("org.qortal.api.resource", "org.qortal.api.gateway.resource");
+ this.config.register(org.glassfish.jersey.media.multipart.MultiPartFeature.class);
this.config.register(OpenApiResource.class);
this.config.register(ApiDefinition.class);
this.config.register(AnnotationPostProcessor.class);
diff --git a/src/main/java/org/qortal/api/HTMLParser.java b/src/main/java/org/qortal/api/HTMLParser.java
index 4887cf84..f5af7138 100644
--- a/src/main/java/org/qortal/api/HTMLParser.java
+++ b/src/main/java/org/qortal/api/HTMLParser.java
@@ -1,14 +1,13 @@
package org.qortal.api;
+
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.select.Elements;
import org.qortal.arbitrary.misc.Service;
-
import java.util.Objects;
-
public class HTMLParser {
private static final Logger LOGGER = LogManager.getLogger(HTMLParser.class);
@@ -22,10 +21,11 @@ public class HTMLParser {
private String identifier;
private String path;
private String theme;
+ private String lang;
private boolean usingCustomRouting;
public HTMLParser(String resourceId, String inPath, String prefix, boolean includeResourceIdInPrefix, byte[] data,
- String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting) {
+ String qdnContext, Service service, String identifier, String theme, boolean usingCustomRouting, String lang) {
String inPathWithoutFilename = inPath.contains("/") ? inPath.substring(0, inPath.lastIndexOf('/')) : String.format("/%s",inPath);
this.qdnBase = includeResourceIdInPrefix ? String.format("%s/%s", prefix, resourceId) : prefix;
this.qdnBaseWithPath = includeResourceIdInPrefix ? String.format("%s/%s%s", prefix, resourceId, inPathWithoutFilename) : String.format("%s%s", prefix, inPathWithoutFilename);
@@ -36,6 +36,7 @@ public class HTMLParser {
this.identifier = identifier;
this.path = inPath;
this.theme = theme;
+ this.lang = lang;
this.usingCustomRouting = usingCustomRouting;
}
@@ -61,9 +62,13 @@ public class HTMLParser {
String identifier = this.identifier != null ? this.identifier.replace("\\", "").replace("\"","\\\"") : "";
String path = this.path != null ? this.path.replace("\\", "").replace("\"","\\\"") : "";
String theme = this.theme != null ? this.theme.replace("\\", "").replace("\"","\\\"") : "";
+ String lang = this.lang != null ? this.lang.replace("\\", "").replace("\"", "\\\"") : "";
String qdnBase = this.qdnBase != null ? this.qdnBase.replace("\\", "").replace("\"","\\\"") : "";
String qdnBaseWithPath = this.qdnBaseWithPath != null ? this.qdnBaseWithPath.replace("\\", "").replace("\"","\\\"") : "";
- String qdnContextVar = String.format("", qdnContext, theme, service, name, identifier, path, qdnBase, qdnBaseWithPath);
+ String qdnContextVar = String.format(
+ "",
+ qdnContext, theme, lang, service, name, identifier, path, qdnBase, qdnBaseWithPath
+ );
head.get(0).prepend(qdnContextVar);
// Add base href tag
diff --git a/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java b/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java
index 3a531413..1ffef4fa 100644
--- a/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java
+++ b/src/main/java/org/qortal/api/model/crosschain/BitcoinyTBDRequest.java
@@ -304,11 +304,11 @@ public class BitcoinyTBDRequest {
private String networkName;
/**
- * Fee Ceiling
+ * Fee Required
*
- * web search, LTC fee ceiling = 1000L
+ * web search, LTC fee required = 1000L
*/
- private long feeCeiling;
+ private long feeRequired;
/**
* Extended Public Key
@@ -570,8 +570,8 @@ public class BitcoinyTBDRequest {
return this.networkName;
}
- public long getFeeCeiling() {
- return this.feeCeiling;
+ public long getFeeRequired() {
+ return this.feeRequired;
}
public String getExtendedPublicKey() {
@@ -671,7 +671,7 @@ public class BitcoinyTBDRequest {
", minimumOrderAmount=" + minimumOrderAmount +
", feePerKb=" + feePerKb +
", networkName='" + networkName + '\'' +
- ", feeCeiling=" + feeCeiling +
+ ", feeRequired=" + feeRequired +
", extendedPublicKey='" + extendedPublicKey + '\'' +
", sendAmount=" + sendAmount +
", sendingFeePerByte=" + sendingFeePerByte +
diff --git a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java
index 7972c551..bdaf1ced 100644
--- a/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java
+++ b/src/main/java/org/qortal/api/proxy/resource/DevProxyServerResource.java
@@ -142,10 +142,20 @@ public class DevProxyServerResource {
}
}
+ String lang = request.getParameter("lang");
+ if (lang == null || lang.isBlank()) {
+ lang = "en"; // fallback
+ }
+
+ String theme = request.getParameter("theme");
+ if (theme == null || theme.isBlank()) {
+ theme = "light";
+ }
+
// Parse and modify output if needed
if (HTMLParser.isHtmlFile(filename)) {
// HTML file - needs to be parsed
- HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true);
+ HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, theme , true, lang);
htmlParser.addAdditionalHeaderTags();
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;");
response.setContentType(con.getContentType());
diff --git a/src/main/java/org/qortal/api/resource/ArbitraryResource.java b/src/main/java/org/qortal/api/resource/ArbitraryResource.java
index a6f44373..bf69fda0 100644
--- a/src/main/java/org/qortal/api/resource/ArbitraryResource.java
+++ b/src/main/java/org/qortal/api/resource/ArbitraryResource.java
@@ -3,6 +3,7 @@ package org.qortal.api.resource;
import com.google.common.primitives.Bytes;
import com.j256.simplemagic.ContentInfo;
import com.j256.simplemagic.ContentInfoUtil;
+
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.ArraySchema;
@@ -12,6 +13,7 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.logging.log4j.LogManager;
@@ -63,14 +65,19 @@ import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
+
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.net.FileNameMap;
import java.net.URLConnection;
import java.nio.file.Files;
import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
@@ -78,6 +85,16 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
+import java.util.zip.GZIPOutputStream;
+
+import org.apache.tika.Tika;
+import org.apache.tika.mime.MimeTypeException;
+import org.apache.tika.mime.MimeTypes;
+
+import javax.ws.rs.core.Response;
+
+import org.glassfish.jersey.media.multipart.FormDataParam;
+import static org.qortal.api.ApiError.REPOSITORY_ISSUE;
@Path("/arbitrary")
@Tag(name = "Arbitrary")
@@ -686,20 +703,20 @@ public class ArbitraryResource {
)
}
)
- public HttpServletResponse get(@PathParam("service") Service service,
+ public void get(@PathParam("service") Service service,
@PathParam("name") String name,
@QueryParam("filepath") String filepath,
@QueryParam("encoding") String encoding,
@QueryParam("rebuild") boolean rebuild,
@QueryParam("async") boolean async,
- @QueryParam("attempts") Integer attempts) {
+ @QueryParam("attempts") Integer attempts, @QueryParam("attachment") boolean attachment, @QueryParam("attachmentFilename") String attachmentFilename) {
// Authentication can be bypassed in the settings, for those running public QDN nodes
if (!Settings.getInstance().isQDNAuthBypassEnabled()) {
Security.checkApiCallAllowed(request);
}
- return this.download(service, name, null, filepath, encoding, rebuild, async, attempts);
+ this.download(service, name, null, filepath, encoding, rebuild, async, attempts, attachment, attachmentFilename);
}
@GET
@@ -719,21 +736,21 @@ public class ArbitraryResource {
)
}
)
- public HttpServletResponse get(@PathParam("service") Service service,
+ public void get(@PathParam("service") Service service,
@PathParam("name") String name,
@PathParam("identifier") String identifier,
@QueryParam("filepath") String filepath,
@QueryParam("encoding") String encoding,
@QueryParam("rebuild") boolean rebuild,
@QueryParam("async") boolean async,
- @QueryParam("attempts") Integer attempts) {
+ @QueryParam("attempts") Integer attempts, @QueryParam("attachment") boolean attachment, @QueryParam("attachmentFilename") String attachmentFilename) {
// Authentication can be bypassed in the settings, for those running public QDN nodes
if (!Settings.getInstance().isQDNAuthBypassEnabled()) {
Security.checkApiCallAllowed(request, null);
}
- return this.download(service, name, identifier, filepath, encoding, rebuild, async, attempts);
+ this.download(service, name, identifier, filepath, encoding, rebuild, async, attempts, attachment, attachmentFilename);
}
@@ -878,6 +895,464 @@ public class ArbitraryResource {
}
+ @GET
+ @Path("/check/tmp")
+ @Produces(MediaType.TEXT_PLAIN)
+ @Operation(
+ summary = "Check if the disk has enough disk space for an upcoming upload",
+ responses = {
+ @ApiResponse(description = "OK if sufficient space", responseCode = "200"),
+ @ApiResponse(description = "Insufficient space", responseCode = "507") // 507 = Insufficient Storage
+ }
+ )
+ @SecurityRequirement(name = "apiKey")
+ public Response checkUploadSpace(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
+ @QueryParam("totalSize") Long totalSize) {
+ Security.checkApiCallAllowed(request);
+
+ if (totalSize == null || totalSize <= 0) {
+ return Response.status(Response.Status.BAD_REQUEST)
+ .entity("Missing or invalid totalSize parameter").build();
+ }
+
+ File uploadDir = new File("uploads-temp");
+ if (!uploadDir.exists()) {
+ uploadDir.mkdirs(); // ensure the folder exists
+ }
+
+ long usableSpace = uploadDir.getUsableSpace();
+ long requiredSpace = (long)(((double)totalSize) * 2.2); // estimate for chunks + merge
+
+ if (usableSpace < requiredSpace) {
+ return Response.status(507).entity("Insufficient disk space").build();
+ }
+
+ return Response.ok("Sufficient disk space").build();
+ }
+
+ @POST
+@Path("/{service}/{name}/chunk")
+@Consumes(MediaType.MULTIPART_FORM_DATA)
+@Operation(
+ summary = "Upload a single file chunk to be later assembled into a complete arbitrary resource (no identifier)",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.MULTIPART_FORM_DATA,
+ schema = @Schema(
+ implementation = Object.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "Chunk uploaded successfully",
+ responseCode = "200"
+ ),
+ @ApiResponse(
+ description = "Error writing chunk",
+ responseCode = "500"
+ )
+ }
+)
+@SecurityRequirement(name = "apiKey")
+public Response uploadChunkNoIdentifier(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
+ @PathParam("service") String serviceString,
+ @PathParam("name") String name,
+ @FormDataParam("chunk") InputStream chunkStream,
+ @FormDataParam("index") int index) {
+ Security.checkApiCallAllowed(request);
+
+ try {
+ String safeService = Paths.get(serviceString).getFileName().toString();
+ String safeName = Paths.get(name).getFileName().toString();
+
+ java.nio.file.Path tempDir = Paths.get("uploads-temp", safeService, safeName);
+ Files.createDirectories(tempDir);
+
+
+
+ java.nio.file.Path chunkFile = tempDir.resolve("chunk_" + index);
+ Files.copy(chunkStream, chunkFile, StandardCopyOption.REPLACE_EXISTING);
+
+ return Response.ok("Chunk " + index + " received").build();
+ } catch (IOException e) {
+ LOGGER.error("Failed to write chunk {} for service '{}' and name '{}'", index, serviceString, name, e);
+ return Response.serverError().entity("Failed to write chunk: " + e.getMessage()).build();
+ }
+}
+
+@POST
+@Path("/{service}/{name}/finalize")
+@Produces(MediaType.TEXT_PLAIN)
+@Operation(
+ summary = "Finalize a chunked upload (no identifier) and build a raw, unsigned, ARBITRARY transaction",
+ responses = {
+ @ApiResponse(
+ description = "raw, unsigned, ARBITRARY transaction encoded in Base58",
+ content = @Content(mediaType = MediaType.TEXT_PLAIN)
+ )
+ }
+)
+@SecurityRequirement(name = "apiKey")
+public String finalizeUploadNoIdentifier(
+ @HeaderParam(Security.API_KEY_HEADER) String apiKey,
+ @PathParam("service") String serviceString,
+ @PathParam("name") String name,
+ @QueryParam("title") String title,
+ @QueryParam("description") String description,
+ @QueryParam("tags") List tags,
+ @QueryParam("category") Category category,
+ @QueryParam("filename") String filename,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
+ @QueryParam("isZip") Boolean isZip
+) {
+ Security.checkApiCallAllowed(request);
+ java.nio.file.Path tempFile = null;
+ java.nio.file.Path tempDir = null;
+ java.nio.file.Path chunkDir = null;
+ String safeService = Paths.get(serviceString).getFileName().toString();
+ String safeName = Paths.get(name).getFileName().toString();
+
+
+
+ try {
+ chunkDir = Paths.get("uploads-temp", safeService, safeName);
+
+ if (!Files.exists(chunkDir) || !Files.isDirectory(chunkDir)) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "No chunks found for upload");
+ }
+
+ String safeFilename = (filename == null || filename.isBlank()) ? "qortal-" + NTP.getTime() : filename;
+ tempDir = Files.createTempDirectory("qortal-");
+ String sanitizedFilename = Paths.get(safeFilename).getFileName().toString();
+ tempFile = tempDir.resolve(sanitizedFilename);
+
+ try (OutputStream out = Files.newOutputStream(tempFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ byte[] buffer = new byte[65536];
+ for (java.nio.file.Path chunk : Files.list(chunkDir)
+ .filter(path -> path.getFileName().toString().startsWith("chunk_"))
+ .sorted(Comparator.comparingInt(path -> {
+ String name2 = path.getFileName().toString();
+ String numberPart = name2.substring("chunk_".length());
+ return Integer.parseInt(numberPart);
+ })).collect(Collectors.toList())) {
+ try (InputStream in = Files.newInputStream(chunk)) {
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ out.write(buffer, 0, bytesRead);
+ }
+ }
+ }
+ }
+
+ String detectedExtension = "";
+ String uploadFilename = null;
+ boolean extensionIsValid = false;
+
+ if (filename != null && !filename.isBlank()) {
+ int lastDot = filename.lastIndexOf('.');
+ if (lastDot > 0 && lastDot < filename.length() - 1) {
+ extensionIsValid = true;
+ uploadFilename = filename;
+ }
+ }
+
+ if (!extensionIsValid) {
+ Tika tika = new Tika();
+ String mimeType = tika.detect(tempFile.toFile());
+ try {
+ MimeTypes allTypes = MimeTypes.getDefaultMimeTypes();
+ org.apache.tika.mime.MimeType mime = allTypes.forName(mimeType);
+ detectedExtension = mime.getExtension();
+ } catch (MimeTypeException e) {
+ LOGGER.warn("Could not determine file extension for MIME type: {}", mimeType, e);
+ }
+
+ if (filename != null && !filename.isBlank()) {
+ int lastDot = filename.lastIndexOf('.');
+ String baseName = (lastDot > 0) ? filename.substring(0, lastDot) : filename;
+ uploadFilename = baseName + (detectedExtension != null ? detectedExtension : "");
+ } else {
+ uploadFilename = "qortal-" + NTP.getTime() + (detectedExtension != null ? detectedExtension : "");
+ }
+ }
+
+ Boolean isZipBoolean = false;
+
+ if (isZip != null && isZip) {
+ isZipBoolean = true;
+ }
+
+
+ // ✅ Call upload with `null` as identifier
+ return this.upload(
+ Service.valueOf(serviceString),
+ name,
+ null, // no identifier
+ tempFile.toString(),
+ null,
+ null,
+ isZipBoolean,
+ fee,
+ uploadFilename,
+ title,
+ description,
+ tags,
+ category,
+ preview
+ );
+
+ } catch (IOException e) {
+ LOGGER.error("Failed to merge chunks for service='{}', name='{}'", serviceString, name, e);
+
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, "Failed to merge chunks: " + e.getMessage());
+ } finally {
+ if (tempDir != null) {
+ try {
+ Files.walk(tempDir)
+ .sorted(Comparator.reverseOrder())
+ .map(java.nio.file.Path::toFile)
+ .forEach(File::delete);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to delete temp directory: {}", tempDir, e);
+ }
+ }
+
+ try {
+ Files.walk(chunkDir)
+ .sorted(Comparator.reverseOrder())
+ .map(java.nio.file.Path::toFile)
+ .forEach(File::delete);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to delete chunk directory: {}", chunkDir, e);
+ }
+ }
+}
+
+
+
+ @POST
+@Path("/{service}/{name}/{identifier}/chunk")
+@Consumes(MediaType.MULTIPART_FORM_DATA)
+@Operation(
+ summary = "Upload a single file chunk to be later assembled into a complete arbitrary resource",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.MULTIPART_FORM_DATA,
+ schema = @Schema(
+ implementation = Object.class
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "Chunk uploaded successfully",
+ responseCode = "200"
+ ),
+ @ApiResponse(
+ description = "Error writing chunk",
+ responseCode = "500"
+ )
+ }
+)
+@SecurityRequirement(name = "apiKey")
+public Response uploadChunk(@HeaderParam(Security.API_KEY_HEADER) String apiKey,
+ @PathParam("service") String serviceString,
+ @PathParam("name") String name,
+ @PathParam("identifier") String identifier,
+ @FormDataParam("chunk") InputStream chunkStream,
+ @FormDataParam("index") int index) {
+ Security.checkApiCallAllowed(request);
+
+ try {
+ String safeService = Paths.get(serviceString).getFileName().toString();
+ String safeName = Paths.get(name).getFileName().toString();
+ String safeIdentifier = Paths.get(identifier).getFileName().toString();
+
+ java.nio.file.Path tempDir = Paths.get("uploads-temp", safeService, safeName, safeIdentifier);
+
+ Files.createDirectories(tempDir);
+
+ java.nio.file.Path chunkFile = tempDir.resolve("chunk_" + index);
+ Files.copy(chunkStream, chunkFile, StandardCopyOption.REPLACE_EXISTING);
+
+ return Response.ok("Chunk " + index + " received").build();
+ } catch (IOException e) {
+ LOGGER.error("Failed to write chunk {} for service='{}', name='{}', identifier='{}'", index, serviceString, name, identifier, e);
+ return Response.serverError().entity("Failed to write chunk: " + e.getMessage()).build();
+ }
+}
+
+@POST
+@Path("/{service}/{name}/{identifier}/finalize")
+@Produces(MediaType.TEXT_PLAIN)
+@Operation(
+ summary = "Finalize a chunked upload and build a raw, unsigned, ARBITRARY transaction",
+ responses = {
+ @ApiResponse(
+ description = "raw, unsigned, ARBITRARY transaction encoded in Base58",
+ content = @Content(mediaType = MediaType.TEXT_PLAIN)
+ )
+ }
+)
+@SecurityRequirement(name = "apiKey")
+public String finalizeUpload(
+ @HeaderParam(Security.API_KEY_HEADER) String apiKey,
+ @PathParam("service") String serviceString,
+ @PathParam("name") String name,
+ @PathParam("identifier") String identifier,
+ @QueryParam("title") String title,
+ @QueryParam("description") String description,
+ @QueryParam("tags") List tags,
+ @QueryParam("category") Category category,
+ @QueryParam("filename") String filename,
+ @QueryParam("fee") Long fee,
+ @QueryParam("preview") Boolean preview,
+ @QueryParam("isZip") Boolean isZip
+) {
+ Security.checkApiCallAllowed(request);
+ java.nio.file.Path tempFile = null;
+ java.nio.file.Path tempDir = null;
+ java.nio.file.Path chunkDir = null;
+
+
+
+
+
+ try {
+ String safeService = Paths.get(serviceString).getFileName().toString();
+ String safeName = Paths.get(name).getFileName().toString();
+ String safeIdentifier = Paths.get(identifier).getFileName().toString();
+ java.nio.file.Path baseUploadsDir = Paths.get("uploads-temp"); // relative to Qortal working dir
+ chunkDir = baseUploadsDir.resolve(safeService).resolve(safeName).resolve(safeIdentifier);
+
+ if (!Files.exists(chunkDir) || !Files.isDirectory(chunkDir)) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "No chunks found for upload");
+ }
+
+ // Step 1: Determine a safe filename for disk temp file (regardless of extension correctness)
+ String safeFilename = filename;
+ if (filename == null || filename.isBlank()) {
+ safeFilename = "qortal-" + NTP.getTime();
+ }
+
+ tempDir = Files.createTempDirectory("qortal-");
+ String sanitizedFilename = Paths.get(safeFilename).getFileName().toString();
+ tempFile = tempDir.resolve(sanitizedFilename);
+
+
+ // Step 2: Merge chunks
+
+ try (OutputStream out = Files.newOutputStream(tempFile, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+ byte[] buffer = new byte[65536];
+ for (java.nio.file.Path chunk : Files.list(chunkDir)
+ .filter(path -> path.getFileName().toString().startsWith("chunk_"))
+ .sorted(Comparator.comparingInt(path -> {
+ String name2 = path.getFileName().toString();
+ String numberPart = name2.substring("chunk_".length());
+ return Integer.parseInt(numberPart);
+ })).collect(Collectors.toList())) {
+ try (InputStream in = Files.newInputStream(chunk)) {
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ out.write(buffer, 0, bytesRead);
+ }
+ }
+ }
+ }
+
+
+ // Step 3: Determine correct extension
+ String detectedExtension = "";
+ String uploadFilename = null;
+ boolean extensionIsValid = false;
+
+ if (filename != null && !filename.isBlank()) {
+ int lastDot = filename.lastIndexOf('.');
+ if (lastDot > 0 && lastDot < filename.length() - 1) {
+ extensionIsValid = true;
+ uploadFilename = filename;
+ }
+ }
+
+ if (!extensionIsValid) {
+ Tika tika = new Tika();
+ String mimeType = tika.detect(tempFile.toFile());
+ try {
+ MimeTypes allTypes = MimeTypes.getDefaultMimeTypes();
+ org.apache.tika.mime.MimeType mime = allTypes.forName(mimeType);
+ detectedExtension = mime.getExtension();
+ } catch (MimeTypeException e) {
+ LOGGER.warn("Could not determine file extension for MIME type: {}", mimeType, e);
+ }
+
+ if (filename != null && !filename.isBlank()) {
+ int lastDot = filename.lastIndexOf('.');
+ String baseName = (lastDot > 0) ? filename.substring(0, lastDot) : filename;
+ uploadFilename = baseName + (detectedExtension != null ? detectedExtension : "");
+ } else {
+ uploadFilename = "qortal-" + NTP.getTime() + (detectedExtension != null ? detectedExtension : "");
+ }
+ }
+
+
+ Boolean isZipBoolean = false;
+
+ if (isZip != null && isZip) {
+ isZipBoolean = true;
+ }
+
+
+ return this.upload(
+ Service.valueOf(serviceString),
+ name,
+ identifier,
+ tempFile.toString(),
+ null,
+ null,
+ isZipBoolean,
+ fee,
+ uploadFilename,
+ title,
+ description,
+ tags,
+ category,
+ preview
+ );
+
+ } catch (IOException e) {
+ LOGGER.error("Unexpected error in finalizeUpload for service='{}', name='{}', name='{}'", serviceString, name, identifier, e);
+
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.REPOSITORY_ISSUE, "Failed to merge chunks: " + e.getMessage());
+ } finally {
+ if (tempDir != null) {
+ try {
+ Files.walk(tempDir)
+ .sorted(Comparator.reverseOrder())
+ .map(java.nio.file.Path::toFile)
+ .forEach(File::delete);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to delete temp directory: {}", tempDir, e);
+ }
+ }
+
+ try {
+ Files.walk(chunkDir)
+ .sorted(Comparator.reverseOrder())
+ .map(java.nio.file.Path::toFile)
+ .forEach(File::delete);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to delete chunk directory: {}", chunkDir, e);
+ }
+ }
+}
+
+
+
+
// Upload base64-encoded data
@@ -1343,7 +1818,7 @@ public class ArbitraryResource {
if (path == null) {
// See if we have a string instead
if (string != null) {
- if (filename == null) {
+ if (filename == null || filename.isBlank()) {
// Use current time as filename
filename = String.format("qortal-%d", NTP.getTime());
}
@@ -1358,7 +1833,7 @@ public class ArbitraryResource {
}
// ... or base64 encoded raw data
else if (base64 != null) {
- if (filename == null) {
+ if (filename == null || filename.isBlank()) {
// Use current time as filename
filename = String.format("qortal-%d", NTP.getTime());
}
@@ -1409,6 +1884,7 @@ public class ArbitraryResource {
);
transactionBuilder.build();
+
// Don't compute nonce - this is done by the client (or via POST /arbitrary/compute)
ArbitraryTransactionData transactionData = transactionBuilder.getArbitraryTransactionData();
return Base58.encode(ArbitraryTransactionTransformer.toBytes(transactionData));
@@ -1424,22 +1900,20 @@ public class ArbitraryResource {
}
}
- private HttpServletResponse download(Service service, String name, String identifier, String filepath, String encoding, boolean rebuild, boolean async, Integer maxAttempts) {
-
+ private void download(Service service, String name, String identifier, String filepath, String encoding, boolean rebuild, boolean async, Integer maxAttempts, boolean attachment, String attachmentFilename) {
try {
ArbitraryDataReader arbitraryDataReader = new ArbitraryDataReader(name, ArbitraryDataFile.ResourceIdType.NAME, service, identifier);
-
+
int attempts = 0;
if (maxAttempts == null) {
maxAttempts = 5;
}
-
+
// Loop until we have data
if (async) {
// Asynchronous
arbitraryDataReader.loadAsynchronously(false, 1);
- }
- else {
+ } else {
// Synchronous
while (!Controller.isStopping()) {
attempts++;
@@ -1449,88 +1923,189 @@ public class ArbitraryResource {
break;
} catch (MissingDataException e) {
if (attempts > maxAttempts) {
- // Give up after 5 attempts
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "Data unavailable. Please try again later.");
}
}
}
- Thread.sleep(3000L);
}
}
-
+
java.nio.file.Path outputPath = arbitraryDataReader.getFilePath();
if (outputPath == null) {
// Assume the resource doesn't exist
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, "File not found");
}
-
+
if (filepath == null || filepath.isEmpty()) {
// No file path supplied - so check if this is a single file resource
String[] files = ArrayUtils.removeElement(outputPath.toFile().list(), ".qortal");
if (files != null && files.length == 1) {
// This is a single file resource
filepath = files[0];
- }
- else {
- throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA,
- "filepath is required for resources containing more than one file");
+ } else {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "filepath is required for resources containing more than one file");
}
}
-
+
java.nio.file.Path path = Paths.get(outputPath.toString(), filepath);
if (!Files.exists(path)) {
- String message = String.format("No file exists at filepath: %s", filepath);
- throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, message);
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, "No file exists at filepath: " + filepath);
}
-
- byte[] data;
- int fileSize = (int)path.toFile().length();
- int length = fileSize;
-
- // Parse "Range" header
- Integer rangeStart = null;
- Integer rangeEnd = null;
+
+ if (attachment) {
+ String rawFilename;
+
+ if (attachmentFilename != null && !attachmentFilename.isEmpty()) {
+ // 1. Sanitize first
+ String safeAttachmentFilename = attachmentFilename.replaceAll("[\\\\/:*?\"<>|]", "_");
+
+ // 2. Check for a valid extension (3–5 alphanumeric chars)
+ if (!safeAttachmentFilename.matches(".*\\.[a-zA-Z0-9]{2,5}$")) {
+ safeAttachmentFilename += ".bin";
+ }
+
+ rawFilename = safeAttachmentFilename;
+ } else {
+ // Fallback if no filename is provided
+ String baseFilename = (identifier != null && !identifier.isEmpty())
+ ? name + "-" + identifier
+ : name;
+ rawFilename = baseFilename.replaceAll("[\\\\/:*?\"<>|]", "_") + ".bin";
+ }
+
+ // Optional: trim length
+ rawFilename = rawFilename.length() > 100 ? rawFilename.substring(0, 100) : rawFilename;
+
+ // 3. Set Content-Disposition header
+ response.setHeader("Content-Disposition", "attachment; filename=\"" + rawFilename + "\"");
+ }
+
+ // Determine the total size of the requested file
+ long fileSize = Files.size(path);
+ String mimeType = context.getMimeType(path.toString());
+
+ // Attempt to read the "Range" header from the request to support partial content delivery (e.g., for video streaming or resumable downloads)
String range = request.getHeader("Range");
- if (range != null) {
- range = range.replace("bytes=", "");
- String[] parts = range.split("-");
- rangeStart = (parts != null && parts.length > 0) ? Integer.parseInt(parts[0]) : null;
- rangeEnd = (parts != null && parts.length > 1) ? Integer.parseInt(parts[1]) : fileSize;
+
+ long rangeStart = 0;
+ long rangeEnd = fileSize - 1;
+ boolean isPartial = false;
+
+ // If a Range header is present and no base64 encoding is requested, parse the range values
+ if (range != null && encoding == null) {
+ range = range.replace("bytes=", ""); // Remove the "bytes=" prefix
+ String[] parts = range.split("-"); // Split the range into start and end
+
+ // Parse range start
+ if (parts.length > 0 && !parts[0].isEmpty()) {
+ rangeStart = Long.parseLong(parts[0]);
+ }
+
+ // Parse range end, if present
+ if (parts.length > 1 && !parts[1].isEmpty()) {
+ rangeEnd = Long.parseLong(parts[1]);
+ }
+
+ isPartial = true; // Indicate that this is a partial content request
}
-
- if (rangeStart != null && rangeEnd != null) {
- // We have a range, so update the requested length
- length = rangeEnd - rangeStart;
+
+ // Calculate how many bytes should be sent in the response
+ long contentLength = rangeEnd - rangeStart + 1;
+
+ // Inform the client that byte ranges are supported
+ response.setHeader("Accept-Ranges", "bytes");
+
+ if (isPartial) {
+ // If partial content was requested, return 206 Partial Content with appropriate headers
+ response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
+ response.setHeader("Content-Range", String.format("bytes %d-%d/%d", rangeStart, rangeEnd, fileSize));
+ } else {
+ // Otherwise, return the entire file with status 200 OK
+ response.setStatus(HttpServletResponse.SC_OK);
}
-
- if (length < fileSize && encoding == null) {
- // Partial content requested, and not encoding the data
- response.setStatus(206);
- response.addHeader("Content-Range", String.format("bytes %d-%d/%d", rangeStart, rangeEnd-1, fileSize));
- data = FilesystemUtils.readFromFile(path.toString(), rangeStart, length);
+
+ // Initialize output streams for writing the file to the response
+ OutputStream rawOut = null;
+ OutputStream base64Out = null;
+ OutputStream gzipOut = null;
+
+ try {
+ rawOut = response.getOutputStream();
+
+ if (encoding != null && "base64".equalsIgnoreCase(encoding)) {
+ // If base64 encoding is requested, override content type
+ response.setContentType("text/plain");
+
+ // Check if the client accepts gzip encoding
+ String acceptEncoding = request.getHeader("Accept-Encoding");
+ boolean wantsGzip = acceptEncoding != null && acceptEncoding.contains("gzip");
+
+ if (wantsGzip) {
+ // Wrap output in GZIP and Base64 streams if gzip is accepted
+ response.setHeader("Content-Encoding", "gzip");
+ gzipOut = new GZIPOutputStream(rawOut);
+ base64Out = java.util.Base64.getEncoder().wrap(gzipOut);
+ } else {
+ // Wrap output in Base64 only
+ base64Out = java.util.Base64.getEncoder().wrap(rawOut);
+ }
+
+ rawOut = base64Out; // Use the wrapped stream for writing
+ } else {
+ // For raw binary output, set the content type and length
+ response.setContentType(mimeType != null ? mimeType : "application/octet-stream");
+ response.setContentLength((int) contentLength);
+ }
+
+ // Stream file content
+ try (InputStream inputStream = Files.newInputStream(path)) {
+ if (rangeStart > 0) {
+ inputStream.skip(rangeStart);
+ }
+
+ byte[] buffer = new byte[65536];
+ long bytesRemaining = contentLength;
+ int bytesRead;
+
+ while (bytesRemaining > 0 && (bytesRead = inputStream.read(buffer, 0, (int) Math.min(buffer.length, bytesRemaining))) != -1) {
+ rawOut.write(buffer, 0, bytesRead);
+ bytesRemaining -= bytesRead;
+ }
+ }
+
+ // Stream finished
+ if (base64Out != null) {
+ base64Out.close(); // Also flushes and closes the wrapped gzipOut
+ } else if (gzipOut != null) {
+ gzipOut.close(); // Only close gzipOut if it wasn't wrapped by base64Out
+ } else {
+ rawOut.flush(); // Flush only the base output stream if nothing was wrapped
+ }
+
+ if (!response.isCommitted()) {
+ response.setStatus(HttpServletResponse.SC_OK);
+ response.getWriter().write(" ");
+ }
+
+ } catch (IOException e) {
+ // Streaming errors should not rethrow — just log
+ LOGGER.warn(String.format("Streaming error for %s %s: %s", service, name, e.getMessage()));
}
- else {
- // Full content requested (or encoded data)
- response.setStatus(200);
- data = Files.readAllBytes(path); // TODO: limit file size that can be read into memory
+
+ } catch (IOException | ApiException | DataException e) {
+ LOGGER.warn(String.format("Unable to load %s %s: %s", service, name, e.getMessage()));
+ if (!response.isCommitted()) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
}
-
- // Encode the data if requested
- if (encoding != null && Objects.equals(encoding.toLowerCase(), "base64")) {
- data = Base64.encode(data);
+ } catch (NumberFormatException e) {
+ LOGGER.warn(String.format("Invalid range for %s %s: %s", service, name, e.getMessage()));
+ if (!response.isCommitted()) {
+ throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_DATA, e.getMessage());
}
-
- response.addHeader("Accept-Ranges", "bytes");
- response.setContentType(context.getMimeType(path.toString()));
- response.setContentLength(data.length);
- response.getOutputStream().write(data);
-
- return response;
- } catch (Exception e) {
- LOGGER.debug(String.format("Unable to load %s %s: %s", service, name, e.getMessage()));
- throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FILE_NOT_FOUND, e.getMessage());
}
}
+
+
private FileProperties getFileProperties(Service service, String name, String identifier) {
try {
diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java
index 3720a0b5..fb070178 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java
@@ -502,10 +502,10 @@ public class CrossChainBitcoinResource {
}
@GET
- @Path("/feeceiling")
+ @Path("/feerequired")
@Operation(
- summary = "Returns Bitcoin fee per Kb.",
- description = "Returns Bitcoin fee per Kb.",
+ summary = "The total fee required for unlocking BTC to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
responses = {
@ApiResponse(
content = @Content(
@@ -516,17 +516,17 @@ public class CrossChainBitcoinResource {
)
}
)
- public String getBitcoinFeeCeiling() {
+ public String getBitcoinFeeRequired() {
Bitcoin bitcoin = Bitcoin.getInstance();
- return String.valueOf(bitcoin.getFeeCeiling());
+ return String.valueOf(bitcoin.getFeeRequired());
}
@POST
- @Path("/updatefeeceiling")
+ @Path("/updatefeerequired")
@Operation(
- summary = "Sets Bitcoin fee ceiling.",
- description = "Sets Bitcoin fee ceiling.",
+ summary = "The total fee required for unlocking BTC to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -545,13 +545,13 @@ public class CrossChainBitcoinResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA})
- public String setBitcoinFeeCeiling(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
+ public String setBitcoinFeeRequired(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
Security.checkApiCallAllowed(request);
Bitcoin bitcoin = Bitcoin.getInstance();
try {
- return CrossChainUtils.setFeeCeiling(bitcoin, fee);
+ return CrossChainUtils.setFeeRequired(bitcoin, fee);
}
catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
diff --git a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java
index 72b10096..1bf707db 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainDigibyteResource.java
@@ -502,10 +502,10 @@ public class CrossChainDigibyteResource {
}
@GET
- @Path("/feeceiling")
+ @Path("/feerequired")
@Operation(
- summary = "Returns Digibyte fee per Kb.",
- description = "Returns Digibyte fee per Kb.",
+ summary = "The total fee required for unlocking DGB to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
responses = {
@ApiResponse(
content = @Content(
@@ -516,17 +516,17 @@ public class CrossChainDigibyteResource {
)
}
)
- public String getDigibyteFeeCeiling() {
+ public String getDigibyteFeeRequired() {
Digibyte digibyte = Digibyte.getInstance();
- return String.valueOf(digibyte.getFeeCeiling());
+ return String.valueOf(digibyte.getFeeRequired());
}
@POST
- @Path("/updatefeeceiling")
+ @Path("/updatefeerequired")
@Operation(
- summary = "Sets Digibyte fee ceiling.",
- description = "Sets Digibyte fee ceiling.",
+ summary = "The total fee required for unlocking DGB to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -545,13 +545,13 @@ public class CrossChainDigibyteResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA})
- public String setDigibyteFeeCeiling(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
+ public String setDigibyteFeeRequired(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
Security.checkApiCallAllowed(request);
Digibyte digibyte = Digibyte.getInstance();
try {
- return CrossChainUtils.setFeeCeiling(digibyte, fee);
+ return CrossChainUtils.setFeeRequired(digibyte, fee);
}
catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
diff --git a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java
index 4aa82e2b..5de39fb1 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainDogecoinResource.java
@@ -502,10 +502,10 @@ public class CrossChainDogecoinResource {
}
@GET
- @Path("/feeceiling")
+ @Path("/feerequired")
@Operation(
- summary = "Returns Dogecoin fee per Kb.",
- description = "Returns Dogecoin fee per Kb.",
+ summary = "The total fee required for unlocking DOGE to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
responses = {
@ApiResponse(
content = @Content(
@@ -516,17 +516,17 @@ public class CrossChainDogecoinResource {
)
}
)
- public String getDogecoinFeeCeiling() {
+ public String getDogecoinFeeRequired() {
Dogecoin dogecoin = Dogecoin.getInstance();
- return String.valueOf(dogecoin.getFeeCeiling());
+ return String.valueOf(dogecoin.getFeeRequired());
}
@POST
- @Path("/updatefeeceiling")
+ @Path("/updatefeerequired")
@Operation(
- summary = "Sets Dogecoin fee ceiling.",
- description = "Sets Dogecoin fee ceiling.",
+ summary = "The total fee required for unlocking DOGE to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -545,13 +545,13 @@ public class CrossChainDogecoinResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA})
- public String setDogecoinFeeCeiling(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
+ public String setDogecoinFeeRequired(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
Security.checkApiCallAllowed(request);
Dogecoin dogecoin = Dogecoin.getInstance();
try {
- return CrossChainUtils.setFeeCeiling(dogecoin, fee);
+ return CrossChainUtils.setFeeRequired(dogecoin, fee);
}
catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java
index 5b9e29d2..1564ff61 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java
@@ -540,10 +540,10 @@ public class CrossChainLitecoinResource {
}
@GET
- @Path("/feeceiling")
+ @Path("/feerequired")
@Operation(
- summary = "Returns Litecoin fee per Kb.",
- description = "Returns Litecoin fee per Kb.",
+ summary = "The total fee required for unlocking LTC to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
responses = {
@ApiResponse(
content = @Content(
@@ -554,17 +554,17 @@ public class CrossChainLitecoinResource {
)
}
)
- public String getLitecoinFeeCeiling() {
+ public String getLitecoinFeeRequired() {
Litecoin litecoin = Litecoin.getInstance();
- return String.valueOf(litecoin.getFeeCeiling());
+ return String.valueOf(litecoin.getFeeRequired());
}
@POST
- @Path("/updatefeeceiling")
+ @Path("/updatefeerequired")
@Operation(
- summary = "Sets Litecoin fee ceiling.",
- description = "Sets Litecoin fee ceiling.",
+ summary = "The total fee required for unlocking LTC to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -583,13 +583,13 @@ public class CrossChainLitecoinResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA})
- public String setLitecoinFeeCeiling(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
+ public String setLitecoinFeeRequired(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
Security.checkApiCallAllowed(request);
Litecoin litecoin = Litecoin.getInstance();
try {
- return CrossChainUtils.setFeeCeiling(litecoin, fee);
+ return CrossChainUtils.setFeeRequired(litecoin, fee);
}
catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
diff --git a/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java
index c6378f0b..c5e8ac82 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainPirateChainResource.java
@@ -587,10 +587,10 @@ public class CrossChainPirateChainResource {
}
@GET
- @Path("/feeceiling")
+ @Path("/feerequired")
@Operation(
- summary = "Returns PirateChain fee per Kb.",
- description = "Returns PirateChain fee per Kb.",
+ summary = "The total fee required for unlocking ARRR to the trade offer creator.",
+ description = "The total fee required for unlocking ARRR to the trade offer creator.",
responses = {
@ApiResponse(
content = @Content(
@@ -601,17 +601,17 @@ public class CrossChainPirateChainResource {
)
}
)
- public String getPirateChainFeeCeiling() {
+ public String getPirateChainFeeRequired() {
PirateChain pirateChain = PirateChain.getInstance();
- return String.valueOf(pirateChain.getFeeCeiling());
+ return String.valueOf(pirateChain.getFeeRequired());
}
@POST
- @Path("/updatefeeceiling")
+ @Path("/updatefeerequired")
@Operation(
- summary = "Sets PirateChain fee ceiling.",
- description = "Sets PirateChain fee ceiling.",
+ summary = "The total fee required for unlocking ARRR to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -630,13 +630,13 @@ public class CrossChainPirateChainResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA})
- public String setPirateChainFeeCeiling(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
+ public String setPirateChainFeeRequired(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
Security.checkApiCallAllowed(request);
PirateChain pirateChain = PirateChain.getInstance();
try {
- return CrossChainUtils.setFeeCeiling(pirateChain, fee);
+ return CrossChainUtils.setFeeRequired(pirateChain, fee);
}
catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
diff --git a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java
index c06122a9..72ff57f9 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainRavencoinResource.java
@@ -502,10 +502,10 @@ public class CrossChainRavencoinResource {
}
@GET
- @Path("/feeceiling")
+ @Path("/feerequired")
@Operation(
- summary = "Returns Ravencoin fee per Kb.",
- description = "Returns Ravencoin fee per Kb.",
+ summary = "The total fee required for unlocking RVN to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
responses = {
@ApiResponse(
content = @Content(
@@ -516,17 +516,17 @@ public class CrossChainRavencoinResource {
)
}
)
- public String getRavencoinFeeCeiling() {
+ public String getRavencoinFeeRequired() {
Ravencoin ravencoin = Ravencoin.getInstance();
- return String.valueOf(ravencoin.getFeeCeiling());
+ return String.valueOf(ravencoin.getFeeRequired());
}
@POST
- @Path("/updatefeeceiling")
+ @Path("/updatefeerequired")
@Operation(
- summary = "Sets Ravencoin fee ceiling.",
- description = "Sets Ravencoin fee ceiling.",
+ summary = "The total fee required for unlocking RVN to the trade offer creator.",
+ description = "This is in sats for a transaction that is approximately 300 kB in size.",
requestBody = @RequestBody(
required = true,
content = @Content(
@@ -545,13 +545,13 @@ public class CrossChainRavencoinResource {
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA})
- public String setRavencoinFeeCeiling(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
+ public String setRavencoinFeeRequired(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String fee) {
Security.checkApiCallAllowed(request);
Ravencoin ravencoin = Ravencoin.getInstance();
try {
- return CrossChainUtils.setFeeCeiling(ravencoin, fee);
+ return CrossChainUtils.setFeeRequired(ravencoin, fee);
}
catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java
index 3f7acf68..7ee7878c 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainResource.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java
@@ -10,6 +10,8 @@ import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
import org.glassfish.jersey.media.multipart.ContentDisposition;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrors;
@@ -18,6 +20,7 @@ import org.qortal.api.Security;
import org.qortal.api.model.CrossChainCancelRequest;
import org.qortal.api.model.CrossChainTradeLedgerEntry;
import org.qortal.api.model.CrossChainTradeSummary;
+import org.qortal.controller.ForeignFeesManager;
import org.qortal.controller.tradebot.TradeBot;
import org.qortal.crosschain.ACCT;
import org.qortal.crosschain.AcctMode;
@@ -29,6 +32,8 @@ import org.qortal.data.at.ATData;
import org.qortal.data.at.ATStateData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.crosschain.TransactionSummary;
+import org.qortal.data.crosschain.ForeignFeeDecodedData;
+import org.qortal.data.crosschain.ForeignFeeEncodedData;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
@@ -64,6 +69,8 @@ import java.util.stream.Collectors;
@Tag(name = "Cross-Chain")
public class CrossChainResource {
+ private static final Logger LOGGER = LogManager.getLogger(CrossChainResource.class);
+
@Context
HttpServletRequest request;
@@ -360,6 +367,101 @@ public class CrossChainResource {
}
}
+ @POST
+ @Path("/signedfees")
+ @Operation(
+ summary = "",
+ description = "",
+ requestBody = @RequestBody(
+ required = true,
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ array = @ArraySchema(
+ schema = @Schema(
+ implementation = ForeignFeeEncodedData.class
+ )
+ )
+ )
+ ),
+ responses = {
+ @ApiResponse(
+ description = "true on success",
+ content = @Content(
+ mediaType = MediaType.TEXT_PLAIN,
+ schema = @Schema(
+ type = "boolean"
+ )
+ )
+ )
+ }
+ )
+ public String postSignedForeignFees(List signedFees) {
+
+ LOGGER.info("signedFees = " + signedFees);
+
+ try {
+ ForeignFeesManager.getInstance().addSignedFees(signedFees);
+
+ return "true";
+ }
+ catch( Exception e ) {
+
+ LOGGER.error(e.getMessage(), e);
+
+ return "false";
+ }
+ }
+
+ @GET
+ @Path("/unsignedfees/{address}")
+ @Operation(
+ summary = "",
+ description = "",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ array = @ArraySchema(
+ schema = @Schema(
+ implementation = ForeignFeeEncodedData.class
+ )
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
+ public List getUnsignedFees(@PathParam("address") String address) {
+
+ List unsignedFeesForAddress = ForeignFeesManager.getInstance().getUnsignedFeesForAddress(address);
+
+ LOGGER.info("address = " + address);
+ LOGGER.info("returning unsigned = " + unsignedFeesForAddress);
+ return unsignedFeesForAddress;
+ }
+
+ @GET
+ @Path("/signedfees")
+ @Operation(
+ summary = "",
+ description = "",
+ responses = {
+ @ApiResponse(
+ content = @Content(
+ array = @ArraySchema(
+ schema = @Schema(
+ implementation = ForeignFeeDecodedData.class
+ )
+ )
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
+ public List getSignedFees() {
+
+ return ForeignFeesManager.getInstance().getSignedFees();
+ }
+
/**
* Decode Public Key
*
diff --git a/src/main/java/org/qortal/api/resource/CrossChainUtils.java b/src/main/java/org/qortal/api/resource/CrossChainUtils.java
index ddd1d2d6..eec784e7 100644
--- a/src/main/java/org/qortal/api/resource/CrossChainUtils.java
+++ b/src/main/java/org/qortal/api/resource/CrossChainUtils.java
@@ -12,10 +12,15 @@ import org.bouncycastle.util.Strings;
import org.json.simple.JSONObject;
import org.qortal.api.model.CrossChainTradeLedgerEntry;
import org.qortal.api.model.crosschain.BitcoinyTBDRequest;
+import org.qortal.asset.Asset;
import org.qortal.crosschain.*;
+import org.qortal.data.account.AccountBalanceData;
import org.qortal.data.at.ATData;
import org.qortal.data.at.ATStateData;
import org.qortal.data.crosschain.*;
+import org.qortal.event.EventBus;
+import org.qortal.event.LockingFeeUpdateEvent;
+import org.qortal.event.RequiredFeeUpdateEvent;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.utils.Amounts;
@@ -23,15 +28,11 @@ import org.qortal.utils.BitTwiddling;
import java.io.BufferedWriter;
import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
import java.io.Writer;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.ZonedDateTime;
import java.util.*;
+import java.util.function.Function;
import java.util.stream.Collectors;
@@ -103,11 +104,13 @@ public class CrossChainUtils {
bitcoiny.setFeePerKb(Coin.valueOf(satoshis) );
+ EventBus.INSTANCE.notify(new LockingFeeUpdateEvent());
+
return String.valueOf(bitcoiny.getFeePerKb().value);
}
/**
- * Set Fee Ceiling
+ * Set Fee Required
*
* @param bitcoiny the blockchain support
* @param fee the fee in satoshis
@@ -116,14 +119,16 @@ public class CrossChainUtils {
*
* @throws IllegalArgumentException if invalid
*/
- public static String setFeeCeiling(Bitcoiny bitcoiny, String fee) throws IllegalArgumentException{
+ public static String setFeeRequired(Bitcoiny bitcoiny, String fee) throws IllegalArgumentException{
long satoshis = Long.parseLong(fee);
if( satoshis < 0 ) throw new IllegalArgumentException("can't set fee to negative number");
- bitcoiny.setFeeCeiling( Long.parseLong(fee));
+ bitcoiny.setFeeRequired( Long.parseLong(fee));
- return String.valueOf(bitcoiny.getFeeCeiling());
+ EventBus.INSTANCE.notify(new RequiredFeeUpdateEvent(bitcoiny));
+
+ return String.valueOf(bitcoiny.getFeeRequired());
}
/**
@@ -232,6 +237,9 @@ public class CrossChainUtils {
return bitcoiny.getBlockchainProvider().removeServer(server);
}
+ public static ChainableServer getCurrentServer( Bitcoiny bitcoiny ) {
+ return bitcoiny.getBlockchainProvider().getCurrentServer();
+ }
/**
* Set Current Server
*
@@ -771,4 +779,46 @@ public class CrossChainUtils {
entries.add(ledgerEntry);
}
}
+
+ public static List populateTradeDataList(Repository repository, ACCT acct, List atDataList) throws DataException {
+
+ if(atDataList.isEmpty()) return new ArrayList<>(0);
+
+ List latestATStates
+ = repository.getATRepository()
+ .getLatestATStates(
+ atDataList.stream()
+ .map(ATData::getATAddress)
+ .collect(Collectors.toList())
+ );
+
+ Map atStateDataByAtAddress
+ = latestATStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, Function.identity()));
+
+ Map atDataByAtAddress
+ = atDataList.stream().collect(Collectors.toMap(ATData::getATAddress, Function.identity()));
+
+ Map balanceByAtAddress
+ = repository
+ .getAccountRepository()
+ .getBalances(new ArrayList<>(atDataByAtAddress.keySet()), Asset.QORT)
+ .stream().collect(Collectors.toMap(AccountBalanceData::getAddress, AccountBalanceData::getBalance));
+
+ List crossChainTradeDataList = new ArrayList<>(latestATStates.size());
+
+ for( ATStateData atStateData : latestATStates ) {
+ ATData atData = atDataByAtAddress.get(atStateData.getATAddress());
+ crossChainTradeDataList.add(
+ acct.populateTradeData(
+ repository,
+ atData.getCreatorPublicKey(),
+ atData.getCreation(),
+ atStateData,
+ OptionalLong.of(balanceByAtAddress.get(atStateData.getATAddress()))
+ )
+ );
+ }
+
+ return crossChainTradeDataList;
+ }
}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/api/resource/NamesResource.java b/src/main/java/org/qortal/api/resource/NamesResource.java
index c7d4a425..a28615fd 100644
--- a/src/main/java/org/qortal/api/resource/NamesResource.java
+++ b/src/main/java/org/qortal/api/resource/NamesResource.java
@@ -33,6 +33,7 @@ import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.util.List;
+import java.util.Optional;
import java.util.stream.Collectors;
@Path("/names")
@@ -104,6 +105,45 @@ public class NamesResource {
}
}
+ @GET
+ @Path("/primary/{address}")
+ @Operation(
+ summary = "primary name owned by address",
+ responses = {
+ @ApiResponse(
+ description = "registered primary name info",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_JSON,
+ schema = @Schema(implementation = NameSummary.class)
+ )
+ )
+ }
+ )
+ @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE, ApiError.UNAUTHORIZED})
+ public NameSummary getPrimaryNameByAddress(@PathParam("address") String address) {
+ if (!Crypto.isValidAddress(address))
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+
+ if (Settings.getInstance().isLite()) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
+ }
+ else {
+ Optional primaryName = repository.getNameRepository().getPrimaryName(address);
+
+ if(primaryName.isPresent()) {
+ return new NameSummary(new NameData(primaryName.get(), address));
+ }
+ else {
+ return new NameSummary((new NameData(null, address)));
+ }
+ }
+ } catch (DataException e) {
+ throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
+ }
+ }
+
@GET
@Path("/{name}")
@Operation(
diff --git a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java
index 439904eb..8c075d7e 100644
--- a/src/main/java/org/qortal/api/restricted/resource/AdminResource.java
+++ b/src/main/java/org/qortal/api/restricted/resource/AdminResource.java
@@ -1092,25 +1092,4 @@ public class AdminResource {
return info;
}
-
- @GET
- @Path("/dbstates")
- @Operation(
- summary = "Get DB States",
- description = "Get DB States",
- responses = {
- @ApiResponse(
- content = @Content(mediaType = MediaType.APPLICATION_JSON, array = @ArraySchema(schema = @Schema(implementation = DbConnectionInfo.class)))
- )
- }
- )
- public List getDbConnectionsStates() {
-
- try {
- return Controller.REPOSITORY_FACTORY.getDbConnectionsStates();
- } catch (Exception e) {
- LOGGER.error(e.getMessage(), e);
- return new ArrayList<>(0);
- }
- }
}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java
index 5eb169a4..3bab7bf3 100644
--- a/src/main/java/org/qortal/api/restricted/resource/RenderResource.java
+++ b/src/main/java/org/qortal/api/restricted/resource/RenderResource.java
@@ -71,33 +71,33 @@ public class RenderResource {
@Path("/signature/{signature}")
@SecurityRequirement(name = "apiKey")
public HttpServletResponse getIndexBySignature(@PathParam("signature") String signature,
- @QueryParam("theme") String theme) {
+ @QueryParam("theme") String theme, @QueryParam("lang") String lang) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null);
- return this.get(signature, ResourceIdType.SIGNATURE, null, null, "/", null, "/render/signature", true, true, theme);
+ return this.get(signature, ResourceIdType.SIGNATURE, null, null, "/", null, "/render/signature", true, true, theme, lang);
}
@GET
@Path("/signature/{signature}/{path:.*}")
@SecurityRequirement(name = "apiKey")
public HttpServletResponse getPathBySignature(@PathParam("signature") String signature, @PathParam("path") String inPath,
- @QueryParam("theme") String theme) {
+ @QueryParam("theme") String theme, @QueryParam("lang") String lang) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorization(request, signature, Service.WEBSITE, null);
- return this.get(signature, ResourceIdType.SIGNATURE, null, null, inPath,null, "/render/signature", true, true, theme);
+ return this.get(signature, ResourceIdType.SIGNATURE, null, null, inPath,null, "/render/signature", true, true, theme, lang);
}
@GET
@Path("/hash/{hash}")
@SecurityRequirement(name = "apiKey")
public HttpServletResponse getIndexByHash(@PathParam("hash") String hash58, @QueryParam("secret") String secret58,
- @QueryParam("theme") String theme) {
+ @QueryParam("theme") String theme, @QueryParam("lang") String lang) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null);
- return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, "/", secret58, "/render/hash", true, false, theme);
+ return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, "/", secret58, "/render/hash", true, false, theme, lang);
}
@GET
@@ -105,11 +105,11 @@ public class RenderResource {
@SecurityRequirement(name = "apiKey")
public HttpServletResponse getPathByHash(@PathParam("hash") String hash58, @PathParam("path") String inPath,
@QueryParam("secret") String secret58,
- @QueryParam("theme") String theme) {
+ @QueryParam("theme") String theme, @QueryParam("lang") String lang) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorization(request, hash58, Service.WEBSITE, null);
- return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, inPath, secret58, "/render/hash", true, false, theme);
+ return this.get(hash58, ResourceIdType.FILE_HASH, Service.ARBITRARY_DATA, null, inPath, secret58, "/render/hash", true, false, theme, lang);
}
@GET
@@ -119,12 +119,12 @@ public class RenderResource {
@PathParam("name") String name,
@PathParam("path") String inPath,
@QueryParam("identifier") String identifier,
- @QueryParam("theme") String theme) {
+ @QueryParam("theme") String theme, @QueryParam("lang") String lang) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorization(request, name, service, null);
String prefix = String.format("/render/%s", service);
- return this.get(name, ResourceIdType.NAME, service, identifier, inPath, null, prefix, true, true, theme);
+ return this.get(name, ResourceIdType.NAME, service, identifier, inPath, null, prefix, true, true, theme, lang);
}
@GET
@@ -133,18 +133,18 @@ public class RenderResource {
public HttpServletResponse getIndexByName(@PathParam("service") Service service,
@PathParam("name") String name,
@QueryParam("identifier") String identifier,
- @QueryParam("theme") String theme) {
+ @QueryParam("theme") String theme, @QueryParam("lang") String lang) {
if (!Settings.getInstance().isQDNAuthBypassEnabled())
Security.requirePriorAuthorization(request, name, service, null);
String prefix = String.format("/render/%s", service);
- return this.get(name, ResourceIdType.NAME, service, identifier, "/", null, prefix, true, true, theme);
+ return this.get(name, ResourceIdType.NAME, service, identifier, "/", null, prefix, true, true, theme, lang);
}
private HttpServletResponse get(String resourceId, ResourceIdType resourceIdType, Service service, String identifier,
- String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String theme) {
+ String inPath, String secret58, String prefix, boolean includeResourceIdInPrefix, boolean async, String theme, String lang) {
ArbitraryDataRenderer renderer = new ArbitraryDataRenderer(resourceId, resourceIdType, service, identifier, inPath,
secret58, prefix, includeResourceIdInPrefix, async, "render", request, response, context);
@@ -152,6 +152,9 @@ public class RenderResource {
if (theme != null) {
renderer.setTheme(theme);
}
+ if (lang != null) {
+ renderer.setLang(lang);
+ }
return renderer.render();
}
diff --git a/src/main/java/org/qortal/api/websocket/UnsignedFeesSocket.java b/src/main/java/org/qortal/api/websocket/UnsignedFeesSocket.java
new file mode 100644
index 00000000..c19c2227
--- /dev/null
+++ b/src/main/java/org/qortal/api/websocket/UnsignedFeesSocket.java
@@ -0,0 +1,83 @@
+package org.qortal.api.websocket;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.WebSocketException;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
+import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
+import org.eclipse.jetty.websocket.api.annotations.WebSocket;
+import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
+import org.qortal.data.crosschain.UnsignedFeeEvent;
+import org.qortal.event.Event;
+import org.qortal.event.EventBus;
+import org.qortal.event.FeeWaitingEvent;
+import org.qortal.event.Listener;
+
+import java.io.IOException;
+import java.io.StringWriter;
+
+@WebSocket
+@SuppressWarnings("serial")
+public class UnsignedFeesSocket extends ApiWebSocket implements Listener {
+
+ private static final Logger LOGGER = LogManager.getLogger(UnsignedFeesSocket.class);
+
+ @Override
+ public void configure(WebSocketServletFactory factory) {
+ LOGGER.info("configure");
+
+ factory.register(UnsignedFeesSocket.class);
+
+ EventBus.INSTANCE.addListener(this);
+ }
+
+ @Override
+ public void listen(Event event) {
+ if (!(event instanceof FeeWaitingEvent))
+ return;
+
+ for (Session session : getSessions()) {
+ FeeWaitingEvent feeWaitingEvent = (FeeWaitingEvent) event;
+ sendUnsignedFeeEvent(session, new UnsignedFeeEvent(feeWaitingEvent.isPositive(), feeWaitingEvent.getAddress()));
+ }
+ }
+
+
+ @OnWebSocketConnect
+ @Override
+ public void onWebSocketConnect(Session session) {
+ super.onWebSocketConnect(session);
+ }
+
+ @OnWebSocketClose
+ @Override
+ public void onWebSocketClose(Session session, int statusCode, String reason) {
+ super.onWebSocketClose(session, statusCode, reason);
+ }
+
+ @OnWebSocketError
+ public void onWebSocketError(Session session, Throwable throwable) {
+ /* We ignore errors for now, but method here to silence log spam */
+ }
+
+ @OnWebSocketMessage
+ public void onWebSocketMessage(Session session, String message) {
+ LOGGER.info("onWebSocketMessage: message = " + message);
+ }
+
+ private void sendUnsignedFeeEvent(Session session, UnsignedFeeEvent unsignedFeeEvent) {
+ StringWriter stringWriter = new StringWriter();
+
+ try {
+ marshall(stringWriter, unsignedFeeEvent);
+
+ session.getRemote().sendStringByFuture(stringWriter.toString());
+ } catch (IOException | WebSocketException e) {
+ // No output this time
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataDigest.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataDigest.java
index b07fd396..004bda6e 100644
--- a/src/main/java/org/qortal/arbitrary/ArbitraryDataDigest.java
+++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataDigest.java
@@ -4,6 +4,7 @@ import org.qortal.repository.DataException;
import org.qortal.utils.Base58;
import java.io.IOException;
+import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.Files;
@@ -25,47 +26,53 @@ public class ArbitraryDataDigest {
}
public void compute() throws IOException, DataException {
- List allPaths = Files.walk(path).filter(Files::isRegularFile).sorted().collect(Collectors.toList());
+ List allPaths = Files.walk(path)
+ .filter(Files::isRegularFile)
+ .sorted()
+ .collect(Collectors.toList());
+
Path basePathAbsolute = this.path.toAbsolutePath();
-
+
MessageDigest sha256;
try {
sha256 = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new DataException("SHA-256 hashing algorithm unavailable");
}
-
+
for (Path path : allPaths) {
// We need to work with paths relative to the base path, to ensure the same hash
// is generated on different systems
Path relativePath = basePathAbsolute.relativize(path.toAbsolutePath());
-
+
// Exclude Qortal folder since it can be different each time
// We only care about hashing the actual user data
if (relativePath.startsWith(".qortal/")) {
continue;
}
-
+
// Account for \ VS / : Linux VS Windows
String pathString = relativePath.toString();
-
- if(relativePath.getFileSystem().toString().contains("Windows")) {
- pathString = pathString.replace("\\","/");
+ if (relativePath.getFileSystem().toString().contains("Windows")) {
+ pathString = pathString.replace("\\", "/");
}
-
+
// Hash path
byte[] filePathBytes = pathString.getBytes(StandardCharsets.UTF_8);
- System.out.printf("Path: %s \n", pathString);
- System.out.printf("Path Byte Array: %s \n", Arrays.toString(filePathBytes));
sha256.update(filePathBytes);
-
- // Hash contents
- byte[] fileContent = Files.readAllBytes(path);
- System.out.printf("File Content: %s \n", Arrays.toString(fileContent));
- sha256.update(fileContent);
+
+ try (InputStream in = Files.newInputStream(path)) {
+ byte[] buffer = new byte[65536]; // 64 KB
+ int bytesRead;
+ while ((bytesRead = in.read(buffer)) != -1) {
+ sha256.update(buffer, 0, bytesRead);
+ }
+ }
}
+
this.hash = sha256.digest();
}
+
public boolean isHashValid(byte[] hash) {
return Arrays.equals(hash, this.hash);
diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
index 6e1ca0b9..96bdcdb5 100644
--- a/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
+++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataFile.java
@@ -52,7 +52,7 @@ public class ArbitraryDataFile {
private static final Logger LOGGER = LogManager.getLogger(ArbitraryDataFile.class);
- public static final long MAX_FILE_SIZE = 500 * 1024 * 1024; // 500MiB
+ public static final long MAX_FILE_SIZE = 2L * 1024 * 1024 * 1024; // 2 GiB
protected static final int MAX_CHUNK_SIZE = 1 * 1024 * 1024; // 1MiB
public static final int CHUNK_SIZE = 512 * 1024; // 0.5MiB
public static int SHORT_DIGEST_LENGTH = 8;
diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java
index eb51e8a4..e1794edf 100644
--- a/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java
+++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataRenderer.java
@@ -37,6 +37,7 @@ public class ArbitraryDataRenderer {
private final Service service;
private final String identifier;
private String theme = "light";
+ private String lang = "en";
private String inPath;
private final String secret58;
private final String prefix;
@@ -166,9 +167,9 @@ public class ArbitraryDataRenderer {
if (HTMLParser.isHtmlFile(filename)) {
// HTML file - needs to be parsed
byte[] data = Files.readAllBytes(filePath); // TODO: limit file size that can be read into memory
- HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, includeResourceIdInPrefix, data, qdnContext, service, identifier, theme, usingCustomRouting);
+ HTMLParser htmlParser = new HTMLParser(resourceId, inPath, prefix, includeResourceIdInPrefix, data, qdnContext, service, identifier, theme, usingCustomRouting, lang);
htmlParser.addAdditionalHeaderTags();
- response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; font-src 'self' data:; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' wss:;");
+ response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; font-src 'self' data:; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' wss: blob:;");
response.setContentType(context.getMimeType(filename));
response.setContentLength(htmlParser.getData().length);
response.getOutputStream().write(htmlParser.getData());
@@ -256,5 +257,8 @@ public class ArbitraryDataRenderer {
public void setTheme(String theme) {
this.theme = theme;
}
+ public void setLang(String lang) {
+ this.lang = lang;
+ }
}
diff --git a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java
index a77442ec..b40f72c7 100644
--- a/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java
+++ b/src/main/java/org/qortal/arbitrary/ArbitraryDataTransactionBuilder.java
@@ -29,6 +29,7 @@ import org.qortal.utils.FilesystemUtils;
import org.qortal.utils.NTP;
import java.io.IOException;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
@@ -197,7 +198,7 @@ public class ArbitraryDataTransactionBuilder {
// We can't use PATCH for on-chain data because this requires the .qortal directory, which can't be put on chain
final boolean isSingleFileResource = FilesystemUtils.isSingleFileResource(this.path, false);
- final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(FilesystemUtils.getSingleFileContents(path).length) <= ArbitraryTransaction.MAX_DATA_SIZE);
+ final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(Files.size(path)) <= ArbitraryTransaction.MAX_DATA_SIZE);
if (shouldUseOnChainData) {
LOGGER.info("Data size is small enough to go on chain - using PUT");
return Method.PUT;
@@ -245,7 +246,7 @@ public class ArbitraryDataTransactionBuilder {
// Single file resources are handled differently, especially for very small data payloads, as these go on chain
final boolean isSingleFileResource = FilesystemUtils.isSingleFileResource(path, false);
- final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(FilesystemUtils.getSingleFileContents(path).length) <= ArbitraryTransaction.MAX_DATA_SIZE);
+ final boolean shouldUseOnChainData = (isSingleFileResource && AES.getEncryptedFileSize(Files.size(path)) <= ArbitraryTransaction.MAX_DATA_SIZE);
// Use zip compression if data isn't going on chain
Compression compression = shouldUseOnChainData ? Compression.NONE : Compression.ZIP;
diff --git a/src/main/java/org/qortal/arbitrary/misc/Service.java b/src/main/java/org/qortal/arbitrary/misc/Service.java
index e154f001..21c027c4 100644
--- a/src/main/java/org/qortal/arbitrary/misc/Service.java
+++ b/src/main/java/org/qortal/arbitrary/misc/Service.java
@@ -62,7 +62,17 @@ public enum Service {
// Custom validation function to require an index HTML file in the root directory
List fileNames = ArbitraryDataRenderer.indexFiles();
- String[] files = path.toFile().list();
+ List files;
+
+ // single files are paackaged differently
+ if( path.toFile().isFile() ) {
+ files = new ArrayList<>(1);
+ files.add(path.getFileName().toString());
+ }
+ else {
+ files = new ArrayList<>(Arrays.asList(path.toFile().list()));
+ }
+
if (files != null) {
for (String file : files) {
Path fileName = Paths.get(file).getFileName();
diff --git a/src/main/java/org/qortal/block/Block.java b/src/main/java/org/qortal/block/Block.java
index 67e6dd43..1e07f2e7 100644
--- a/src/main/java/org/qortal/block/Block.java
+++ b/src/main/java/org/qortal/block/Block.java
@@ -1640,6 +1640,8 @@ public class Block {
SelfSponsorshipAlgoV2Block.processAccountPenalties(this);
} else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV3Height()) {
SelfSponsorshipAlgoV3Block.processAccountPenalties(this);
+ } else if (this.blockData.getHeight() == BlockChain.getInstance().getMultipleNamesPerAccountHeight()) {
+ PrimaryNamesBlock.processNames(this.repository);
}
}
}
@@ -1721,11 +1723,19 @@ public class Block {
accountData.setBlocksMinted(accountData.getBlocksMinted() + 1);
LOGGER.trace(() -> String.format("Block minter %s up to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : "")));
- final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty();
+ int blocksMintedAdjustment
+ =
+ (this.blockData.getHeight() > BlockChain.getInstance().getMintedBlocksAdjustmentRemovalHeight())
+ ?
+ 0
+ :
+ accountData.getBlocksMintedAdjustment();
+
+ final int effectiveBlocksMinted = accountData.getBlocksMinted() + blocksMintedAdjustment + accountData.getBlocksMintedPenalty();
for (int newLevel = maximumLevel; newLevel >= 0; --newLevel)
if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) {
- if (newLevel > accountData.getLevel()) {
+ if (newLevel != accountData.getLevel()) {
// Account has increased in level!
accountData.setLevel(newLevel);
bumpedAccounts.put(accountData.getAddress(), newLevel);
@@ -1952,6 +1962,8 @@ public class Block {
SelfSponsorshipAlgoV2Block.orphanAccountPenalties(this);
} else if (this.blockData.getHeight() == BlockChain.getInstance().getSelfSponsorshipAlgoV3Height()) {
SelfSponsorshipAlgoV3Block.orphanAccountPenalties(this);
+ } else if (this.blockData.getHeight() == BlockChain.getInstance().getMultipleNamesPerAccountHeight()) {
+ PrimaryNamesBlock.orphanNames( this.repository );
}
}
@@ -2127,11 +2139,19 @@ public class Block {
accountData.setBlocksMinted(accountData.getBlocksMinted() - 1);
LOGGER.trace(() -> String.format("Block minter %s down to %d minted block%s", accountData.getAddress(), accountData.getBlocksMinted(), (accountData.getBlocksMinted() != 1 ? "s" : "")));
- final int effectiveBlocksMinted = accountData.getBlocksMinted() + accountData.getBlocksMintedAdjustment() + accountData.getBlocksMintedPenalty();
+ int blocksMintedAdjustment
+ =
+ (this.blockData.getHeight() -1 > BlockChain.getInstance().getMintedBlocksAdjustmentRemovalHeight())
+ ?
+ 0
+ :
+ accountData.getBlocksMintedAdjustment();
+
+ final int effectiveBlocksMinted = accountData.getBlocksMinted() + blocksMintedAdjustment + accountData.getBlocksMintedPenalty();
for (int newLevel = maximumLevel; newLevel >= 0; --newLevel)
if (effectiveBlocksMinted >= cumulativeBlocksByLevel.get(newLevel)) {
- if (newLevel < accountData.getLevel()) {
+ if (newLevel != accountData.getLevel()) {
// Account has decreased in level!
accountData.setLevel(newLevel);
repository.getAccountRepository().setLevel(accountData);
diff --git a/src/main/java/org/qortal/block/BlockChain.java b/src/main/java/org/qortal/block/BlockChain.java
index bce09aed..1349383d 100644
--- a/src/main/java/org/qortal/block/BlockChain.java
+++ b/src/main/java/org/qortal/block/BlockChain.java
@@ -92,7 +92,9 @@ public class BlockChain {
adminsReplaceFoundersHeight,
nullGroupMembershipHeight,
ignoreLevelForRewardShareHeight,
- adminQueryFixHeight
+ adminQueryFixHeight,
+ multipleNamesPerAccountHeight,
+ mintedBlocksAdjustmentRemovalHeight
}
// Custom transaction fees
@@ -112,7 +114,8 @@ public class BlockChain {
/** Whether to use legacy, broken RIPEMD160 implementation when converting public keys to addresses. */
private boolean useBrokenMD160ForAddresses = false;
- /** Whether only one registered name is allowed per account. */
+ /** This should get ignored and overwritten in the oneNamePerAccount(int blockchainHeight) method,
+ * because it is based on block height, not based on the genesis block.*/
private boolean oneNamePerAccount = false;
/** Checkpoints */
@@ -474,8 +477,9 @@ public class BlockChain {
return this.useBrokenMD160ForAddresses;
}
- public boolean oneNamePerAccount() {
- return this.oneNamePerAccount;
+ public boolean oneNamePerAccount(int blockchainHeight) {
+ // this is not set on a simple blockchain setting, it is based on a feature trigger height
+ return blockchainHeight < this.getMultipleNamesPerAccountHeight();
}
public List getCheckpoints() {
@@ -688,6 +692,14 @@ public class BlockChain {
return this.featureTriggers.get(FeatureTrigger.adminQueryFixHeight.name()).intValue();
}
+ public int getMultipleNamesPerAccountHeight() {
+ return this.featureTriggers.get(FeatureTrigger.multipleNamesPerAccountHeight.name()).intValue();
+ }
+
+ public int getMintedBlocksAdjustmentRemovalHeight() {
+ return this.featureTriggers.get(FeatureTrigger.mintedBlocksAdjustmentRemovalHeight.name()).intValue();
+ }
+
// More complex getters for aspects that change by height or timestamp
public long getRewardAtHeight(int ourHeight) {
diff --git a/src/main/java/org/qortal/block/PrimaryNamesBlock.java b/src/main/java/org/qortal/block/PrimaryNamesBlock.java
new file mode 100644
index 00000000..3dd21344
--- /dev/null
+++ b/src/main/java/org/qortal/block/PrimaryNamesBlock.java
@@ -0,0 +1,47 @@
+package org.qortal.block;
+
+import org.qortal.account.Account;
+import org.qortal.api.resource.TransactionsResource;
+import org.qortal.data.naming.NameData;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * Class PrimaryNamesBlock
+ */
+public class PrimaryNamesBlock {
+
+ /**
+ * Process Primary Names
+ *
+ * @param repository
+ * @throws DataException
+ */
+ public static void processNames(Repository repository) throws DataException {
+
+ Set addressesWithNames
+ = repository.getNameRepository().getAllNames().stream()
+ .map(NameData::getOwner).collect(Collectors.toSet());
+
+ // for each address with a name, set primary name to the address
+ for( String address : addressesWithNames ) {
+
+ Account account = new Account(repository, address);
+ account.resetPrimaryName(TransactionsResource.ConfirmationStatus.CONFIRMED);
+ }
+ }
+
+ /**
+ * Orphan the Primary Names Block
+ *
+ * @param repository
+ * @throws DataException
+ */
+ public static void orphanNames(Repository repository) throws DataException {
+
+ repository.getNameRepository().clearPrimaryNames();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java
index 22f7e2d2..31337b42 100644
--- a/src/main/java/org/qortal/controller/Controller.java
+++ b/src/main/java/org/qortal/controller/Controller.java
@@ -46,6 +46,7 @@ import org.qortal.utils.*;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
+
import java.awt.TrayIcon.MessageType;
import java.io.File;
import java.io.FileNotFoundException;
@@ -53,6 +54,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.UnknownHostException;
+import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
@@ -70,11 +72,10 @@ import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
+import java.util.stream.Stream;
public class Controller extends Thread {
- public static HSQLDBRepositoryFactory REPOSITORY_FACTORY;
-
static {
// This must go before any calls to LogManager/Logger
System.setProperty("log4j2.formatMsgNoLookups", "true");
@@ -396,6 +397,9 @@ public class Controller extends Thread {
Controller.newInstance(args);
+
+ cleanChunkUploadTempDir(); // cleanup leftover chunks from streaming to disk
+
LOGGER.info("Starting NTP");
Long ntpOffset = Settings.getInstance().getTestNtpOffset();
if (ntpOffset != null)
@@ -405,8 +409,8 @@ public class Controller extends Thread {
LOGGER.info("Starting repository");
try {
- REPOSITORY_FACTORY = new HSQLDBRepositoryFactory(getRepositoryUrl());
- RepositoryManager.setRepositoryFactory(REPOSITORY_FACTORY);
+ HSQLDBRepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(getRepositoryUrl());
+ RepositoryManager.setRepositoryFactory(repositoryFactory);
RepositoryManager.setRequestedCheckpoint(Boolean.TRUE);
try (final Repository repository = RepositoryManager.getRepository()) {
@@ -560,6 +564,9 @@ public class Controller extends Thread {
LOGGER.info("Starting online accounts manager");
OnlineAccountsManager.getInstance().start();
+ LOGGER.info("Starting foreign fees manager");
+ ForeignFeesManager.getInstance().start();
+
LOGGER.info("Starting transaction importer");
TransactionImporter.getInstance().start();
@@ -1130,6 +1137,9 @@ public class Controller extends Thread {
LOGGER.info("Shutting down online accounts manager");
OnlineAccountsManager.getInstance().shutdown();
+ LOGGER.info("Shutting down foreign fees manager");
+ ForeignFeesManager.getInstance().shutdown();
+
LOGGER.info("Shutting down transaction importer");
TransactionImporter.getInstance().shutdown();
@@ -1474,6 +1484,14 @@ public class Controller extends Thread {
OnlineAccountsManager.getInstance().onNetworkOnlineAccountsV3Message(peer, message);
break;
+ case GET_FOREIGN_FEES:
+ ForeignFeesManager.getInstance().onNetworkGetForeignFeesMessage(peer, message);
+ break;
+
+ case FOREIGN_FEES:
+ ForeignFeesManager.getInstance().onNetworkForeignFeesMessage(peer, message);
+ break;
+
case GET_ARBITRARY_DATA:
// Not currently supported
break;
@@ -2160,6 +2178,24 @@ public class Controller extends Thread {
return now - offset;
}
+ private static void cleanChunkUploadTempDir() {
+ Path uploadsTemp = Paths.get("uploads-temp");
+ if (!Files.exists(uploadsTemp)) {
+ return;
+ }
+
+ try (Stream paths = Files.walk(uploadsTemp)) {
+ paths.sorted(Comparator.reverseOrder())
+ .map(Path::toFile)
+ .forEach(File::delete);
+
+ LOGGER.info("Cleaned up all temporary uploads in {}", uploadsTemp);
+ } catch (IOException e) {
+ LOGGER.warn("Failed to clean up uploads-temp directory", e);
+ }
+ }
+
+
public StatsSnapshot getStatsSnapshot() {
return this.stats;
}
diff --git a/src/main/java/org/qortal/controller/ForeignFeesManager.java b/src/main/java/org/qortal/controller/ForeignFeesManager.java
new file mode 100644
index 00000000..3763b7b3
--- /dev/null
+++ b/src/main/java/org/qortal/controller/ForeignFeesManager.java
@@ -0,0 +1,1202 @@
+package org.qortal.controller;
+
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.bitcoinj.core.Coin;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.qortal.controller.tradebot.TradeBot;
+import org.qortal.controller.tradebot.TradeStates;
+import org.qortal.crosschain.ACCT;
+import org.qortal.crosschain.AcctMode;
+import org.qortal.crosschain.Bitcoiny;
+import org.qortal.crosschain.ForeignBlockchain;
+import org.qortal.crosschain.SupportedBlockchain;
+import org.qortal.crypto.Crypto;
+import org.qortal.data.at.ATData;
+import org.qortal.data.crosschain.CrossChainTradeData;
+import org.qortal.data.crosschain.ForeignFeeData;
+import org.qortal.data.crosschain.TradeBotData;
+import org.qortal.data.crosschain.ForeignFeeDecodedData;
+import org.qortal.data.crosschain.ForeignFeeEncodedData;
+import org.qortal.event.Event;
+import org.qortal.event.EventBus;
+import org.qortal.event.FeeWaitingEvent;
+import org.qortal.event.LockingFeeUpdateEvent;
+import org.qortal.event.RequiredFeeUpdateEvent;
+import org.qortal.event.Listener;
+import org.qortal.network.Network;
+import org.qortal.network.Peer;
+import org.qortal.network.message.ForeignFeesMessage;
+import org.qortal.network.message.GetForeignFeesMessage;
+import org.qortal.network.message.Message;
+import org.qortal.repository.DataException;
+import org.qortal.repository.Repository;
+import org.qortal.repository.RepositoryManager;
+import org.qortal.repository.hsqldb.HSQLDBImportExport;
+import org.qortal.settings.Settings;
+import org.qortal.utils.Base58;
+import org.qortal.utils.ForeignFeesMessageUtils;
+import org.qortal.utils.NTP;
+import org.qortal.utils.NamedThreadFactory;
+import org.qortal.utils.Triple;
+
+import java.io.FileNotFoundException;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+public class ForeignFeesManager implements Listener {
+
+ private static final Logger LOGGER = LogManager.getLogger(ForeignFeesManager.class);
+
+ public static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ public static final String SIGNED_FOREIGN_FEES_TYPE = "signedForeignFees";
+ public static final String SIGNED_FOREIGN_FEES_FILE_NAME = "SignedForeignFees.json";
+ public static final String CURRENT_DATASET_LABEL = "current";
+ private static final String REQUIRED_FOREIGN_FEES_TYPE = "requiredForeignFees";
+ private static final String LOCKING_FOREIGN_FEES_TYPE = "lockingForeignFees";
+ public static final String REQUIRED_FOREIGN_FEES_FILE_NAME = "RequiredForeignFees.json";
+ public static final String LOCKING_FOREIGN_FEES_FILE_NAME = "LockingForeignFees.json";
+
+ private final ScheduledExecutorService executor
+ = Executors.newScheduledThreadPool(4, new NamedThreadFactory("Foreign Fee Manager", Thread.NORM_PRIORITY));
+
+ private volatile boolean isStopping = false;
+
+ private final Set foreignFeesImportQueue = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Cache of signed foreign fees, keyed by AT address
+ */
+ private final ConcurrentHashMap> signedByAT = new ConcurrentHashMap<>();
+
+ /**
+ * Cache of unsigned foreign fees on this node, key by AT address
+ */
+ private final ConcurrentHashMap unsignedByAT = new ConcurrentHashMap<>();
+
+ /**
+ * Cache of trade offers, keyed by creator address
+ */
+ private final ConcurrentHashMap> offersByAddress = new ConcurrentHashMap<>();
+
+ /**
+ * Need to Backup Locking Foreign Fees?
+ *
+ * Set when the locking foreign fees need to be backed up to a file.
+ */
+ private AtomicBoolean needToBackupLockingForeignFees = new AtomicBoolean(true);
+
+ /**
+ * Need to Backup Required Foreign Fees?
+ *
+ * Set when the required foreign fees need to be backed up to a file.
+ */
+ private AtomicBoolean needToBackupRequiredForeignFees = new AtomicBoolean(true);
+
+ /**
+ * Need to Backup Signed Foreign Fees?
+ *
+ * Set when the signed foreign fees for this node need to be backed up to a file.
+ */
+ private AtomicBoolean needToBackupSignedForeignFees = new AtomicBoolean(true);
+
+ private ForeignFeesManager() {
+
+ EventBus.INSTANCE.addListener(this);
+ }
+
+ /**
+ * Import Data
+ *
+ * Import signed transaction data for this node and the required fees for unlocking foreign trade funds
+ * for this node.
+ */
+ private void importData() {
+
+ try {
+ String exportPath = Settings.getInstance().getExportPath();
+
+ // import signed foreign fees
+ try {
+ Path importSignedForeignFeesPath = Paths.get(exportPath, SIGNED_FOREIGN_FEES_FILE_NAME);
+ importDataFromFile(importSignedForeignFeesPath.toString());
+ }
+ catch (FileNotFoundException e) {
+ LOGGER.warn(e.getMessage());
+ }
+
+ // import required foreign fees
+ try {
+ Path importRequiredForeignFeespath = Paths.get(exportPath, REQUIRED_FOREIGN_FEES_FILE_NAME);
+ importDataFromFile(importRequiredForeignFeespath.toString());
+ }
+ catch (FileNotFoundException e) {
+ LOGGER.warn(e.getMessage());
+ }
+
+ // import locking foreign fees
+ try {
+ Path importLockingForeignFeespath = Paths.get(exportPath, LOCKING_FOREIGN_FEES_FILE_NAME);
+ importDataFromFile(importLockingForeignFeespath.toString());
+ }
+ catch (FileNotFoundException e) {
+ LOGGER.warn(e.getMessage());
+ }
+
+ }
+ catch (DataException | IOException e) {
+ LOGGER.debug("Unable to import data into foreign fees manager: {}", e.getMessage());
+ }
+ catch (Exception e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Get Unsigned Fees For Address
+ *
+ * @param address the address
+ *
+ * @return the unsigned fee data
+ */
+ public List getUnsignedFeesForAddress(String address) {
+
+ // the trade offers for this address on this node
+ List atAddressesForOffers
+ = this.offersByAddress.getOrDefault(address, new ArrayList<>(0)).stream()
+ .map( data -> data.qortalAtAddress )
+ .collect(Collectors.toList());
+
+ // the unsigned fee data for the address's trade offers
+ return this.unsignedByAT.entrySet().stream()
+ .filter( entry -> atAddressesForOffers.contains(entry.getKey()))
+ .map( entry -> entry.getValue())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Get Signed Fees
+ *
+ * @return the signed fee data on this node
+ */
+ public List getSignedFees() {
+
+ return this.signedByAT.values().stream()
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Add Signed Fees
+ *
+ * Add signed fees to the import queue.
+ *
+ * @param signedFees the signed fees
+ */
+ public void addSignedFees(List signedFees) {
+
+ LOGGER.debug("adding signed fees: count = " + signedFees.size());
+
+ // for each encoided fee, decode and add to import queue
+ for( ForeignFeeEncodedData signedFeeEncoded : signedFees ) {
+
+ LOGGER.debug("adding to import queue: " + signedFeeEncoded);
+
+ // decode the fee data and add to the queue
+ this.foreignFeesImportQueue.add(
+ new ForeignFeeDecodedData(
+ signedFeeEncoded.getTimestamp(),
+ Base58.decode(signedFeeEncoded.getData()),
+ signedFeeEncoded.getAtAddress(),
+ signedFeeEncoded.getFee()
+ )
+ );
+ LOGGER.debug("added");
+ }
+
+ LOGGER.debug("done adding to queue: count = " + this.foreignFeesImportQueue.size());
+
+ // process the fees immediately (not waiting for the fee process timer task already in place)
+ processForeignFeesImportQueue();
+ }
+
+ @Override
+ public void listen(Event event) {
+
+ // locking fee update, then flag locking fee backup
+ if( event instanceof LockingFeeUpdateEvent) {
+ this.needToBackupLockingForeignFees.compareAndSet(false, true);
+ }
+ // if required fee update, then flag required fee update and process fees for the coin updated
+ else if( event instanceof RequiredFeeUpdateEvent) {
+
+ this.needToBackupRequiredForeignFees.compareAndSet(false, true);
+
+ for( String address : processLocalForeignFeesForCoin(((RequiredFeeUpdateEvent) event).getBitcoiny()) ) {
+ EventBus.INSTANCE.notify(new FeeWaitingEvent(true, address));
+ }
+ }
+ //
+ else if( event instanceof TradeBot.StateChangeEvent ) {
+
+ TradeBotData data = ((TradeBot.StateChangeEvent) event).getTradeBotData();
+
+ // if offer is waiting and the time now is determined,
+ // then process the trade data to be signed later
+ if( data.getStateValue() == TradeStates.State.BOB_WAITING_FOR_MESSAGE.value ) {
+ Optional nowDetermined = determineNow();
+ if (nowDetermined.isPresent()) {
+
+ long now = nowDetermined.get();
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ LOGGER.debug("processing trade offer in waiting event");
+
+ Optional offerOptional = getTradeOfferData(repository, data.getAtAddress());
+
+ if( offerOptional.isPresent() ) {
+ CrossChainTradeData offer = offerOptional.get();
+ this.offersByAddress.computeIfAbsent( offer.qortalCreator, x -> new ArrayList<>()).add(offer);
+
+ if( processTradeOfferInWaiting(now, data) ) {
+ EventBus.INSTANCE.notify(new FeeWaitingEvent(true, data.getCreatorAddress()));
+ }
+ }
+ else {
+ LOGGER.warn("offer not present for new trade bot offer = " + data);
+ }
+ } catch (IOException | DataException e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ }
+ }
+ }
+ }
+
+ private static class SingletonContainer {
+ private static final ForeignFeesManager INSTANCE = new ForeignFeesManager();
+ }
+
+ public static ForeignFeesManager getInstance() {
+ return SingletonContainer.INSTANCE;
+ }
+
+ /**
+ * Get Signed Foreign Fee Data By AT Address
+ *
+ * @return st address -> signed foreign fee data
+ */
+ public ConcurrentHashMap> getSignedByAT() {
+ return signedByAT;
+ }
+
+ /**
+ * Start Manager
+ */
+ public void start() {
+
+ // import data after a 1 minute delay
+ // this will get locking fees, required fees and signed data
+ // there will be nothing to import the first time running this manager
+ executor.schedule(this::importData, 1, TimeUnit.MINUTES);
+
+ // process local foreign fees for all coins once after a 2 minute delay
+ // this will get the unsigned fee data
+ executor.schedule(this::processLocalForeignFeesForAll, 2, TimeUnit.MINUTES);
+
+ // maintain AT's every 5 minutes
+ executor.scheduleAtFixedRate(this::maintainCrossChainOffers, 3, 5, TimeUnit.MINUTES);
+
+ // request foreign fees from peers every 5 minutes
+ executor.scheduleAtFixedRate(this::requestRemoteForeignFees, 4, 5, TimeUnit.MINUTES);
+
+ // process imported foreign fees every 5 minutes
+ executor.scheduleWithFixedDelay(this::processForeignFeesImportQueue, 5, 5, TimeUnit.MINUTES);
+
+ // backup data every 5 minutes
+ executor.scheduleAtFixedRate(this::backup, 6, 5, TimeUnit.MINUTES);
+ }
+
+ /**
+ * Backup
+ *
+ * Backup member data used by this manager.
+ */
+ private void backup() {
+
+ try {
+ if( this.needToBackupLockingForeignFees.compareAndSet( true, false )) {
+ LOGGER.debug("backing up locking foreign fees");
+ backupForeignFeeData( bitcoiny -> bitcoiny.getFeePerKb().value, LOCKING_FOREIGN_FEES_FILE_NAME, LOCKING_FOREIGN_FEES_TYPE);
+ }
+ if( this.needToBackupRequiredForeignFees.compareAndSet(true, false) ) {
+ LOGGER.debug("backing up required foreign fees");
+ backupForeignFeeData(Bitcoiny::getFeeRequired, REQUIRED_FOREIGN_FEES_FILE_NAME, REQUIRED_FOREIGN_FEES_TYPE);
+ }
+
+ if( this.needToBackupSignedForeignFees.compareAndSet( true, false ) ) {
+ LOGGER.debug("backing up signed foreign fees");
+ backupSignedForeignFeeData();
+ }
+ } catch (DataException e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Shutdown Manager
+ */
+ public void shutdown() {
+ isStopping = true;
+ executor.shutdownNow();
+ }
+
+ /**
+ * Process Import Queue
+ */
+ private void processForeignFeesImportQueue() {
+
+ LOGGER.debug("processing foreign fees import queue ...");
+
+ if (this.foreignFeesImportQueue.isEmpty()) {
+
+ LOGGER.debug("foreign fees import queue is empty");
+ return;
+ }
+
+ LOGGER.debug("Processing foreign fee import queue (size: {})", this.foreignFeesImportQueue.size());
+
+ Set foreignFeesToRemove = new HashSet<>(this.foreignFeesImportQueue.size());
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+
+ // for each signed foreign fee in the queue,
+ // compare timestamps to prior imports, verify signature and possibly import in
+ for (ForeignFeeDecodedData foreignFeeToImport : this.foreignFeesImportQueue) {
+ if (isStopping)
+ return;
+
+ // need to get the AT address for mapping key identification purposes
+ String atAddress = foreignFeeToImport.getAtAddress();
+
+ LOGGER.debug("foreign fee import, timestamp = " + getFormattedDateTime(foreignFeeToImport.getTimestamp()));
+
+ Optional validatedForeignFeeData
+ = this.signedByAT.getOrDefault( atAddress, Optional.empty() );
+
+ // if there is no established, validated foreign fee for this AT address or
+ // if the import foreign fee is after the validated foreign fee,
+ // then verify the signature and map it to the AT address
+ if (validatedForeignFeeData.isEmpty() || validatedForeignFeeData.get().getTimestamp() < foreignFeeToImport.getTimestamp()) {
+
+ ATData atData = repository.getATRepository().fromATAddress(atAddress);
+
+ LOGGER.debug("verify signer for atAddress = " + atAddress);
+
+ // determine if the creator authorized the foreign fee
+ byte[] publicKey = atData.getCreatorPublicKey();
+ byte[] signature = foreignFeeToImport.getData();
+ byte[] message
+ = ForeignFeesMessageUtils.buildForeignFeesDataMessage(
+ foreignFeeToImport.getTimestamp(),
+ atAddress,
+ foreignFeeToImport.getFee()
+ );
+
+ // if trade offer creator authorized the imported fee,
+ // then finish the import and clear it from the unsigned mapping
+ if( Crypto.verify(publicKey, signature, message) ) {
+ LOGGER.debug("signer verified");
+ this.signedByAT.put(atAddress, Optional.of(foreignFeeToImport));
+ this.needToBackupSignedForeignFees.compareAndSet(false, true);
+ this.unsignedByAT.remove(atAddress);
+
+ String tradeOfferCreatorAddress = Crypto.toAddress(publicKey);
+ boolean allSignedForCreatorAddress
+ = this.offersByAddress
+ .getOrDefault(tradeOfferCreatorAddress, new ArrayList<>(0)).stream()
+ .map(data -> data.qortalAtAddress)
+ .filter(qortalAtAddress -> this.unsignedByAT.contains(qortalAtAddress))
+ .findAny()
+ .isEmpty();
+
+ LOGGER.debug("tradeOfferCreatorAddress = " + tradeOfferCreatorAddress);
+ LOGGER.debug("allSignedForCreatorAddress = " + allSignedForCreatorAddress);
+
+ if(allSignedForCreatorAddress) {
+ EventBus.INSTANCE.notify(new FeeWaitingEvent(false, tradeOfferCreatorAddress));
+ }
+ }
+ // otherwise this fee will get discarded
+ else {
+ LOGGER.debug("invalid signature");
+ }
+ }
+ else {
+ LOGGER.debug(
+ "skipping imported fee since the timestamp is not updated: atAddress = {}, timestamp = {}",
+ atAddress,
+ foreignFeeToImport.getTimestamp()
+ );
+ }
+
+ // now that this fee has been processed, remove it from the process queue
+ foreignFeesToRemove.add(foreignFeeToImport);
+ }
+ } catch (Exception e) {
+ LOGGER.error("Repository issue while verifying foreign fees", e);
+ } finally {
+ LOGGER.debug("removing foreign fees from import queue: count = " + foreignFeesToRemove.size());
+ this.foreignFeesImportQueue.removeAll(foreignFeesToRemove);
+ }
+ }
+
+ /**
+ * Get Formatted Date Time
+ *
+ * For logging purposes only.
+ *
+ * @param timestamp utc time in milliseconds
+ *
+ * @return the formatted string
+ */
+ private static String getFormattedDateTime( long timestamp ) {
+
+ Instant instant = Instant.ofEpochMilli(timestamp);
+ ZonedDateTime zdt = ZonedDateTime.ofInstant(instant, ZoneId.systemDefault());
+ String formattedDateTime = zdt.format(TIMESTAMP_FORMATTER);
+
+ return formattedDateTime;
+ }
+
+ /**
+ * Maintain AT's
+ *
+ * This removes fee data for AT addreses that are no longer offered on the trade portal.
+ */
+ private void maintainCrossChainOffers() {
+
+ LOGGER.debug("maintaining ATs ...");
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+
+ List crossChainTradeOffers = resetOffersByAddress(repository);
+
+ // remove failed trades, then collect AT addresses for trade offers
+ Set atAddresses
+ = TradeBot.getInstance()
+ .removeFailedTrades(repository, crossChainTradeOffers).stream()
+ .map( data -> data.qortalAtAddress )
+ .collect(Collectors.toSet());
+
+ LOGGER.debug("foreign fees before AT removal: count = " + this.signedByAT.size() );
+
+ // retain the fees for the current sell offers, remove all others
+ if( retainFeeByAT(this.signedByAT, atAddresses) ) {
+ this.needToBackupSignedForeignFees.compareAndSet(false, true);
+ }
+
+ LOGGER.debug("foreign fees after AT removal: count = " + this.signedByAT.size() );
+ } catch (Exception e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Reset Offers By Address
+ *
+ * @param repository the data repository
+ *
+ * @return address -> cross chain trades
+ *
+ * @throws DataException
+ */
+ private List resetOffersByAddress(Repository repository) throws DataException {
+ List crossChainTradeOffers = new ArrayList<>();
+
+ // lockdown map while reseting offers by address
+ synchronized( this.offersByAddress) {
+
+ // for each supported foreign blockchaine, get AT data for trade offers
+ for ( SupportedBlockchain blockchain : SupportedBlockchain.values()) {
+ crossChainTradeOffers.addAll( getCrossTradeOffers(repository, blockchain) );
+ }
+
+ // group all trade offers by trade offer creator, then reset map
+ Map> groupedOffersByAddress
+ = crossChainTradeOffers.stream().collect(Collectors.groupingBy(data -> data.qortalCreator));
+
+ this.offersByAddress.clear();
+ this.offersByAddress.putAll(groupedOffersByAddress);
+ }
+
+ return crossChainTradeOffers;
+ }
+
+ /**
+ * Retain Fee By AT
+ *
+ * Retain fees for a list of AT addresses; remove all others.
+ *
+ * @param feeByAT the foreign fee data for each trade offer AT address
+ * @param atAddresses the AT addresses to retain fees for
+ *
+ * @return true if any removals, otherwise false
+ */
+ private static boolean retainFeeByAT(ConcurrentHashMap> feeByAT, Set atAddresses) {
+
+ // return value, false until there is a removal
+ boolean anyRemovals = false;
+
+ // prepate iterator for all AT -> fee mappings
+ Iterator>> iterator = feeByAT.entrySet().iterator();
+
+ // iterate over all AT's mapped under management
+ while (iterator.hasNext()) {
+ Map.Entry> entry = iterator.next();
+
+ // if the at address do not contain this entry in the iteration,
+ // then remove it
+ if (!atAddresses.contains(entry.getKey())) {
+
+ iterator.remove();
+ anyRemovals = true;
+ }
+ }
+
+ return anyRemovals;
+ }
+
+ /**
+ * Get Cross Trade Offers
+ *
+ * @param repository the data repository
+ * @param blockchain the foreign blockchain supporting the trade
+ *
+ * @return the trade offers
+ *
+ * @throws DataException
+ */
+ private static List getCrossTradeOffers(Repository repository, SupportedBlockchain blockchain) throws DataException {
+
+ // get ACCT for the foreign blockchain
+ ACCT acct = blockchain.getLatestAcct();
+
+ // get AT's for foreign blockchain
+ List ats
+ = repository.getATRepository()
+ .getATsByFunctionality(acct.getCodeBytesHash(), true, null, null, null);
+
+ // prepare the return list of cross chain data
+ List crossChainTradeOffers = new ArrayList<>(ats.size());
+
+ // for each AT, get cross chain data and look for trade offer to add
+ for (ATData at : ats) {
+
+ CrossChainTradeData crossChainTrade = acct.populateTradeData(repository, at);
+
+ // if the trade is in offering mode, then add it to return list
+ if (crossChainTrade.mode == AcctMode.OFFERING) {
+ crossChainTradeOffers.add(crossChainTrade);
+ }
+ }
+
+ return crossChainTradeOffers;
+ }
+
+ private static Optional getTradeOfferData(Repository repository, String atAddress) throws DataException {
+
+ ATData atData = repository.getATRepository().fromATAddress(atAddress);
+
+ ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash());
+ CrossChainTradeData crossChainTrade = acct.populateTradeData(repository, atData);
+
+ // if the trade is in offering mode, then add it to return list
+ if (crossChainTrade.mode == AcctMode.OFFERING) {
+ return Optional.of(crossChainTrade);
+ }
+ else {
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Request data from other peers
+ */
+ private void requestRemoteForeignFees() {
+
+ LOGGER.debug("requesting remote foreign fees ...");
+
+ if (!isUpToDate()) return;
+
+ LOGGER.debug("Requesting foreign fees via broadcast...");
+
+ Message message
+ = new GetForeignFeesMessage(
+ this.signedByAT.values().stream()
+ .filter(Optional::isPresent).map(Optional::get)
+ .collect(Collectors.toList())
+ );
+
+ Network.getInstance().broadcast(peer -> message);
+
+ LOGGER.debug("Requested foreign fees via broadcast...");
+ }
+
+ /**
+ * Is Up To Date?
+ *
+ * @return true if up to date, otherwise false
+ */
+ private static boolean isUpToDate() {
+ final Long now = NTP.getTime();
+
+ if (now == null) {
+
+ LOGGER.warn("time is null, aborting");
+ return false;
+ }
+
+ if (!Controller.getInstance().isUpToDate()) {
+
+ LOGGER.debug("not up to date, aborting");
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * Process foreign fees for all coins
+ *
+ * Collect foreign fees for trades waiting locally and store to this manager.
+ *
+ * @return if any fee signatures are needed after this process
+ */
+ private void processLocalForeignFeesForAll() {
+
+ Set addressesThatNeedSignatures = new HashSet<>();
+
+ List names
+ = Arrays.stream(SupportedBlockchain.values())
+ .map( value -> value.getLatestAcct().getClass().getSimpleName())
+ .collect(Collectors.toList());
+
+ for( String name : names ) {
+ ForeignBlockchain blockchain = SupportedBlockchain.getAcctByName(name).getBlockchain();
+
+ if( blockchain instanceof Bitcoiny ) {
+ addressesThatNeedSignatures.addAll( processLocalForeignFeesForCoin((Bitcoiny) blockchain) );
+ }
+ }
+
+ for( String addressThatNeedsSignature : addressesThatNeedSignatures ) {
+ EventBus.INSTANCE.notify(new FeeWaitingEvent(true, addressThatNeedsSignature));
+ }
+ }
+
+ /**
+ * Process foreign fees for coin
+ *
+ * Collect foreign fees for trades waiting locally and store to this manager.
+ *
+ * @param bitcoiny the coin
+ *
+ * @return addresses that need fee signatures
+ */
+ private Set processLocalForeignFeesForCoin(final Bitcoiny bitcoiny) {
+
+ Set addressesThatNeedSignatures = new HashSet<>();
+
+ LOGGER.debug("processing local foreign fees ...");
+
+ Optional nowDetermined = determineNow();
+ if (nowDetermined.isEmpty()){
+ return new HashSet<>(0);
+ }
+
+ long now = nowDetermined.get();
+
+ try (final Repository repository = RepositoryManager.getRepository()) {
+ List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
+
+ // collect all local trade offers waiting
+ List tradeOffersWaiting
+ = allTradeBotData.stream()
+ .filter(d -> d.getStateValue() == TradeStates.State.BOB_WAITING_FOR_MESSAGE.value)
+ .filter(d -> SupportedBlockchain.getAcctByName( d.getAcctName() ).getBlockchain().equals( bitcoiny ))
+ .collect(Collectors.toList());
+
+ LOGGER.debug("trade offers waiting: count = " + tradeOffersWaiting.size());
+
+ // process each local trade offer waiting (listed)
+ for (TradeBotData tradeOfferWaiting : tradeOffersWaiting) {
+
+ // process trade offer first,
+ // then reset the fee signatures needed status next relative to prior status
+ if(processTradeOfferInWaiting(now, tradeOfferWaiting) ) {
+ addressesThatNeedSignatures.add(tradeOfferWaiting.getCreatorAddress());
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+
+ return addressesThatNeedSignatures;
+ }
+
+ /**
+ * Determine Now
+ *
+ * @return now if in synce, otherwise empty
+ */
+ private static Optional determineNow() {
+ // if time is not available, then abort
+ Long now = NTP.getTime();
+ if (now == null) {
+
+ LOGGER.warn("current time is not available, abort sending foreign fees");
+ return Optional.empty();
+ }
+
+ // if 2 hours behind w/o recovery mode, then abort
+ final Long minLatestBlockTimestamp = now - (2 * 60 * 60 * 1000L);
+ if (!Controller.getInstance().isUpToDate(minLatestBlockTimestamp) && !Synchronizer.getInstance().getRecoveryMode()) {
+
+ LOGGER.warn("out of sync, abort sending foreign fees");
+ return Optional.empty();
+ }
+ return Optional.of(now);
+ }
+
+ /**
+ * Process Trade Offer In Waiting
+ *
+ * @param now the time in millis for now
+ * @param tradeOfferWaiting the trade offer in waiting
+ *
+ * @return true if the fee for this offer needs to be signed, otherwise false
+ *
+ * @throws IOException
+ */
+ private boolean processTradeOfferInWaiting(Long now, TradeBotData tradeOfferWaiting) throws IOException {
+
+ boolean isFeeWaiting = false;
+
+ // derive the supported blockchain for the trade offer waiting
+ String foreignBlockchain = tradeOfferWaiting.getForeignBlockchain();
+ SupportedBlockchain supportedBlockchain = SupportedBlockchain.fromString(foreignBlockchain);
+
+ LOGGER.debug("trade offer waiting: blockchain = " + foreignBlockchain);
+
+ // if the supported blockchain is a Bitcoiny blockchain, then the fee will be available
+ if (supportedBlockchain.getInstance() instanceof Bitcoiny) {
+
+ // get the foreign blockcahin, the AT address and the foreign fee set to this node
+ Bitcoiny bitcoiny = (Bitcoiny) supportedBlockchain.getInstance();
+ String atAddress = tradeOfferWaiting.getAtAddress();
+ int fee = Math.toIntExact(bitcoiny.getFeeRequired());
+
+ LOGGER.debug("atAddress = {}, fee = {}", atAddress, fee);
+
+ // get the signed foreign fee, if it exists
+ Optional foreignFeeDecodedData = this.signedByAT.get(atAddress);
+
+ // if the foreign fee has been signed
+ if (foreignFeeDecodedData != null && foreignFeeDecodedData.isPresent()) {
+
+ LOGGER.debug("signed available");
+
+ // if the local fee is different than the fee stored in this manager,
+ // then empty the fee in the manager and set the updated fee to unsigned data
+ if (!foreignFeeDecodedData.get().getFee().equals(fee)) {
+
+ LOGGER.debug("fee updated");
+ this.signedByAT.remove(atAddress);
+
+ this.needToBackupSignedForeignFees.compareAndSet(false, true);
+ setUnsignedData(now, atAddress, fee);
+ isFeeWaiting = true;
+ }
+ else {
+ LOGGER.debug("fee not updated");
+ }
+ }
+ // if the foreign fee has not been signed, then set the fee to unsigned data
+ else {
+ LOGGER.debug("fee not signed");
+ setUnsignedData(now, atAddress, fee);
+ isFeeWaiting = true;
+ }
+ }
+ // if the supported blockchain is not a Bitcoiny blockchain, then the fee is not available
+ else {
+ LOGGER.warn("Blockchain fee not available: blockchain = " + foreignBlockchain);
+ }
+
+ return isFeeWaiting;
+ }
+
+ /**
+ * Set Unisgned Data
+ *
+ * @param timestamp
+ * @param atAddress
+ * @param fee
+ *
+ * @throws IOException
+ */
+ private void setUnsignedData(Long timestamp, String atAddress, int fee) throws IOException {
+ ForeignFeeEncodedData feeData
+ = new ForeignFeeEncodedData(
+ timestamp,
+ Base58.encode(ForeignFeesMessageUtils.buildForeignFeesDataMessage(timestamp, atAddress, fee)),
+ atAddress,
+ fee
+ );
+
+ LOGGER.debug("updating unsigned");
+ this.unsignedByAT.put(atAddress, feeData);
+ LOGGER.debug("updated unsigned = " + this.unsignedByAT);
+ }
+
+ // Network handlers
+
+ /**
+ * Handle GetForeignFeesMessage
+ *
+ * @param peer
+ * @param message
+ */
+ public void onNetworkGetForeignFeesMessage(Peer peer, Message message) {
+ GetForeignFeesMessage getForeignFeesMessage = (GetForeignFeesMessage) message;
+
+ // map the fees the peer already has
+ Map inMessageDataByAT
+ = getForeignFeesMessage.getForeignFeeData().stream()
+ .collect(Collectors.toMap( ForeignFeeDecodedData::getAtAddress, Function.identity()));
+
+ // start collecting fees to send to the peer
+ List outgoingForeignFees = new ArrayList<>();
+
+ // for all the signed fees locally stored, compare to what the peer currently has and send out what they need
+ for(Map.Entry> entry : this.signedByAT.entrySet() ) {
+
+ Optional signedForeignFeeData = entry.getValue();
+
+ // if the fee has been signed, then evaluate it for sending
+ if (signedForeignFeeData.isPresent()) {
+
+ String atAddress = entry.getKey();
+
+ LOGGER.debug("comparing signed foreign fee for get foreign fee message: atAddress = " + atAddress);
+
+ // if message contains AT address, then check timestamps
+ if (inMessageDataByAT.containsKey(atAddress) ) {
+
+ LOGGER.debug("message does contain: atAddress = " + atAddress);
+
+ // get data from message for AT address
+ ForeignFeeDecodedData feeData = inMessageDataByAT.get(atAddress);
+
+ // if message data is earlier than what is here locally, then send local out to the peer
+ if( feeData != null && signedForeignFeeData.get().getTimestamp() > feeData.getTimestamp()) {
+ outgoingForeignFees.add(signedForeignFeeData.get());
+ }
+ }
+ // if the message does not contain data for this AT, then send the data out to the peer
+ else {
+ LOGGER.debug("message does not contain: atAddress = " + atAddress);
+
+ outgoingForeignFees.add(signedForeignFeeData.get());
+ }
+ }
+ // if value is empty, then do nothing
+ else {
+ LOGGER.debug("unavailable signed foreign fee for get foreign fee message: atAddress = " + entry.getKey());
+ }
+ }
+
+ LOGGER.debug("Sending {} foreign fees to {}", outgoingForeignFees.size(), peer);
+
+ // send out to peer
+ peer.sendMessage(new ForeignFeesMessage(outgoingForeignFees));
+
+ LOGGER.debug("Sent {} foreign fees to {}", outgoingForeignFees.size(), peer);
+ }
+
+ /**
+ * Handle ForeignFeesMessage
+ *
+ * @param peer
+ * @param message
+ */
+ public void onNetworkForeignFeesMessage(Peer peer, Message message) {
+ ForeignFeesMessage onlineAccountsMessage = (ForeignFeesMessage) message;
+
+ List peersForeignFees = onlineAccountsMessage.getForeignFees();
+ LOGGER.debug("Received {} foreign fees from {}", peersForeignFees.size(), peer);
+
+ int importCount = 0;
+
+ // add any foreign fees to the queue that aren't already present
+ for (ForeignFeeDecodedData peerForeignFee : peersForeignFees) {
+
+ if( foreignFeesImportQueue.add(peerForeignFee) )
+ importCount++;
+ }
+
+ if (importCount > 0)
+ LOGGER.debug("Added {} foreign to queue", importCount);
+ }
+
+ /**
+ * Backup Signed Foreign Fee Data
+ *
+ * @throws DataException
+ */
+ private void backupSignedForeignFeeData() throws DataException {
+ try {
+ Path backupDirectory = HSQLDBImportExport.getExportDirectory(true);
+
+ // get all signed foreigh fee data on this node
+ List signedForeignFees
+ = this.signedByAT.values().stream()
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(Collectors.toList());
+
+ // get the JSON for the signed foreign fees
+ JSONArray currentSignedForeignFeeDataJson = new JSONArray();
+ for (ForeignFeeDecodedData signedForeignFee : signedForeignFees) {
+ JSONObject foreignFeeSignatureJson = signedForeignFee.toJson();
+ currentSignedForeignFeeDataJson.put(foreignFeeSignatureJson);
+ }
+
+ // put data into a JSON
+ JSONObject currentSignedForeignFeeDataJsonWrapper = new JSONObject();
+ currentSignedForeignFeeDataJsonWrapper.put("type", SIGNED_FOREIGN_FEES_TYPE);
+ currentSignedForeignFeeDataJsonWrapper.put("dataset", CURRENT_DATASET_LABEL);
+ currentSignedForeignFeeDataJsonWrapper.put("data", currentSignedForeignFeeDataJson);
+
+ // write signed fee data to backup file
+ String fileName = Paths.get(backupDirectory.toString(), SIGNED_FOREIGN_FEES_FILE_NAME).toString();
+ FileWriter writer = new FileWriter(fileName);
+ writer.write(currentSignedForeignFeeDataJsonWrapper.toString(2));
+ writer.close();
+
+ }
+ catch (DataException e) {
+ throw new DataException("Unable to export foreign fee signatures.");
+ }
+ catch (Exception e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Backup Foreign Fee Data
+ *
+ * @param feeGetter the fee from the Bitcoiny instance
+ * @param filename the backup file name
+ * @param type the type of fee label
+ *
+ * @throws DataException
+ */
+ private void backupForeignFeeData(Function feeGetter, String filename, String type) throws DataException {
+ try {
+ Path backupDirectory = HSQLDBImportExport.getExportDirectory(true);
+
+ // get the names of the supported blockchains
+ List names
+ = Arrays.stream(SupportedBlockchain.values())
+ .map( value -> value.getLatestAcct().getClass().getSimpleName())
+ .collect(Collectors.toList());
+
+ // start list of required foreign fees
+ List foreignFees = new ArrayList<>(names.size());
+
+ // for each blockchain name, get the blockchain and collect the foreign fee for this node
+ for( String name : names) {
+ ForeignBlockchain blockchain = SupportedBlockchain.getAcctByName(name).getBlockchain();
+
+ // if the blockchain supports fees, add the data to the list
+ if( blockchain instanceof Bitcoiny ) {
+ foreignFees.add( new ForeignFeeData(name, feeGetter.apply((Bitcoiny) blockchain)) );
+ }
+ }
+
+ // put the list of fee data into a JSON array
+ JSONArray currentForeignFeesJson = new JSONArray();
+ for (ForeignFeeData foreignFee : foreignFees) {
+ JSONObject requiredForeignFeeJson = foreignFee.toJson();
+ currentForeignFeesJson.put(requiredForeignFeeJson);
+ }
+
+ // put the JSON array and some metadata into a JSON object
+ JSONObject currentForeignFeeDataJsonWrapper = new JSONObject();
+ currentForeignFeeDataJsonWrapper.put("type", type);
+ currentForeignFeeDataJsonWrapper.put("dataset", CURRENT_DATASET_LABEL);
+ currentForeignFeeDataJsonWrapper.put("data", currentForeignFeesJson);
+
+ // write the JSON to the backup file
+ String fileName = Paths.get(backupDirectory.toString(), filename).toString();
+ FileWriter writer = new FileWriter(fileName);
+ writer.write(currentForeignFeeDataJsonWrapper.toString(2));
+ writer.close();
+
+ }
+ catch (DataException e) {
+ throw new DataException("Unable to export required foreign fees.");
+ }
+ catch (Exception e) {
+ LOGGER.error(e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Import Data From File
+ *
+ * @param filename the file name
+ *
+ * @throws DataException
+ * @throws IOException
+ */
+ public void importDataFromFile(String filename) throws DataException, IOException {
+
+ // file path and check for existance
+ Path path = Paths.get(filename);
+ if (!path.toFile().exists()) {
+ throw new FileNotFoundException(String.format("File doesn't exist: %s", filename));
+ }
+
+ // read in the file
+ byte[] fileContents = Files.readAllBytes(path);
+ if (fileContents == null) {
+ throw new FileNotFoundException(String.format("Unable to read file contents: %s", filename));
+ }
+
+ LOGGER.debug(String.format("Importing %s into foreign fees manager ...", filename));
+
+ String jsonString = new String(fileContents);
+
+ // get the data type and data from the JSON
+ Triple parsedJSON = HSQLDBImportExport.parseJSONString(jsonString);
+ if (parsedJSON.getA() == null || parsedJSON.getC() == null) {
+ throw new DataException(String.format("Missing data when importing %s into foreign fees manager", filename));
+ }
+ String type = parsedJSON.getA();
+ JSONArray data = parsedJSON.getC();
+
+ Iterator