Merge branch 'trade-bot'

This commit is contained in:
catbref
2020-08-06 09:00:45 +01:00
60 changed files with 5503 additions and 892 deletions

View File

@@ -43,6 +43,8 @@ import org.qortal.api.websocket.ActiveChatsWebSocket;
import org.qortal.api.websocket.AdminStatusWebSocket;
import org.qortal.api.websocket.BlocksWebSocket;
import org.qortal.api.websocket.ChatMessagesWebSocket;
import org.qortal.api.websocket.TradeBotWebSocket;
import org.qortal.api.websocket.TradeOffersWebSocket;
import org.qortal.settings.Settings;
public class ApiService {
@@ -196,6 +198,8 @@ public class ApiService {
context.addServlet(BlocksWebSocket.class, "/websockets/blocks");
context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*");
context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages");
context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers");
context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot");
// Start server
this.server.start();

View File

@@ -25,6 +25,9 @@ public class CrossChainBitcoinRedeemRequest {
@Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG")
public byte[] secret;
@Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf")
public byte[] receivingAccountInfo;
public CrossChainBitcoinRedeemRequest() {
}

View File

@@ -12,20 +12,19 @@ public class CrossChainBuildRequest {
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] creatorPublicKey;
@Schema(description = "Initial QORT amount paid when trade agreed", example = "0.00100000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long initialQortAmount;
@Schema(description = "Final QORT amount paid out on successful trade", example = "80.40200000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long finalQortAmount;
public long qortAmount;
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "123.45670000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long fundingQortAmount;
@Schema(description = "HASH160 of creator's Bitcoin public key", example = "2daMveGc5pdjRyFacbxBzMksCbyC")
public byte[] bitcoinPublicKeyHash;
@Schema(description = "HASH160 of secret", example = "43vnftqkjxrhb5kJdkU1ZFQLEnWV")
public byte[] secretHash;
public byte[] hashOfSecretB;
@Schema(description = "Bitcoin P2SH BTC balance for release of secret", example = "0.00864200")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)

View File

@@ -8,10 +8,10 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainCancelRequest {
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
@Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] creatorPublicKey;
@Schema(description = "Qortal AT address")
@Schema(description = "Qortal trade AT address")
public String atAddress;
public CrossChainCancelRequest() {

View File

@@ -0,0 +1,86 @@
package org.qortal.api.model;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.qortal.crosschain.BTCACCT;
import org.qortal.data.crosschain.CrossChainTradeData;
import io.swagger.v3.oas.annotations.media.Schema;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainOfferSummary {
// Properties
@Schema(description = "AT's Qortal address")
public String qortalAtAddress;
@Schema(description = "AT creator's Qortal address")
public String qortalCreator;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long qortAmount;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long btcAmount;
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
private int tradeTimeout;
private BTCACCT.Mode mode;
private long timestamp;
private String partnerQortalReceivingAddress;
protected CrossChainOfferSummary() {
/* For JAXB */
}
public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
this.qortalAtAddress = crossChainTradeData.qortalAtAddress;
this.qortalCreator = crossChainTradeData.qortalCreator;
this.qortAmount = crossChainTradeData.qortAmount;
this.btcAmount = crossChainTradeData.expectedBitcoin;
this.tradeTimeout = crossChainTradeData.tradeTimeout;
this.mode = crossChainTradeData.mode;
this.timestamp = timestamp;
this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress;
}
public String getQortalAtAddress() {
return this.qortalAtAddress;
}
public String getQortalCreator() {
return this.qortalCreator;
}
public long getQortAmount() {
return this.qortAmount;
}
public long getBtcAmount() {
return this.btcAmount;
}
public int getTradeTimeout() {
return this.tradeTimeout;
}
public BTCACCT.Mode getMode() {
return this.mode;
}
public long getTimestamp() {
return this.timestamp;
}
public String getPartnerQortalReceivingAddress() {
return this.partnerQortalReceivingAddress;
}
}

View File

@@ -8,14 +8,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainSecretRequest {
@Schema(description = "Public key to match AT's 'recipient'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] recipientPublicKey;
@Schema(description = "Public key to match AT's trade 'partner'", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] partnerPublicKey;
@Schema(description = "Qortal AT address")
public String atAddress;
@Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG")
public byte[] secret;
@Schema(description = "secret-A (32 bytes)", example = "FHMzten4he9jZ4HGb4297Utj6F5g2w7serjq2EnAg2s1")
public byte[] secretA;
@Schema(description = "secret-B (32 bytes)", example = "EN2Bgx3BcEMtxFCewmCVSMkfZjVKYhx3KEXC5A21KBGx")
public byte[] secretB;
@Schema(description = "Qortal address for receiving QORT from AT")
public String receivingAddress;
public CrossChainSecretRequest() {
}

View File

@@ -8,14 +8,14 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainTradeRequest {
@Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] creatorPublicKey;
@Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry")
public byte[] tradePublicKey;
@Schema(description = "Qortal AT address")
public String atAddress;
@Schema(description = "Qortal address for trade partner/recipient")
public String recipient;
@Schema(description = "Signature of trading partner's 'offer' MESSAGE transaction")
public byte[] messageTransactionSignature;
public CrossChainTradeRequest() {
}

View File

@@ -0,0 +1,43 @@
package org.qortal.api.model;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.qortal.data.crosschain.CrossChainTradeData;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainTradeSummary {
private long tradeTimestamp;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long qortAmount;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long btcAmount;
protected CrossChainTradeSummary() {
/* For JAXB */
}
public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
this.tradeTimestamp = timestamp;
this.qortAmount = crossChainTradeData.qortAmount;
this.btcAmount = crossChainTradeData.expectedBitcoin;
}
public long getTradeTimestamp() {
return this.tradeTimestamp;
}
public long getQortAmount() {
return this.qortAmount;
}
public long getBtcAmount() {
return this.btcAmount;
}
}

View File

@@ -0,0 +1,36 @@
package org.qortal.api.model;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class TradeBotCreateRequest {
@Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB")
public byte[] creatorPublicKey;
@Schema(description = "QORT amount paid out on successful trade", example = "80.40200000")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long qortAmount;
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "81")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long fundingQortAmount;
@Schema(description = "Bitcoin amount wanted in return", example = "0.00864200")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long bitcoinAmount;
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
public int tradeTimeout;
@Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx")
public String receivingAddress;
public TradeBotCreateRequest() {
}
}

View File

@@ -0,0 +1,23 @@
package org.qortal.api.model;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD)
public class TradeBotRespondRequest {
@Schema(description = "Qortal AT address", example = "AH3e3jHEsGHPVQPDiJx4pYqgVi72auxgVy")
public String atAddress;
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ")
public String xprv58;
@Schema(description = "Qortal address for receiving QORT from AT")
public String receivingAddress;
public TradeBotRespondRequest() {
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.controller.ChatNotifier;
import org.qortal.crypto.Crypto;
@@ -24,7 +23,7 @@ import org.qortal.repository.RepositoryManager;
@WebSocket
@SuppressWarnings("serial")
public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSocket {
public class ActiveChatsWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -33,7 +32,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
@OnWebSocketConnect
public void onWebSocketConnect(Session session) {
Map<String, String> pathParams = this.getPathParams(session, "/{address}");
Map<String, String> pathParams = getPathParams(session, "/{address}");
String address = pathParams.get("address");
if (address == null || !Crypto.isValidAddress(address)) {
@@ -76,7 +75,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock
StringWriter stringWriter = new StringWriter();
this.marshall(stringWriter, activeChats);
marshall(stringWriter, activeChats);
// Only output if something has changed
String output = stringWriter.toString();

View File

@@ -11,7 +11,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.api.model.NodeStatus;
import org.qortal.controller.StatusNotifier;
@@ -21,7 +20,7 @@ import org.qortal.repository.RepositoryManager;
@WebSocket
@SuppressWarnings("serial")
public class AdminStatusWebSocket extends WebSocketServlet implements ApiWebSocket {
public class AdminStatusWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -57,7 +56,7 @@ public class AdminStatusWebSocket extends WebSocketServlet implements ApiWebSock
StringWriter stringWriter = new StringWriter();
this.marshall(stringWriter, nodeStatus);
marshall(stringWriter, nodeStatus);
// Only output if something has changed
String output = stringWriter.toString();

View File

@@ -3,7 +3,10 @@ package org.qortal.api.websocket;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.xml.bind.JAXBContext;
@@ -13,24 +16,28 @@ import javax.xml.bind.Marshaller;
import org.eclipse.jetty.http.pathmap.UriTemplatePathSpec;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest;
import org.eclipse.jetty.websocket.servlet.WebSocketServlet;
import org.eclipse.persistence.jaxb.JAXBContextFactory;
import org.eclipse.persistence.jaxb.MarshallerProperties;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrorRoot;
interface ApiWebSocket {
@SuppressWarnings("serial")
abstract class ApiWebSocket extends WebSocketServlet {
default String getPathInfo(Session session) {
private static final Map<Class<? extends ApiWebSocket>, List<Session>> SESSIONS_BY_CLASS = new HashMap<>();
protected static String getPathInfo(Session session) {
ServletUpgradeRequest upgradeRequest = (ServletUpgradeRequest) session.getUpgradeRequest();
return upgradeRequest.getHttpServletRequest().getPathInfo();
}
default Map<String, String> getPathParams(Session session, String pathSpec) {
protected static Map<String, String> getPathParams(Session session, String pathSpec) {
UriTemplatePathSpec uriTemplatePathSpec = new UriTemplatePathSpec(pathSpec);
return uriTemplatePathSpec.getPathParams(this.getPathInfo(session));
return uriTemplatePathSpec.getPathParams(getPathInfo(session));
}
default void sendError(Session session, ApiError apiError) {
protected static void sendError(Session session, ApiError apiError) {
ApiErrorRoot apiErrorRoot = new ApiErrorRoot();
apiErrorRoot.setApiError(apiError);
@@ -43,7 +50,7 @@ interface ApiWebSocket {
}
}
default void marshall(Writer writer, Object object) throws IOException {
protected static void marshall(Writer writer, Object object) throws IOException {
Marshaller marshaller = createMarshaller(object.getClass());
try {
@@ -53,7 +60,7 @@ interface ApiWebSocket {
}
}
default void marshall(Writer writer, Collection<?> collection) throws IOException {
protected static void marshall(Writer writer, Collection<?> collection) throws IOException {
// If collection is empty then we're returning "[]" anyway
if (collection.isEmpty()) {
writer.append("[]");
@@ -92,4 +99,22 @@ interface ApiWebSocket {
}
}
public void onWebSocketConnect(Session session) {
synchronized (SESSIONS_BY_CLASS) {
SESSIONS_BY_CLASS.computeIfAbsent(this.getClass(), clazz -> new ArrayList<>()).add(session);
}
}
public void onWebSocketClose(Session session, int statusCode, String reason) {
synchronized (SESSIONS_BY_CLASS) {
SESSIONS_BY_CLASS.get(this.getClass()).remove(session);
}
}
protected List<Session> getSessions() {
synchronized (SESSIONS_BY_CLASS) {
return new ArrayList<>(SESSIONS_BY_CLASS.get(this.getClass()));
}
}
}

View File

@@ -11,7 +11,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.api.ApiError;
import org.qortal.api.model.BlockInfo;
@@ -23,7 +22,7 @@ import org.qortal.utils.Base58;
@WebSocket
@SuppressWarnings("serial")
public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket {
public class BlocksWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -111,7 +110,7 @@ public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket {
StringWriter stringWriter = new StringWriter();
try {
this.marshall(stringWriter, blockInfo);
marshall(stringWriter, blockInfo);
session.getRemote().sendStringByFuture(stringWriter.toString());
} catch (IOException | WebSocketException e) {

View File

@@ -14,7 +14,6 @@ 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.WebSocketServlet;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.controller.ChatNotifier;
import org.qortal.data.chat.ChatMessage;
@@ -25,7 +24,7 @@ import org.qortal.repository.RepositoryManager;
@WebSocket
@SuppressWarnings("serial")
public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSocket {
public class ChatMessagesWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
@@ -129,7 +128,7 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc
StringWriter stringWriter = new StringWriter();
try {
this.marshall(stringWriter, chatMessages);
marshall(stringWriter, chatMessages);
session.getRemote().sendStringByFuture(stringWriter.toString());
} catch (IOException | WebSocketException e) {

View File

@@ -0,0 +1,119 @@
package org.qortal.api.websocket;
import java.io.IOException;
import java.io.StringWriter;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
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.controller.TradeBot;
import org.qortal.data.crosschain.TradeBotData;
import org.qortal.event.Event;
import org.qortal.event.EventBus;
import org.qortal.event.Listener;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.utils.Base58;
@WebSocket
@SuppressWarnings("serial")
public class TradeBotWebSocket extends ApiWebSocket implements Listener {
/** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */
private static final Map<String, TradeBotData.State> PREVIOUS_STATES = new HashMap<>();
@Override
public void configure(WebSocketServletFactory factory) {
factory.register(TradeBotWebSocket.class);
try (final Repository repository = RepositoryManager.getRepository()) {
List<TradeBotData> tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
if (tradeBotEntries == null)
// How do we properly fail here?
return;
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getState)));
} catch (DataException e) {
// No output this time
}
EventBus.INSTANCE.addListener(this::listen);
}
@Override
public void listen(Event event) {
if (!(event instanceof TradeBot.StateChangeEvent))
return;
TradeBotData tradeBotData = ((TradeBot.StateChangeEvent) event).getTradeBotData();
String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey());
synchronized (PREVIOUS_STATES) {
if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState())
// Not changed
return;
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState());
}
List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData);
for (Session session : getSessions())
sendEntries(session, tradeBotEntries);
}
@OnWebSocketConnect
public void onWebSocketConnect(Session session) {
// Send all known trade-bot entries
try (final Repository repository = RepositoryManager.getRepository()) {
List<TradeBotData> tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
if (tradeBotEntries == null) {
session.close(4001, "repository issue fetching trade-bot entries");
return;
}
if (!sendEntries(session, tradeBotEntries)) {
session.close(4002, "websocket issue");
return;
}
} catch (DataException e) {
// No output this time
}
super.onWebSocketConnect(session);
}
@OnWebSocketClose
public void onWebSocketClose(Session session, int statusCode, String reason) {
super.onWebSocketClose(session, statusCode, reason);
}
@OnWebSocketMessage
public void onWebSocketMessage(Session session, String message) {
/* ignored */
}
private boolean sendEntries(Session session, List<TradeBotData> tradeBotEntries) {
try {
StringWriter stringWriter = new StringWriter();
marshall(stringWriter, tradeBotEntries);
String output = stringWriter.toString();
session.getRemote().sendStringByFuture(output);
} catch (IOException e) {
// No output this time?
return false;
}
return true;
}
}

View File

@@ -0,0 +1,212 @@
package org.qortal.api.websocket;
import java.io.IOException;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.eclipse.jetty.websocket.api.Session;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect;
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.api.model.CrossChainOfferSummary;
import org.qortal.controller.BlockNotifier;
import org.qortal.crosschain.BTCACCT;
import org.qortal.data.at.ATStateData;
import org.qortal.data.block.BlockData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.utils.NTP;
@WebSocket
@SuppressWarnings("serial")
public class TradeOffersWebSocket extends ApiWebSocket {
@Override
public void configure(WebSocketServletFactory factory) {
factory.register(TradeOffersWebSocket.class);
}
@OnWebSocketConnect
public void onWebSocketConnect(Session session) {
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
final boolean includeHistoric = queryParams.get("includeHistoric") != null;
final Map<String, BTCACCT.Mode> previousAtModes = new HashMap<>();
List<CrossChainOfferSummary> crossChainOfferSummaries;
try (final Repository repository = RepositoryManager.getRepository()) {
List<ATStateData> initialAtStates;
// We want ALL OFFERING trades
Boolean isFinished = Boolean.FALSE;
Integer dataByteOffset = BTCACCT.MODE_BYTE_OFFSET;
Long expectedValue = (long) BTCACCT.Mode.OFFERING.value;
Integer minimumFinalHeight = null;
initialAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
null, null, null);
if (initialAtStates == null) {
session.close(4001, "repository issue fetching OFFERING trades");
return;
}
// Save initial AT modes
previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING)));
// Convert to offer summaries
crossChainOfferSummaries = produceSummaries(repository, initialAtStates, null);
if (includeHistoric) {
// We also want REDEEMED/REFUNDED/CANCELLED trades over the last 24 hours
long timestamp = NTP.getTime() - 24 * 60 * 60 * 1000L;
minimumFinalHeight = repository.getBlockRepository().getHeightFromTimestamp(timestamp);
if (minimumFinalHeight != 0) {
isFinished = Boolean.TRUE;
dataByteOffset = null;
expectedValue = null;
++minimumFinalHeight; // because height is just *before* timestamp
List<ATStateData> historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
null, null, null);
if (historicAtStates == null) {
session.close(4002, "repository issue fetching historic trades");
return;
}
for (ATStateData historicAtState : historicAtStates) {
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null);
switch (historicOfferSummary.getMode()) {
case REDEEMED:
case REFUNDED:
case CANCELLED:
break;
default:
continue;
}
// Add summary to initial burst
crossChainOfferSummaries.add(historicOfferSummary);
// Save initial AT mode
previousAtModes.put(historicAtState.getATAddress(), historicOfferSummary.getMode());
}
}
}
} catch (DataException e) {
session.close(4003, "generic repository issue");
return;
}
if (!sendOfferSummaries(session, crossChainOfferSummaries)) {
session.close(4004, "websocket issue");
return;
}
BlockNotifier.Listener listener = blockData -> onNotify(session, blockData, previousAtModes);
BlockNotifier.getInstance().register(session, listener);
}
@OnWebSocketClose
public void onWebSocketClose(Session session, int statusCode, String reason) {
BlockNotifier.getInstance().deregister(session);
}
@OnWebSocketMessage
public void onWebSocketMessage(Session session, String message) {
/* ignored */
}
private void onNotify(Session session, BlockData blockData, final Map<String, BTCACCT.Mode> previousAtModes) {
List<CrossChainOfferSummary> crossChainOfferSummaries = null;
try (final Repository repository = RepositoryManager.getRepository()) {
// Find any new trade ATs since this block
final Boolean isFinished = null;
final Integer dataByteOffset = null;
final Long expectedValue = null;
final Integer minimumFinalHeight = blockData.getHeight();
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
null, null, null);
if (atStates == null)
return;
crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp());
} catch (DataException e) {
// No output this time
}
synchronized (previousAtModes) { //NOSONAR squid:S2445 suppressed because previousAtModes is final and curried in lambda
// Remove any entries unchanged from last time
crossChainOfferSummaries.removeIf(offerSummary -> previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode());
// Don't send anything if no results
if (crossChainOfferSummaries.isEmpty())
return;
final boolean wasSent = sendOfferSummaries(session, crossChainOfferSummaries);
if (!wasSent)
return;
previousAtModes.putAll(crossChainOfferSummaries.stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, CrossChainOfferSummary::getMode)));
}
}
private boolean sendOfferSummaries(Session session, List<CrossChainOfferSummary> crossChainOfferSummaries) {
try {
StringWriter stringWriter = new StringWriter();
marshall(stringWriter, crossChainOfferSummaries);
String output = stringWriter.toString();
session.getRemote().sendStringByFuture(output);
} catch (IOException e) {
// No output this time?
return false;
}
return true;
}
private static CrossChainOfferSummary produceSummary(Repository repository, ATStateData atState, Long timestamp) throws DataException {
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState);
long atStateTimestamp;
if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING)
// We want when trade was created, not when it was last updated
atStateTimestamp = atState.getCreation();
else
atStateTimestamp = timestamp != null ? timestamp : repository.getBlockRepository().getTimestampFromHeight(atState.getHeight());
return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp);
}
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, List<ATStateData> atStates, Long timestamp) throws DataException {
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
for (ATStateData atState : atStates)
offerSummaries.add(produceSummary(repository, atState, timestamp));
return offerSummaries;
}
}

View File

@@ -17,7 +17,6 @@ import org.qortal.account.Account;
import org.qortal.account.NullAccount;
import org.qortal.account.PublicKeyAccount;
import org.qortal.asset.Asset;
import org.qortal.block.Block;
import org.qortal.block.BlockChain;
import org.qortal.block.BlockChain.CiyamAtSettings;
import org.qortal.crypto.Crypto;
@@ -30,13 +29,13 @@ import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.PaymentTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.group.Group;
import org.qortal.repository.BlockRepository;
import org.qortal.repository.ATRepository;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.transaction.AtTransaction;
import org.qortal.transaction.Transaction;
import org.qortal.transaction.Transaction.TransactionType;
import org.qortal.utils.Base58;
import org.qortal.utils.BitTwiddling;
import com.google.common.primitives.Bytes;
@@ -133,9 +132,9 @@ public class QortalATAPI extends API {
byte[] signature = blockSummaries.get(0).getSignature();
// Save some of minter's signature and transactions signature, so middle 24 bytes of the full 128 byte signature.
this.setA2(state, fromBytes(signature, 52));
this.setA3(state, fromBytes(signature, 60));
this.setA4(state, fromBytes(signature, 68));
this.setA2(state, BitTwiddling.longFromBEBytes(signature, 52));
this.setA3(state, BitTwiddling.longFromBEBytes(signature, 60));
this.setA4(state, BitTwiddling.longFromBEBytes(signature, 68));
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch previous block?", e);
}
@@ -149,59 +148,27 @@ public class QortalATAPI extends API {
int height = timestamp.blockHeight;
int sequence = timestamp.transactionSequence + 1;
BlockRepository blockRepository = this.getRepository().getBlockRepository();
ATRepository.NextTransactionInfo nextTransactionInfo;
try {
int currentHeight = blockRepository.getBlockchainHeight();
List<Transaction> blockTransactions = null;
while (height <= currentHeight) {
if (blockTransactions == null) {
BlockData blockData = blockRepository.fromHeight(height);
if (blockData == null)
throw new DataException("Unable to fetch block " + height + " from repository?");
Block block = new Block(this.getRepository(), blockData);
blockTransactions = block.getTransactions();
}
// No more transactions in this block? Try next block
if (sequence >= blockTransactions.size()) {
++height;
sequence = 0;
blockTransactions = null;
continue;
}
Transaction transaction = blockTransactions.get(sequence);
// Transaction needs to be sent to specified recipient
List<String> recipientAddresses = transaction.getRecipientAddresses();
if (recipientAddresses.contains(atAddress)) {
// Found a transaction
this.setA1(state, new Timestamp(height, timestamp.blockchainId, sequence).longValue());
// Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
byte[] signature = transaction.getTransactionData().getSignature();
this.setA2(state, fromBytes(signature, 8));
this.setA3(state, fromBytes(signature, 16));
this.setA4(state, fromBytes(signature, 24));
return;
}
// Transaction wasn't for us - keep going
++sequence;
}
// No more transactions - zero A and exit
this.zeroA(state);
nextTransactionInfo = this.getRepository().getATRepository().findNextTransaction(atAddress, height, sequence);
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch next transaction?", e);
}
if (nextTransactionInfo == null) {
// No more transactions for AT at this time - zero A and exit
this.zeroA(state);
return;
}
// Found a transaction
this.setA1(state, new Timestamp(nextTransactionInfo.height, timestamp.blockchainId, nextTransactionInfo.sequence).longValue());
// Copy transaction's partial signature into the other three A fields for future verification that it's the same transaction
this.setA2(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 8));
this.setA3(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 16));
this.setA4(state, BitTwiddling.longFromBEBytes(nextTransactionInfo.signature, 24));
}
@Override
@@ -282,7 +249,7 @@ public class QortalATAPI extends API {
byte[] hash = Crypto.digest(input);
return fromBytes(hash, 0);
return BitTwiddling.longFromBEBytes(hash, 0);
} catch (DataException e) {
throw new RuntimeException("AT API unable to fetch latest block from repository?", e);
}
@@ -296,30 +263,14 @@ public class QortalATAPI extends API {
TransactionData transactionData = this.getTransactionFromA(state);
byte[] messageData = null;
switch (transactionData.getType()) {
case MESSAGE:
messageData = ((MessageTransactionData) transactionData).getData();
break;
case AT:
messageData = ((ATTransactionData) transactionData).getMessage();
break;
default:
return;
}
// Check data length is appropriate, i.e. not larger than B
if (messageData.length > 4 * 8)
return;
byte[] messageData = this.getMessageFromTransaction(transactionData);
// Pad messageData to fit B
byte[] paddedMessageData = Bytes.ensureCapacity(messageData, 4 * 8, 0);
if (messageData.length < 4 * 8)
messageData = Bytes.ensureCapacity(messageData, 4 * 8, 0);
// Endian must be correct here so that (for example) a SHA256 message can be compared to one generated locally
this.setB(state, paddedMessageData);
this.setB(state, messageData);
}
@Override
@@ -457,12 +408,6 @@ public class QortalATAPI extends API {
// Utility methods
/** Convert part of little-endian byte[] to long */
/* package */ static long fromBytes(byte[] bytes, int start) {
return (bytes[start] & 0xffL) | (bytes[start + 1] & 0xffL) << 8 | (bytes[start + 2] & 0xffL) << 16 | (bytes[start + 3] & 0xffL) << 24
| (bytes[start + 4] & 0xffL) << 32 | (bytes[start + 5] & 0xffL) << 40 | (bytes[start + 6] & 0xffL) << 48 | (bytes[start + 7] & 0xffL) << 56;
}
/** Returns partial transaction signature, used to verify we're operating on the same transaction and not naively using block height & sequence. */
public static byte[] partialSignature(byte[] fullSignature) {
return Arrays.copyOfRange(fullSignature, 8, 32);
@@ -473,7 +418,7 @@ public class QortalATAPI extends API {
// Compare end of transaction's signature against A2 thru A4
byte[] sig = transactionData.getSignature();
if (this.getA2(state) != fromBytes(sig, 8) || this.getA3(state) != fromBytes(sig, 16) || this.getA4(state) != fromBytes(sig, 24))
if (this.getA2(state) != BitTwiddling.longFromBEBytes(sig, 8) || this.getA3(state) != BitTwiddling.longFromBEBytes(sig, 16) || this.getA4(state) != BitTwiddling.longFromBEBytes(sig, 24))
throw new IllegalStateException("Transaction signature in A no longer matches signature from repository");
}
@@ -497,6 +442,20 @@ public class QortalATAPI extends API {
}
}
/** Returns message data from transaction. */
/*package*/ byte[] getMessageFromTransaction(TransactionData transactionData) {
switch (transactionData.getType()) {
case MESSAGE:
return ((MessageTransactionData) transactionData).getData();
case AT:
return ((ATTransactionData) transactionData).getMessage();
default:
return null;
}
}
/** Returns AT's account */
/* package */ Account getATAccount() {
return new Account(this.repository, this.atData.getATAddress());
@@ -563,4 +522,8 @@ public class QortalATAPI extends API {
super.setB(state, bBytes);
}
protected void zeroB(MachineState state) {
super.zeroB(state);
}
}

View File

@@ -12,6 +12,7 @@ import org.ciyam.at.IllegalFunctionCodeException;
import org.ciyam.at.MachineState;
import org.qortal.crosschain.BTC;
import org.qortal.crypto.Crypto;
import org.qortal.data.transaction.TransactionData;
import org.qortal.settings.Settings;
/**
@@ -22,8 +23,70 @@ import org.qortal.settings.Settings;
*/
public enum QortalFunctionCode {
/**
* <tt>0x0510</tt><br>
* Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.
* Returns length of message data from transaction in A.<br>
* <tt>0x0501</tt><br>
* If transaction has no 'message', returns -1.
*/
GET_MESSAGE_LENGTH_FROM_TX_IN_A(0x0501, 0, true) {
@Override
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
QortalATAPI api = (QortalATAPI) state.getAPI();
TransactionData transactionData = api.getTransactionFromA(state);
byte[] messageData = api.getMessageFromTransaction(transactionData);
if (messageData == null)
functionData.returnValue = -1L;
else
functionData.returnValue = (long) messageData.length;
}
},
/**
* Put offset 'message' from transaction in A into B<br>
* <tt>0x0502 start-offset</tt><br>
* Copies up to 32 bytes of message data, starting at <tt>start-offset</tt> into B.<br>
* If transaction has no 'message', or <tt>start-offset</tt> out of bounds, then zero B<br>
* Example 'message' could be 256-bit shared secret
*/
PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B(0x0502, 1, false) {
@Override
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
QortalATAPI api = (QortalATAPI) state.getAPI();
// In case something goes wrong, or we don't have enough message data.
api.zeroB(state);
if (functionData.value1 < 0 || functionData.value1 > Integer.MAX_VALUE)
return;
int startOffset = functionData.value1.intValue();
TransactionData transactionData = api.getTransactionFromA(state);
byte[] messageData = api.getMessageFromTransaction(transactionData);
if (messageData == null || startOffset > messageData.length)
return;
/*
* Copy up to 32 bytes of message data into B,
* retain order but pad with zeros in lower bytes.
*
* So a 4-byte message "a b c d" would copy thusly:
* a b c d 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
*/
int byteCount = Math.min(32, messageData.length - startOffset);
byte[] bBytes = new byte[32];
System.arraycopy(messageData, startOffset, bBytes, 0, byteCount);
api.setB(state, bBytes);
}
},
/**
* Convert address in B to 20-byte value in LSB of B1, and all of B2 & B3.<br>
* <tt>0x0510</tt>
*/
CONVERT_B_TO_PKH(0x0510, 0, false) {
@Override
@@ -38,8 +101,8 @@ public enum QortalFunctionCode {
}
},
/**
* <tt>0x0511</tt><br>
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.<br>
* <tt>0x0511</tt><br>
* P2SH stored in lower 25 bytes of B.
*/
CONVERT_B_TO_P2SH(0x0511, 0, false) {
@@ -51,8 +114,8 @@ public enum QortalFunctionCode {
}
},
/**
* <tt>0x0512</tt><br>
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.<br>
* <tt>0x0512</tt><br>
* Qortal address stored in lower 25 bytes of B.
*/
CONVERT_B_TO_QORTAL(0x0512, 0, false) {

View File

@@ -792,6 +792,9 @@ public class Controller extends Thread {
this.notifyGroupMembershipChange = false;
ChatNotifier.getInstance().onGroupMembershipChange();
}
// Trade-bot might want to perform some actions too
TradeBot.getInstance().onChainTipChange();
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,45 @@
package org.qortal.crosschain;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.ECKey;
import org.bitcoinj.core.InsufficientMoneyException;
import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.NetworkParameters;
import org.bitcoinj.core.Sha256Hash;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.core.UTXO;
import org.bitcoinj.core.UTXOProvider;
import org.bitcoinj.core.UTXOProviderException;
import org.bitcoinj.crypto.DeterministicHierarchy;
import org.bitcoinj.crypto.DeterministicKey;
import org.bitcoinj.params.MainNetParams;
import org.bitcoinj.params.RegTestParams;
import org.bitcoinj.params.TestNet3Params;
import org.bitcoinj.script.Script.ScriptType;
import org.bitcoinj.script.ScriptBuilder;
import org.bitcoinj.utils.MonetaryFormat;
import org.bitcoinj.wallet.DeterministicKeyChain;
import org.bitcoinj.wallet.SendRequest;
import org.bitcoinj.wallet.Wallet;
import org.qortal.crosschain.ElectrumX.UnspentOutput;
import org.qortal.crypto.Crypto;
import org.qortal.settings.Settings;
import org.qortal.utils.BitTwiddling;
import org.qortal.utils.Pair;
import com.google.common.hash.HashCode;
public class BTC {
public static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
public static final int HASH160_LENGTH = 20;
@@ -30,6 +47,7 @@ public class BTC {
protected static final Logger LOGGER = LogManager.getLogger(BTC.class);
private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
private static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
public enum BitcoinNet {
MAIN {
@@ -58,6 +76,9 @@ public class BTC {
private final NetworkParameters params;
private final ElectrumX electrumX;
// Let ECKey.equals() do the hard work
private final Set<ECKey> spentKeys = new HashSet<>();
// Constructors and instance
private BTC() {
@@ -88,6 +109,34 @@ public class BTC {
// Actual useful methods for use by other classes
public static String format(Coin amount) {
return BTC.FORMAT.format(amount).toString();
}
public static String format(long amount) {
return format(Coin.valueOf(amount));
}
public boolean isValidXprv(String xprv58) {
try {
DeterministicKey.deserializeB58(null, xprv58, this.params);
return true;
} catch (IllegalArgumentException e) {
return false;
}
}
/** Returns P2PKH Bitcoin address using passed public key hash. */
public String pkhToAddress(byte[] publicKeyHash) {
return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
}
public String deriveP2shAddress(byte[] redeemScriptBytes) {
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
return p2shAddress.toString();
}
/** Returns median timestamp from latest 11 blocks, in seconds. */
public Integer getMedianBlockTime() {
Integer height = this.electrumX.getCurrentHeight();
@@ -99,34 +148,31 @@ public class BTC {
if (blockHeaders == null || blockHeaders.size() < 11)
return null;
List<Integer> blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.fromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
List<Integer> blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
// Descending, but order shouldn't matter as we're picking median...
// Descending order
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
// Pick median
return blockTimestamps.get(5);
}
public Coin getBalance(String base58Address) {
Long balance = this.electrumX.getBalance(addressToScript(base58Address));
if (balance == null)
return null;
return Coin.valueOf(balance);
public Long getBalance(String base58Address) {
return this.electrumX.getBalance(addressToScript(base58Address));
}
public List<TransactionOutput> getUnspentOutputs(String base58Address) {
List<Pair<byte[], Integer>> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
List<UnspentOutput> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address));
if (unspentOutputs == null)
return null;
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
for (Pair<byte[], Integer> unspentOutput : unspentOutputs) {
List<TransactionOutput> transactionOutputs = getOutputs(unspentOutput.getA());
for (UnspentOutput unspentOutput : unspentOutputs) {
List<TransactionOutput> transactionOutputs = getOutputs(unspentOutput.hash);
if (transactionOutputs == null)
return null;
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB()));
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
}
return unspentTransactionOutputs;
@@ -141,6 +187,7 @@ public class BTC {
return transaction.getOutputs();
}
/** Returns list of raw transactions spending passed address. */
public List<byte[]> getAddressTransactions(String base58Address) {
return this.electrumX.getAddressTransactions(addressToScript(base58Address));
}
@@ -149,6 +196,181 @@ public class BTC {
return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize());
}
/**
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt>.
*
* @param xprv58 BIP32 extended Bitcoin private key
* @param recipient P2PKH address
* @param amount unscaled amount
* @return transaction, or null if insufficient funds
*/
public Transaction buildSpend(String xprv58, String recipient, long amount) {
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ALL_SPENT));
Address destination = Address.fromString(this.params, recipient);
SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
if (this.params == TestNet3Params.get())
// Much smaller fee for TestNet3
sendRequest.feePerKb = Coin.valueOf(2000L);
try {
wallet.completeTx(sendRequest);
return sendRequest.tx;
} catch (InsufficientMoneyException e) {
return null;
}
}
/**
* Returns unspent Bitcoin balance given 'm' BIP32 key.
*
* @param xprv58 BIP32 extended Bitcoin private key
* @return unspent BTC balance, or null if unable to determine balance
*/
public Long getWalletBalance(String xprv58) {
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT));
Coin balance = wallet.getBalance();
if (balance == null)
return null;
return balance.value;
}
// UTXOProvider support
static class WalletAwareUTXOProvider implements UTXOProvider {
private static final int LOOKAHEAD_INCREMENT = 3;
private final BTC btc;
private final Wallet wallet;
enum KeySearchMode {
REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT;
}
private final KeySearchMode keySearchMode;
private final DeterministicKeyChain keyChain;
public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) {
this.btc = btc;
this.wallet = wallet;
this.keySearchMode = keySearchMode;
this.keyChain = this.wallet.getActiveKeyChain();
// Set up wallet's key chain
this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT);
this.keyChain.maybeLookAhead();
}
public List<UTXO> getOpenTransactionOutputs(List<ECKey> keys) throws UTXOProviderException {
List<UTXO> allUnspentOutputs = new ArrayList<>();
final boolean coinbase = false;
int ki = 0;
do {
boolean areAllKeysUnspent = true;
boolean areAllKeysSpent = true;
for (; ki < keys.size(); ++ki) {
ECKey key = keys.get(ki);
Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH);
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
List<UnspentOutput> unspentOutputs = btc.electrumX.getUnspentOutputs(script);
if (unspentOutputs == null)
throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
/*
* If there are no unspent outputs then either:
* a) all the outputs have been spent
* b) address has never been used
*
* For case (a) we want to remember not to check this address (key) again.
*/
if (unspentOutputs.isEmpty()) {
// If this is a known key that has been spent before, then we can skip asking for transaction history
if (btc.spentKeys.contains(key)) {
wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
areAllKeysUnspent = false;
continue;
}
// Ask for transaction history - if it's empty then key has never been used
List<byte[]> historicTransactionHashes = btc.electrumX.getAddressTransactions(script);
if (historicTransactionHashes == null)
throw new UTXOProviderException(
String.format("Unable to fetch transaction history for %s", address));
if (!historicTransactionHashes.isEmpty()) {
// Fully spent key - case (a)
btc.spentKeys.add(key);
wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
areAllKeysUnspent = false;
} else {
// Key never been used - case (b)
areAllKeysSpent = false;
}
continue;
}
// If we reach here, then there's definitely at least one unspent key
areAllKeysSpent = false;
for (UnspentOutput unspentOutput : unspentOutputs) {
List<TransactionOutput> transactionOutputs = btc.getOutputs(unspentOutput.hash);
if (transactionOutputs == null)
throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s",
HashCode.fromBytes(unspentOutput.hash)));
TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index);
UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index,
Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase,
transactionOutput.getScriptPubKey());
allUnspentOutputs.add(utxo);
}
}
if ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent)
|| (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) {
// Generate some more keys
this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT);
this.keyChain.maybeLookAhead();
// This returns all keys, including those already in 'keys'
List<DeterministicKey> allLeafKeys = this.keyChain.getLeafKeys();
// Add only new keys onto our list of keys to search
List<DeterministicKey> newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
keys.addAll(newKeys);
// Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
}
// If we have processed all keys, then we're done
} while (ki < keys.size());
return allUnspentOutputs;
}
public int getChainHeadHeight() throws UTXOProviderException {
Integer height = btc.electrumX.getCurrentHeight();
if (height == null)
throw new UTXOProviderException("Unable to determine Bitcoin chain height");
return height.intValue();
}
public NetworkParameters getParams() {
return btc.params;
}
}
// Utility methods for us
private byte[] addressToScript(String base58Address) {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
package org.qortal.crosschain;
import java.util.List;
import java.util.function.Function;
import org.bitcoinj.core.Address;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.ECKey;
import org.bitcoinj.core.LegacyAddress;
import org.bitcoinj.core.NetworkParameters;
import org.bitcoinj.core.Transaction;
import org.bitcoinj.core.Transaction.SigHash;
import org.bitcoinj.core.TransactionInput;
import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.crypto.TransactionSignature;
import org.bitcoinj.script.Script;
import org.bitcoinj.script.ScriptBuilder;
import org.bitcoinj.script.ScriptChunk;
import org.bitcoinj.script.ScriptOpCodes;
import org.qortal.crypto.Crypto;
import org.qortal.utils.BitTwiddling;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
public class BTCP2SH {
public static final int SECRET_LENGTH = 32;
public static final int MIN_LOCKTIME = 1500000000;
/*
* OP_TUCK (to copy public key to before signature)
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
* OP_HASH160 (convert public key to PKH)
* OP_DUP (duplicate PKH)
* <push 20 bytes> <refund PKH> OP_EQUAL (does PKH match refund PKH?)
* OP_IF
* OP_DROP (no need for duplicate PKH)
* <push 4 bytes> <locktime>
* OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is <locktime> so script passes)
* OP_ELSE
* <push 20 bytes> <redeem PKH> OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails)
* OP_HASH160 (hash secret)
* <push 20 bytes> <hash of secret> OP_EQUAL (do hashes of secrets match? if true, script passes else script fails)
* OP_ENDIF
*/
private static final byte[] redeemScript1 = HashCode.fromString("7dada97614").asBytes(); // OP_TUCK OP_CHECKSIGVERIFY OP_HASH160 OP_DUP push(0x14 bytes)
private static final byte[] redeemScript2 = HashCode.fromString("87637504").asBytes(); // OP_EQUAL OP_IF OP_DROP push(0x4 bytes)
private static final byte[] redeemScript3 = HashCode.fromString("b16714").asBytes(); // OP_CHECKLOCKTIMEVERIFY OP_ELSE push(0x14 bytes)
private static final byte[] redeemScript4 = HashCode.fromString("88a914").asBytes(); // OP_EQUALVERIFY OP_HASH160 push(0x14 bytes)
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
/**
* Returns Bitcoin redeemScript used for cross-chain trading.
* <p>
* See comments in {@link BTCP2SH} for more details.
*
* @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes
* @param lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund
* @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key
* @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds
* @return
*/
public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] secretHash) {
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
}
/**
* Builds a custom transaction to spend P2SH.
*
* @param amount output amount, should be total of input amounts, less miner fees
* @param spendKey key for signing transaction, and also where funds are 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param lockTime (optional) transaction nLockTime, used in refund scenario
* @param scriptSigBuilder function for building scriptSig using transaction input signature
* @param outputPublicKeyHash PKH used to create P2PKH output
* @return Signed Bitcoin transaction for spending P2SH
*/
public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes,
Long lockTime, Function<byte[], Script> scriptSigBuilder, byte[] outputPublicKeyHash) {
NetworkParameters params = BTC.getInstance().getNetworkParameters();
Transaction transaction = new Transaction(params);
transaction.setVersion(2);
// Output is back to P2SH funder
transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(outputPublicKeyHash));
for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) {
TransactionOutput fundingOutput = fundingOutputs.get(inputIndex);
// Input (without scriptSig prior to signing)
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
if (lockTime != null)
input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
else
input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
transaction.addInput(input);
}
// Set locktime after inputs added but before input signatures are generated
if (lockTime != null)
transaction.setLockTime(lockTime);
for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) {
// Generate transaction signature for input
final boolean anyoneCanPay = false;
TransactionSignature txSig = transaction.calculateSignature(inputIndex, spendKey, redeemScriptBytes, SigHash.ALL, anyoneCanPay);
// Calculate transaction signature
byte[] txSigBytes = txSig.encodeToBitcoin();
// Build scriptSig using lambda and tx signature
Script scriptSig = scriptSigBuilder.apply(txSigBytes);
// Set input scriptSig
transaction.getInput(inputIndex).setScriptSig(scriptSig);
}
return transaction;
}
/**
* Returns signed Bitcoin transaction claiming refund from P2SH address.
*
* @param refundAmount refund amount, should be total of input amounts, less miner fees
* @param refundKey key for signing transaction, and also where refund is 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
* @return Signed Bitcoin transaction for refunding P2SH
*/
public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, long lockTime) {
Function<byte[], Script> refundSigScriptBuilder = (txSigBytes) -> {
// Build scriptSig with...
ScriptBuilder scriptBuilder = new ScriptBuilder();
// transaction signature
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
// redeem public key
byte[] refundPubKey = refundKey.getPubKey();
scriptBuilder.addChunk(new ScriptChunk(refundPubKey.length, refundPubKey));
// redeem script
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
return scriptBuilder.build();
};
// Send funds back to funding address
return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, refundKey.getPubKeyHash());
}
/**
* Returns signed Bitcoin transaction redeeming funds from P2SH address.
*
* @param redeemAmount redeem amount, should be total of input amounts, less miner fees
* @param redeemKey key for signing transaction, and also where funds are 'sent' (output)
* @param fundingOutput output from transaction that funded P2SH address
* @param redeemScriptBytes the redeemScript itself, in byte[] form
* @param secret actual 32-byte secret used when building redeemScript
* @param receivingAccountInfo Bitcoin PKH used for output
* @return Signed Bitcoin transaction for redeeming P2SH
*/
public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
Function<byte[], Script> redeemSigScriptBuilder = (txSigBytes) -> {
// Build scriptSig with...
ScriptBuilder scriptBuilder = new ScriptBuilder();
// secret
scriptBuilder.addChunk(new ScriptChunk(secret.length, secret));
// transaction signature
scriptBuilder.addChunk(new ScriptChunk(txSigBytes.length, txSigBytes));
// redeem public key
byte[] redeemPubKey = redeemKey.getPubKey();
scriptBuilder.addChunk(new ScriptChunk(redeemPubKey.length, redeemPubKey));
// redeem script
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
return scriptBuilder.build();
};
return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
}
/** Returns 'secret', if any, given list of raw bitcoin transactions. */
public static byte[] findP2shSecret(String p2shAddress, List<byte[]> rawTransactions) {
NetworkParameters params = BTC.getInstance().getNetworkParameters();
for (byte[] rawTransaction : rawTransactions) {
Transaction transaction = new Transaction(params, rawTransaction);
// Cycle through inputs, looking for one that spends our P2SH
for (TransactionInput input : transaction.getInputs()) {
Script scriptSig = input.getScriptSig();
List<ScriptChunk> scriptChunks = scriptSig.getChunks();
// Expected number of script chunks for redeem. Refund might not have the same number.
int expectedChunkCount = 1 /*secret*/ + 1 /*sig*/ + 1 /*pubkey*/ + 1 /*redeemScript*/;
if (scriptChunks.size() != expectedChunkCount)
continue;
// We're expecting last chunk to contain the actual redeemScript
ScriptChunk lastChunk = scriptChunks.get(scriptChunks.size() - 1);
byte[] redeemScriptBytes = lastChunk.data;
// If non-push scripts, redeemScript will be null
if (redeemScriptBytes == null)
continue;
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
if (!inputAddress.toString().equals(p2shAddress))
// Input isn't spending our P2SH
continue;
byte[] secret = scriptChunks.get(0).data;
if (secret.length != BTCP2SH.SECRET_LENGTH)
continue;
return secret;
}
}
return null;
}
}

View File

@@ -25,11 +25,11 @@ import org.json.simple.JSONObject;
import org.json.simple.JSONValue;
import org.qortal.crypto.Crypto;
import org.qortal.crypto.TrustlessSSLSocketFactory;
import org.qortal.utils.Pair;
import com.google.common.hash.HashCode;
import com.google.common.primitives.Bytes;
/** ElectrumX network support for querying Bitcoin-related info like block headers, transaction outputs, etc. */
public class ElectrumX {
private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class);
@@ -93,7 +93,21 @@ public class ElectrumX {
private ElectrumX(String bitcoinNetwork) {
switch (bitcoinNetwork) {
case "MAIN":
servers.addAll(Arrays.asList());
servers.addAll(Arrays.asList(
// Servers chosen on NO BASIS WHATSOEVER from various sources!
new Server("tardis.bauerj.eu", Server.ConnectionType.SSL, 50002),
new Server("rbx.curalle.ovh", Server.ConnectionType.SSL, 50002),
new Server("quick.electumx.live", Server.ConnectionType.SSL, 50002),
new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
new Server("electrumx.ddns.net", Server.ConnectionType.SSL, 50002),
new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
new Server("electrum.eff.ro", Server.ConnectionType.SSL, 50002),
new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
new Server("E-X.not.fyi", Server.ConnectionType.SSL, 50002),
new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 50001),
new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 50002),
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001)));
break;
case "TEST3":
@@ -119,6 +133,7 @@ public class ElectrumX {
rpc("server.banner");
}
/** Returns ElectrumX instance linked to passed Bitcoin network, one of "MAIN", "TEST3" or "REGTEST". */
public static synchronized ElectrumX getInstance(String bitcoinNetwork) {
if (!instances.containsKey(bitcoinNetwork))
instances.put(bitcoinNetwork, new ElectrumX(bitcoinNetwork));
@@ -129,16 +144,26 @@ public class ElectrumX {
// Methods for use by other classes
public Integer getCurrentHeight() {
JSONObject blockJson = (JSONObject) this.rpc("blockchain.headers.subscribe");
if (blockJson == null || !blockJson.containsKey("height"))
Object blockObj = this.rpc("blockchain.headers.subscribe");
if (!(blockObj instanceof JSONObject))
return null;
JSONObject blockJson = (JSONObject) blockObj;
if (!blockJson.containsKey("height"))
return null;
return ((Long) blockJson.get("height")).intValue();
}
public List<byte[]> getBlockHeaders(int startHeight, long count) {
JSONObject blockJson = (JSONObject) this.rpc("blockchain.block.headers", startHeight, count);
if (blockJson == null || !blockJson.containsKey("count") || !blockJson.containsKey("hex"))
Object blockObj = this.rpc("blockchain.block.headers", startHeight, count);
if (!(blockObj instanceof JSONObject))
return null;
JSONObject blockJson = (JSONObject) blockObj;
if (!blockJson.containsKey("count") || !blockJson.containsKey("hex"))
return null;
Long returnedCount = (Long) blockJson.get("count");
@@ -155,57 +180,87 @@ public class ElectrumX {
return rawBlockHeaders;
}
/** Returns confirmed balance, based on passed payment script, or null if there was an error or no known balance. */
public Long getBalance(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
JSONObject balanceJson = (JSONObject) this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
if (balanceJson == null || !balanceJson.containsKey("confirmed"))
Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
if (!(balanceObj instanceof JSONObject))
return null;
JSONObject balanceJson = (JSONObject) balanceObj;
if (!balanceJson.containsKey("confirmed"))
return null;
return (Long) balanceJson.get("confirmed");
}
public List<Pair<byte[], Integer>> getUnspentOutputs(byte[] script) {
/** Unspent output info as returned by ElectrumX network. */
public static class UnspentOutput {
public final byte[] hash;
public final int index;
public final int height;
public final long value;
public UnspentOutput(byte[] hash, int index, int height, long value) {
this.hash = hash;
this.index = index;
this.height = height;
this.value = value;
}
}
/** Returns list of unspent outputs pertaining to passed payment script, or null if there was an error. */
public List<UnspentOutput> getUnspentOutputs(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
JSONArray unspentJson = (JSONArray) this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
if (unspentJson == null)
Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
if (!(unspentJson instanceof JSONArray))
return null;
List<Pair<byte[], Integer>> unspentOutputs = new ArrayList<>();
for (Object rawUnspent : unspentJson) {
List<UnspentOutput> unspentOutputs = new ArrayList<>();
for (Object rawUnspent : (JSONArray) unspentJson) {
JSONObject unspent = (JSONObject) rawUnspent;
int height = ((Long) unspent.get("height")).intValue();
// We only want unspent outputs from confirmed transactions (and definitely not mempool duplicates with height 0)
if (height <= 0)
continue;
byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes();
int outputIndex = ((Long) unspent.get("tx_pos")).intValue();
long value = (Long) unspent.get("value");
unspentOutputs.add(new Pair<>(txHash, outputIndex));
unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value));
}
return unspentOutputs;
}
/** Returns raw transaction for passed transaction hash, or null if not found. */
public byte[] getRawTransaction(byte[] txHash) {
String rawTransactionHex = (String) this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString());
if (rawTransactionHex == null)
Object rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString());
if (!(rawTransactionHex instanceof String))
return null;
return HashCode.fromString(rawTransactionHex).asBytes();
return HashCode.fromString((String) rawTransactionHex).asBytes();
}
/** Returns list of raw transactions, relating to passed payment script, if null if there's an error. */
public List<byte[]> getAddressTransactions(byte[] script) {
byte[] scriptHash = Crypto.digest(script);
Bytes.reverse(scriptHash);
JSONArray transactionsJson = (JSONArray) this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
if (transactionsJson == null)
Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
if (!(transactionsJson instanceof JSONArray))
return null;
List<byte[]> rawTransactions = new ArrayList<>();
for (Object rawTransactionInfo : transactionsJson) {
for (Object rawTransactionInfo : (JSONArray) transactionsJson) {
JSONObject transactionInfo = (JSONObject) rawTransactionInfo;
// We only want confirmed transactions
@@ -223,6 +278,7 @@ public class ElectrumX {
return rawTransactions;
}
/** Returns true if raw transaction successfully broadcast. */
public boolean broadcastTransaction(byte[] transactionBytes) {
Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString());
if (rawBroadcastResult == null)
@@ -235,14 +291,15 @@ public class ElectrumX {
// Class-private utility methods
/** Query current server for its list of peer servers, and return those we can parse. */
private Set<Server> serverPeersSubscribe() {
Set<Server> newServers = new HashSet<>();
JSONArray peers = (JSONArray) this.connectedRpc("server.peers.subscribe");
if (peers == null)
Object peers = this.connectedRpc("server.peers.subscribe");
if (!(peers instanceof JSONArray))
return newServers;
for (Object rawPeer : peers) {
for (Object rawPeer : (JSONArray) peers) {
JSONArray peer = (JSONArray) rawPeer;
if (peer.size() < 3)
continue;
@@ -287,6 +344,7 @@ public class ElectrumX {
return newServers;
}
/** Return output from RPC call, with automatic reconnection to different server if needed. */
private synchronized Object rpc(String method, Object...params) {
while (haveConnection()) {
Object response = connectedRpc(method, params);
@@ -305,6 +363,7 @@ public class ElectrumX {
return null;
}
/** Returns true if we have, or create, a connection to an ElectrumX server. */
private boolean haveConnection() {
if (this.currentServer != null)
return true;
@@ -377,10 +436,12 @@ public class ElectrumX {
if (response.isEmpty())
return null;
JSONObject responseJson = (JSONObject) JSONValue.parse(response);
if (responseJson == null)
Object responseObj = JSONValue.parse(response);
if (!(responseObj instanceof JSONObject))
return null;
JSONObject responseJson = (JSONObject) responseObj;
return responseJson.get("result");
}

View File

@@ -4,14 +4,14 @@ import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.qortal.crosschain.BTCACCT;
import io.swagger.v3.oas.annotations.media.Schema;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class CrossChainTradeData {
public enum Mode { OFFER, TRADE };
// Properties
@Schema(description = "AT's Qortal address")
@@ -20,32 +20,40 @@ public class CrossChainTradeData {
@Schema(description = "AT creator's Qortal address")
public String qortalCreator;
@Schema(description = "AT creator's Qortal trade address")
public String qortalCreatorTradeAddress;
@Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)")
public byte[] creatorBitcoinPKH;
@Schema(description = "Timestamp when AT was created (milliseconds since epoch)")
public long creationTimestamp;
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
public int tradeTimeout;
@Schema(description = "AT's current QORT balance")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long qortBalance;
@Schema(description = "HASH160 of 32-byte secret")
public byte[] secretHash;
@Schema(description = "HASH160 of 32-byte secret-A")
public byte[] hashOfSecretA;
@Schema(description = "Initial QORT payment that will be sent to Qortal trade partner")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long initialPayout;
@Schema(description = "HASH160 of 32-byte secret-B")
public byte[] hashOfSecretB;
@Schema(description = "Final QORT payment that will be sent to Qortal trade partner")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long redeemPayout;
public long qortAmount;
@Schema(description = "Trade partner's Qortal address (trade begins when this is set)")
public String qortalRecipient;
public String qortalPartnerAddress;
@Schema(description = "Timestamp when AT switched to trade mode")
public Long tradeModeTimestamp;
@Schema(description = "How long from beginning trade until AT triggers automatic refund to AT creator (minutes)")
public long tradeRefundTimeout;
@Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)")
public Integer refundTimeout;
@Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)")
public Integer tradeRefundHeight;
@@ -54,10 +62,19 @@ public class CrossChainTradeData {
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long expectedBitcoin;
public Mode mode;
public BTCACCT.Mode mode;
@Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout")
public Integer lockTime;
@Schema(description = "Suggested Bitcoin P2SH-A nLockTime based on trade timeout")
public Integer lockTimeA;
@Schema(description = "Suggested Bitcoin P2SH-B nLockTime based on trade timeout")
public Integer lockTimeB;
@Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)")
public byte[] partnerBitcoinPKH;
@Schema(description = "Trade partner's Qortal receiving address")
public String qortalPartnerReceivingAddress;
// Constructors

View File

@@ -0,0 +1,193 @@
package org.qortal.data.crosschain;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
import java.util.Map;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlTransient;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import io.swagger.v3.oas.annotations.media.Schema;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
public class TradeBotData {
private byte[] tradePrivateKey;
public enum State {
BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(15), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(25), BOB_DONE(30), BOB_REFUNDED(35),
ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_DONE(95), ALICE_REFUNDING_B(100), ALICE_REFUNDING_A(105), ALICE_REFUNDED(110);
public final int value;
private static final Map<Integer, State> map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
State(int value) {
this.value = value;
}
public static State valueOf(int value) {
return map.get(value);
}
}
private State tradeState;
private String creatorAddress;
private String atAddress;
private long timestamp;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long qortAmount;
private byte[] tradeNativePublicKey;
private byte[] tradeNativePublicKeyHash;
String tradeNativeAddress;
private byte[] secret;
private byte[] hashOfSecret;
private byte[] tradeForeignPublicKey;
private byte[] tradeForeignPublicKeyHash;
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long bitcoinAmount;
// Never expose this via API
@XmlTransient
@Schema(hidden = true)
private String xprv58;
private byte[] lastTransactionSignature;
private Integer lockTimeA;
// Could be Bitcoin or Qortal...
private byte[] receivingAccountInfo;
protected TradeBotData() {
/* JAXB */
}
public TradeBotData(byte[] tradePrivateKey, State tradeState, String creatorAddress, String atAddress,
long timestamp, long qortAmount,
byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress,
byte[] secret, byte[] hashOfSecret,
byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash,
long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) {
this.tradePrivateKey = tradePrivateKey;
this.tradeState = tradeState;
this.creatorAddress = creatorAddress;
this.atAddress = atAddress;
this.timestamp = timestamp;
this.qortAmount = qortAmount;
this.tradeNativePublicKey = tradeNativePublicKey;
this.tradeNativePublicKeyHash = tradeNativePublicKeyHash;
this.tradeNativeAddress = tradeNativeAddress;
this.secret = secret;
this.hashOfSecret = hashOfSecret;
this.tradeForeignPublicKey = tradeForeignPublicKey;
this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash;
this.bitcoinAmount = bitcoinAmount;
this.xprv58 = xprv58;
this.lastTransactionSignature = lastTransactionSignature;
this.lockTimeA = lockTimeA;
this.receivingAccountInfo = receivingAccountInfo;
}
public byte[] getTradePrivateKey() {
return this.tradePrivateKey;
}
public State getState() {
return this.tradeState;
}
public void setState(State state) {
this.tradeState = state;
}
public String getCreatorAddress() {
return this.creatorAddress;
}
public String getAtAddress() {
return this.atAddress;
}
public void setAtAddress(String atAddress) {
this.atAddress = atAddress;
}
public long getTimestamp() {
return this.timestamp;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
public long getQortAmount() {
return this.qortAmount;
}
public byte[] getTradeNativePublicKey() {
return this.tradeNativePublicKey;
}
public byte[] getTradeNativePublicKeyHash() {
return this.tradeNativePublicKeyHash;
}
public String getTradeNativeAddress() {
return this.tradeNativeAddress;
}
public byte[] getSecret() {
return this.secret;
}
public byte[] getHashOfSecret() {
return this.hashOfSecret;
}
public byte[] getTradeForeignPublicKey() {
return this.tradeForeignPublicKey;
}
public byte[] getTradeForeignPublicKeyHash() {
return this.tradeForeignPublicKeyHash;
}
public long getBitcoinAmount() {
return this.bitcoinAmount;
}
public String getXprv58() {
return this.xprv58;
}
public byte[] getLastTransactionSignature() {
return this.lastTransactionSignature;
}
public void setLastTransactionSignature(byte[] lastTransactionSignature) {
this.lastTransactionSignature = lastTransactionSignature;
}
public Integer getLockTimeA() {
return this.lockTimeA;
}
public void setLockTimeA(Integer lockTimeA) {
this.lockTimeA = lockTimeA;
}
public byte[] getReceivingAccountInfo() {
return this.receivingAccountInfo;
}
}

View File

@@ -0,0 +1,5 @@
package org.qortal.event;
public interface Event {
}

View File

@@ -0,0 +1,33 @@
package org.qortal.event;
import java.util.ArrayList;
import java.util.List;
public enum EventBus {
INSTANCE;
private static final List<Listener> LISTENERS = new ArrayList<>();
public void addListener(Listener newListener) {
synchronized (LISTENERS) {
LISTENERS.add(newListener);
}
}
public void removeListener(Listener listener) {
synchronized (LISTENERS) {
LISTENERS.remove(listener);
}
}
public void notify(Event event) {
List<Listener> clonedListeners;
synchronized (LISTENERS) {
clonedListeners = new ArrayList<>(LISTENERS);
}
for (Listener listener : clonedListeners)
listener.listen(event);
}
}

View File

@@ -0,0 +1,6 @@
package org.qortal.event;
@FunctionalInterface
public interface Listener {
void listen(Event event);
}

View File

@@ -15,6 +15,9 @@ public interface ATRepository {
/** Returns where AT with passed address exists in repository */
public boolean exists(String atAddress) throws DataException;
/** Returns AT creator's public key, or null if not found */
public byte[] getCreatorPublicKey(String atAddress) throws DataException;
/** Returns list of executable ATs, empty if none found */
public List<ATData> getAllExecutableATs() throws DataException;
@@ -54,6 +57,24 @@ public interface ATRepository {
*/
public ATStateData getLatestATState(String atAddress) throws DataException;
/**
* Returns final ATStateData for ATs matching codeHash (required)
* and specific data segment value (optional).
* <p>
* If searching for specific data segment value, both <tt>dataByteOffset</tt>
* and <tt>expectedValue</tt> need to be non-null.
* <p>
* Note that <tt>dataByteOffset</tt> starts from 0 and will typically be
* a multiple of <tt>MachineState.VALUE_SIZE</tt>, which is usually 8:
* width of a long.
* <p>
* Although <tt>expectedValue</tt>, if provided, is natively an unsigned long,
* the data segment comparison is done via unsigned hex string.
*/
public List<ATStateData> getMatchingFinalATStates(byte[] codeHash, Boolean isFinished,
Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight,
Integer limit, Integer offset, Boolean reverse) throws DataException;
/**
* Returns all ATStateData for a given block height.
* <p>
@@ -88,4 +109,28 @@ public interface ATRepository {
/** Delete state data for all ATs at this height */
public void deleteATStates(int height) throws DataException;
// Finding transactions for ATs to process
static class NextTransactionInfo {
public final int height;
public final int sequence;
public final byte[] signature;
public NextTransactionInfo(int height, int sequence, byte[] signature) {
this.height = height;
this.sequence = sequence;
this.signature = signature;
}
}
/**
* Find next transaction for AT to process.
* <p>
* @param recipient AT address
* @param height starting height
* @param sequence starting sequence
* @return next transaction info, or null if none found
*/
public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException;
}

View File

@@ -61,6 +61,15 @@ public interface BlockRepository {
*/
public int getHeightFromTimestamp(long timestamp) throws DataException;
/**
* Returns block timestamp for a given height.
*
* @param height
* @return timestamp, or 0 if height is out of bounds.
* @throws DataException
*/
public long getTimestampFromHeight(int height) throws DataException;
/**
* Return highest block height from repository.
*

View File

@@ -0,0 +1,18 @@
package org.qortal.repository;
import java.util.List;
import org.qortal.data.crosschain.TradeBotData;
public interface CrossChainRepository {
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException;
public List<TradeBotData> getAllTradeBotData() throws DataException;
public void save(TradeBotData tradeBotData) throws DataException;
/** Delete trade-bot states using passed private key. */
public int delete(byte[] tradePrivateKey) throws DataException;
}

View File

@@ -14,6 +14,8 @@ public interface Repository extends AutoCloseable {
public ChatRepository getChatRepository();
public CrossChainRepository getCrossChainRepository();
public GroupRepository getGroupRepository();
public NameRepository getNameRepository();

View File

@@ -6,6 +6,7 @@ import java.util.Map;
import org.qortal.api.resource.TransactionsResource.ConfirmationStatus;
import org.qortal.data.group.GroupApprovalData;
import org.qortal.data.transaction.GroupApprovalTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.data.transaction.TransferAssetTransactionData;
import org.qortal.transaction.Transaction.TransactionType;
@@ -107,6 +108,18 @@ public interface TransactionRepository {
*/
public byte[] getLatestAutoUpdateTransaction(TransactionType txType, int txGroupId, Integer service) throws DataException;
/**
* Returns list of MESSAGE transaction data matching recipient.
* @param recipient
* @param limit
* @param offset
* @param reverse
* @return
* @throws DataException
*/
public List<MessageTransactionData> getMessagesByRecipient(String recipient,
Integer limit, Integer offset, Boolean reverse) throws DataException;
/**
* Returns list of transactions relating to specific asset ID.
*

View File

@@ -68,6 +68,20 @@ public class HSQLDBATRepository implements ATRepository {
}
}
@Override
public byte[] getCreatorPublicKey(String atAddress) throws DataException {
String sql = "SELECT creator FROM ATs WHERE AT_address = ?";
try (ResultSet resultSet = this.repository.checkedExecute(sql, atAddress)) {
if (resultSet == null)
return null;
return resultSet.getBytes(1);
} catch (SQLException e) {
throw new DataException("Unable to fetch AT creator's public key from repository", e);
}
}
@Override
public List<ATData> getAllExecutableATs() throws DataException {
String sql = "SELECT AT_address, creator, created_when, version, asset_id, code_bytes, code_hash, "
@@ -273,6 +287,78 @@ public class HSQLDBATRepository implements ATRepository {
}
}
@Override
public List<ATStateData> getMatchingFinalATStates(byte[] codeHash, Boolean isFinished,
Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight,
Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(1024);
sql.append("SELECT AT_address, height, created_when, state_data, state_hash, fees, is_initial "
+ "FROM ATs "
+ "CROSS JOIN LATERAL("
+ "SELECT height, created_when, state_data, state_hash, fees, is_initial "
+ "FROM ATStates "
+ "WHERE ATStates.AT_address = ATs.AT_address "
+ "ORDER BY height DESC "
+ "LIMIT 1"
+ ") AS FinalATStates "
+ "WHERE code_hash = ? ");
List<Object> bindParams = new ArrayList<>();
bindParams.add(codeHash);
if (isFinished != null) {
sql.append("AND is_finished = ?");
bindParams.add(isFinished);
}
if (dataByteOffset != null && expectedValue != null) {
sql.append("AND RAWTOHEX(SUBSTRING(state_data FROM ? FOR 8)) = ? ");
// We convert our long to hex Java-side to control endian
String expectedHexValue = String.format("%016x", expectedValue); // left-zero-padding and conversion
// SQL binary data offsets start at 1
bindParams.add(dataByteOffset + 1);
bindParams.add(expectedHexValue);
}
if (minimumFinalHeight != null) {
sql.append("AND height >= ");
sql.append(minimumFinalHeight);
}
sql.append(" ORDER BY height ");
if (reverse != null && reverse)
sql.append("DESC");
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<ATStateData> atStates = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
if (resultSet == null)
return atStates;
do {
String atAddress = resultSet.getString(1);
int height = resultSet.getInt(2);
long created = resultSet.getLong(3);
byte[] stateData = resultSet.getBytes(4); // Actually BLOB
byte[] stateHash = resultSet.getBytes(5);
long fees = resultSet.getLong(6);
boolean isInitial = resultSet.getBoolean(7);
ATStateData atStateData = new ATStateData(atAddress, height, created, stateData, stateHash, fees, isInitial);
atStates.add(atStateData);
} while (resultSet.next());
return atStates;
} catch (SQLException e) {
throw new DataException("Unable to fetch matching AT states from repository", e);
}
}
@Override
public List<ATStateData> getBlockATStatesAtHeight(int height) throws DataException {
String sql = "SELECT AT_address, state_hash, fees, is_initial "
@@ -341,4 +427,40 @@ public class HSQLDBATRepository implements ATRepository {
}
}
// Finding transactions for ATs to process
public NextTransactionInfo findNextTransaction(String recipient, int height, int sequence) throws DataException {
// We only need to search for a subset of transaction types: MESSAGE, PAYMENT or AT
String sql = "SELECT height, sequence, Transactions.signature "
+ "FROM ("
+ "SELECT signature FROM PaymentTransactions WHERE recipient = ? "
+ "UNION "
+ "SELECT signature FROM MessageTransactions WHERE recipient = ? "
+ "UNION "
+ "SELECT signature FROM ATTransactions WHERE recipient = ?"
+ ") AS Transactions "
+ "JOIN BlockTransactions ON BlockTransactions.transaction_signature = Transactions.signature "
+ "JOIN Blocks ON Blocks.signature = BlockTransactions.block_signature "
+ "WHERE (height > ? OR (height = ? AND sequence > ?)) "
+ "ORDER BY height ASC, sequence ASC "
+ "LIMIT 1";
Object[] bindParams = new Object[] { recipient, recipient, recipient, height, height, sequence };
try (ResultSet resultSet = this.repository.checkedExecute(sql, bindParams)) {
if (resultSet == null)
return null;
int nextHeight = resultSet.getInt(1);
int nextSequence = resultSet.getInt(2);
byte[] nextSignature = resultSet.getBytes(3);
return new NextTransactionInfo(nextHeight, nextSequence, nextSignature);
} catch (SQLException e) {
throw new DataException("Unable to find next transaction to AT from repository", e);
}
}
}

View File

@@ -132,6 +132,20 @@ public class HSQLDBBlockRepository implements BlockRepository {
}
}
@Override
public long getTimestampFromHeight(int height) throws DataException {
String sql = "SELECT minted_when FROM Blocks WHERE height = ?";
try (ResultSet resultSet = this.repository.checkedExecute(sql, height)) {
if (resultSet == null)
return 0;
return resultSet.getLong(1);
} catch (SQLException e) {
throw new DataException("Error obtaining block timestamp by height from repository", e);
}
}
@Override
public int getBlockchainHeight() throws DataException {
String sql = "SELECT height FROM Blocks ORDER BY height DESC LIMIT 1";

View File

@@ -0,0 +1,165 @@
package org.qortal.repository.hsqldb;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import org.qortal.data.crosschain.TradeBotData;
import org.qortal.repository.CrossChainRepository;
import org.qortal.repository.DataException;
public class HSQLDBCrossChainRepository implements CrossChainRepository {
protected HSQLDBRepository repository;
public HSQLDBCrossChainRepository(HSQLDBRepository repository) {
this.repository = repository;
}
@Override
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException {
String sql = "SELECT trade_state, creator_address, at_address, "
+ "updated_when, qort_amount, "
+ "trade_native_public_key, trade_native_public_key_hash, "
+ "trade_native_address, secret, hash_of_secret, "
+ "trade_foreign_public_key, trade_foreign_public_key_hash, "
+ "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info "
+ "FROM TradeBotStates "
+ "WHERE trade_private_key = ?";
try (ResultSet resultSet = this.repository.checkedExecute(sql, tradePrivateKey)) {
if (resultSet == null)
return null;
int tradeStateValue = resultSet.getInt(1);
TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue);
if (tradeState == null)
throw new DataException("Illegal trade-bot trade-state fetched from repository");
String creatorAddress = resultSet.getString(2);
String atAddress = resultSet.getString(3);
long timestamp = resultSet.getLong(4);
long qortAmount = resultSet.getLong(5);
byte[] tradeNativePublicKey = resultSet.getBytes(6);
byte[] tradeNativePublicKeyHash = resultSet.getBytes(7);
String tradeNativeAddress = resultSet.getString(8);
byte[] secret = resultSet.getBytes(9);
byte[] hashOfSecret = resultSet.getBytes(10);
byte[] tradeForeignPublicKey = resultSet.getBytes(11);
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(12);
long bitcoinAmount = resultSet.getLong(13);
String xprv58 = resultSet.getString(14);
byte[] lastTransactionSignature = resultSet.getBytes(15);
Integer lockTimeA = resultSet.getInt(16);
if (lockTimeA == 0 && resultSet.wasNull())
lockTimeA = null;
byte[] receivingAccountInfo = resultSet.getBytes(17);
return new TradeBotData(tradePrivateKey, tradeState,
creatorAddress, atAddress, timestamp, qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secret, hashOfSecret,
tradeForeignPublicKey, tradeForeignPublicKeyHash,
bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo);
} catch (SQLException e) {
throw new DataException("Unable to fetch trade-bot trading state from repository", e);
}
}
@Override
public List<TradeBotData> getAllTradeBotData() throws DataException {
String sql = "SELECT trade_private_key, trade_state, creator_address, at_address, "
+ "updated_when, qort_amount, "
+ "trade_native_public_key, trade_native_public_key_hash, "
+ "trade_native_address, secret, hash_of_secret, "
+ "trade_foreign_public_key, trade_foreign_public_key_hash, "
+ "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info "
+ "FROM TradeBotStates";
List<TradeBotData> allTradeBotData = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql)) {
if (resultSet == null)
return allTradeBotData;
do {
byte[] tradePrivateKey = resultSet.getBytes(1);
int tradeStateValue = resultSet.getInt(2);
TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue);
if (tradeState == null)
throw new DataException("Illegal trade-bot trade-state fetched from repository");
String creatorAddress = resultSet.getString(3);
String atAddress = resultSet.getString(4);
long timestamp = resultSet.getLong(5);
long qortAmount = resultSet.getLong(6);
byte[] tradeNativePublicKey = resultSet.getBytes(7);
byte[] tradeNativePublicKeyHash = resultSet.getBytes(8);
String tradeNativeAddress = resultSet.getString(9);
byte[] secret = resultSet.getBytes(10);
byte[] hashOfSecret = resultSet.getBytes(11);
byte[] tradeForeignPublicKey = resultSet.getBytes(12);
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(13);
long bitcoinAmount = resultSet.getLong(14);
String xprv58 = resultSet.getString(15);
byte[] lastTransactionSignature = resultSet.getBytes(16);
Integer lockTimeA = resultSet.getInt(17);
if (lockTimeA == 0 && resultSet.wasNull())
lockTimeA = null;
byte[] receivingAccountInfo = resultSet.getBytes(18);
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState,
creatorAddress, atAddress, timestamp, qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secret, hashOfSecret,
tradeForeignPublicKey, tradeForeignPublicKeyHash,
bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo);
allTradeBotData.add(tradeBotData);
} while (resultSet.next());
return allTradeBotData;
} catch (SQLException e) {
throw new DataException("Unable to fetch trade-bot trading states from repository", e);
}
}
@Override
public void save(TradeBotData tradeBotData) throws DataException {
HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates");
saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey())
.bind("trade_state", tradeBotData.getState().value)
.bind("creator_address", tradeBotData.getCreatorAddress())
.bind("at_address", tradeBotData.getAtAddress())
.bind("updated_when", tradeBotData.getTimestamp())
.bind("qort_amount", tradeBotData.getQortAmount())
.bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey())
.bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash())
.bind("trade_native_address", tradeBotData.getTradeNativeAddress())
.bind("secret", tradeBotData.getSecret()).bind("hash_of_secret", tradeBotData.getHashOfSecret())
.bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey())
.bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash())
.bind("bitcoin_amount", tradeBotData.getBitcoinAmount())
.bind("xprv58", tradeBotData.getXprv58())
.bind("last_transaction_signature", tradeBotData.getLastTransactionSignature())
.bind("locktime_a", tradeBotData.getLockTimeA())
.bind("receiving_account_info", tradeBotData.getReceivingAccountInfo());
try {
saveHelper.execute(this.repository);
} catch (SQLException e) {
throw new DataException("Unable to save trade bot data into repository", e);
}
}
@Override
public int delete(byte[] tradePrivateKey) throws DataException {
try {
return this.repository.delete("TradeBotStates", "trade_private_key = ?", tradePrivateKey);
} catch (SQLException e) {
throw new DataException("Unable to delete trade-bot states from repository", e);
}
}
}

View File

@@ -4,9 +4,14 @@ import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.HashMap;
import java.util.Map;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.qortal.utils.Base58;
import com.google.common.hash.HashCode;
public class HSQLDBDatabaseUpdates {
@@ -618,6 +623,22 @@ public class HSQLDBDatabaseUpdates {
stmt.execute("CREATE TABLE PublicizeTransactions (signature Signature, nonce INT NOT NULL, " + TRANSACTION_KEYS + ")");
break;
case 20:
// Trade bot
stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, "
+ "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, "
+ "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, "
+ "trade_native_address QortalAddress NOT NULL, secret VARBINARY(32) NOT NULL, hash_of_secret VARBINARY(32) NOT NULL, "
+ "trade_foreign_public_key VARBINARY(33) NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, "
+ "bitcoin_amount BIGINT NOT NULL, xprv58 VARCHAR(200), last_transaction_signature Signature, locktime_a BIGINT, "
+ "receiving_account_info VARBINARY(32) NOT NULL, PRIMARY KEY (trade_private_key))");
break;
case 21:
// AT functionality index
stmt.execute("CREATE INDEX IF NOT EXISTS ATCodeHashIndex ON ATs (code_hash, is_finished)");
break;
default:
// nothing to do
return false;

View File

@@ -32,6 +32,7 @@ import org.qortal.repository.ArbitraryRepository;
import org.qortal.repository.AssetRepository;
import org.qortal.repository.BlockRepository;
import org.qortal.repository.ChatRepository;
import org.qortal.repository.CrossChainRepository;
import org.qortal.repository.DataException;
import org.qortal.repository.GroupRepository;
import org.qortal.repository.NameRepository;
@@ -115,6 +116,11 @@ public class HSQLDBRepository implements Repository {
return new HSQLDBChatRepository(this);
}
@Override
public CrossChainRepository getCrossChainRepository() {
return new HSQLDBCrossChainRepository(this);
}
@Override
public GroupRepository getGroupRepository() {
return new HSQLDBGroupRepository(this);

View File

@@ -19,6 +19,7 @@ import org.qortal.data.PaymentData;
import org.qortal.data.group.GroupApprovalData;
import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.GroupApprovalTransactionData;
import org.qortal.data.transaction.MessageTransactionData;
import org.qortal.data.transaction.TransactionData;
import org.qortal.data.transaction.TransferAssetTransactionData;
import org.qortal.repository.DataException;
@@ -630,6 +631,43 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
}
}
@Override
public List<MessageTransactionData> getMessagesByRecipient(String recipient,
Integer limit, Integer offset, Boolean reverse) throws DataException {
StringBuilder sql = new StringBuilder(1024);
sql.append("SELECT signature from MessageTransactions "
+ "JOIN Transactions USING (signature) "
+ "JOIN BlockTransactions ON transaction_signature = signature "
+ "WHERE recipient = ?");
sql.append("ORDER BY Transactions.created_when");
sql.append((reverse == null || !reverse) ? " ASC" : " DESC");
HSQLDBRepository.limitOffsetSql(sql, limit, offset);
List<MessageTransactionData> messageTransactionsData = new ArrayList<>();
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), recipient)) {
if (resultSet == null)
return messageTransactionsData;
do {
byte[] signature = resultSet.getBytes(1);
TransactionData transactionData = this.fromSignature(signature);
if (transactionData == null || transactionData.getType() != TransactionType.MESSAGE)
return null;
messageTransactionsData.add((MessageTransactionData) transactionData);
} while (resultSet.next());
return messageTransactionsData;
} catch (SQLException e) {
throw new DataException("Unable to fetch trade-bot messages from repository", e);
}
}
@Override
public List<TransactionData> getAssetTransactions(long assetId, ConfirmationStatus confirmationStatus, Integer limit, Integer offset, Boolean reverse)
throws DataException {

View File

@@ -26,7 +26,7 @@ import com.google.common.base.Utf8;
public class DeployAtTransaction extends Transaction {
// Properties
private DeployAtTransactionData deployATTransactionData;
private DeployAtTransactionData deployAtTransactionData;
// Other useful constants
public static final int MAX_NAME_SIZE = 200;
@@ -40,31 +40,31 @@ public class DeployAtTransaction extends Transaction {
public DeployAtTransaction(Repository repository, TransactionData transactionData) {
super(repository, transactionData);
this.deployATTransactionData = (DeployAtTransactionData) this.transactionData;
this.deployAtTransactionData = (DeployAtTransactionData) this.transactionData;
}
// More information
@Override
public List<String> getRecipientAddresses() throws DataException {
return Collections.singletonList(this.deployATTransactionData.getAtAddress());
return Collections.singletonList(this.deployAtTransactionData.getAtAddress());
}
/** Returns AT version from the header bytes */
private short getVersion() {
byte[] creationBytes = deployATTransactionData.getCreationBytes();
byte[] creationBytes = deployAtTransactionData.getCreationBytes();
return (short) ((creationBytes[0] << 8) | (creationBytes[1] & 0xff)); // Big-endian
}
/** Make sure deployATTransactionData has an ATAddress */
private void ensureATAddress() throws DataException {
if (this.deployATTransactionData.getAtAddress() != null)
public static void ensureATAddress(DeployAtTransactionData deployAtTransactionData) throws DataException {
if (deployAtTransactionData.getAtAddress() != null)
return;
// Use transaction transformer
try {
String atAddress = Crypto.toATAddress(TransactionTransformer.toBytesForSigning(this.deployATTransactionData));
this.deployATTransactionData.setAtAddress(atAddress);
String atAddress = Crypto.toATAddress(TransactionTransformer.toBytesForSigning(deployAtTransactionData));
deployAtTransactionData.setAtAddress(atAddress);
} catch (TransformationException e) {
throw new DataException("Unable to generate AT address");
}
@@ -73,9 +73,9 @@ public class DeployAtTransaction extends Transaction {
// Navigation
public Account getATAccount() throws DataException {
ensureATAddress();
ensureATAddress(this.deployAtTransactionData);
return new Account(this.repository, this.deployATTransactionData.getAtAddress());
return new Account(this.repository, this.deployAtTransactionData.getAtAddress());
}
// Processing
@@ -83,30 +83,30 @@ public class DeployAtTransaction extends Transaction {
@Override
public ValidationResult isValid() throws DataException {
// Check name size bounds
int nameLength = Utf8.encodedLength(this.deployATTransactionData.getName());
int nameLength = Utf8.encodedLength(this.deployAtTransactionData.getName());
if (nameLength < 1 || nameLength > MAX_NAME_SIZE)
return ValidationResult.INVALID_NAME_LENGTH;
// Check description size bounds
int descriptionlength = Utf8.encodedLength(this.deployATTransactionData.getDescription());
int descriptionlength = Utf8.encodedLength(this.deployAtTransactionData.getDescription());
if (descriptionlength < 1 || descriptionlength > MAX_DESCRIPTION_SIZE)
return ValidationResult.INVALID_DESCRIPTION_LENGTH;
// Check AT-type size bounds
int atTypeLength = Utf8.encodedLength(this.deployATTransactionData.getAtType());
int atTypeLength = Utf8.encodedLength(this.deployAtTransactionData.getAtType());
if (atTypeLength < 1 || atTypeLength > MAX_AT_TYPE_SIZE)
return ValidationResult.INVALID_AT_TYPE_LENGTH;
// Check tags size bounds
int tagsLength = Utf8.encodedLength(this.deployATTransactionData.getTags());
int tagsLength = Utf8.encodedLength(this.deployAtTransactionData.getTags());
if (tagsLength < 1 || tagsLength > MAX_TAGS_SIZE)
return ValidationResult.INVALID_TAGS_LENGTH;
// Check amount is positive
if (this.deployATTransactionData.getAmount() <= 0)
if (this.deployAtTransactionData.getAmount() <= 0)
return ValidationResult.NEGATIVE_AMOUNT;
long assetId = this.deployATTransactionData.getAssetId();
long assetId = this.deployAtTransactionData.getAssetId();
AssetData assetData = this.repository.getAssetRepository().fromAssetId(assetId);
// Check asset even exists
if (assetData == null)
@@ -117,7 +117,7 @@ public class DeployAtTransaction extends Transaction {
return ValidationResult.ASSET_NOT_SPENDABLE;
// Check asset amount is integer if asset is not divisible
if (!assetData.isDivisible() && this.deployATTransactionData.getAmount() % Amounts.MULTIPLIER != 0)
if (!assetData.isDivisible() && this.deployAtTransactionData.getAmount() % Amounts.MULTIPLIER != 0)
return ValidationResult.INVALID_AMOUNT;
Account creator = this.getCreator();
@@ -125,15 +125,15 @@ public class DeployAtTransaction extends Transaction {
// Check creator has enough funds
if (assetId == Asset.QORT) {
// Simple case: amount and fee both in QORT
long minimumBalance = this.deployATTransactionData.getFee() + this.deployATTransactionData.getAmount();
long minimumBalance = this.deployAtTransactionData.getFee() + this.deployAtTransactionData.getAmount();
if (creator.getConfirmedBalance(Asset.QORT) < minimumBalance)
return ValidationResult.NO_BALANCE;
} else {
if (creator.getConfirmedBalance(Asset.QORT) < this.deployATTransactionData.getFee())
if (creator.getConfirmedBalance(Asset.QORT) < this.deployAtTransactionData.getFee())
return ValidationResult.NO_BALANCE;
if (creator.getConfirmedBalance(assetId) < this.deployATTransactionData.getAmount())
if (creator.getConfirmedBalance(assetId) < this.deployAtTransactionData.getAmount())
return ValidationResult.NO_BALANCE;
}
@@ -142,12 +142,12 @@ public class DeployAtTransaction extends Transaction {
return ValidationResult.INVALID_CREATION_BYTES;
// Check creation bytes are valid (for v2+)
this.ensureATAddress();
ensureATAddress(this.deployAtTransactionData);
// Just enough AT data to allow API to query initial balances, etc.
String atAddress = this.deployATTransactionData.getAtAddress();
byte[] creatorPublicKey = this.deployATTransactionData.getCreatorPublicKey();
long creation = this.deployATTransactionData.getTimestamp();
String atAddress = this.deployAtTransactionData.getAtAddress();
byte[] creatorPublicKey = this.deployAtTransactionData.getCreatorPublicKey();
long creation = this.deployAtTransactionData.getTimestamp();
ATData skeletonAtData = new ATData(atAddress, creatorPublicKey, creation, assetId);
int height = this.repository.getBlockRepository().getBlockchainHeight() + 1;
@@ -157,7 +157,7 @@ public class DeployAtTransaction extends Transaction {
QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance();
try {
new MachineState(api, loggerFactory, this.deployATTransactionData.getCreationBytes());
new MachineState(api, loggerFactory, this.deployAtTransactionData.getCreationBytes());
} catch (IllegalArgumentException e) {
// Not valid
return ValidationResult.INVALID_CREATION_BYTES;
@@ -169,25 +169,25 @@ public class DeployAtTransaction extends Transaction {
@Override
public ValidationResult isProcessable() throws DataException {
Account creator = getCreator();
long assetId = this.deployATTransactionData.getAssetId();
long assetId = this.deployAtTransactionData.getAssetId();
// Check creator has enough funds
if (assetId == Asset.QORT) {
// Simple case: amount and fee both in QORT
long minimumBalance = this.deployATTransactionData.getFee() + this.deployATTransactionData.getAmount();
long minimumBalance = this.deployAtTransactionData.getFee() + this.deployAtTransactionData.getAmount();
if (creator.getConfirmedBalance(Asset.QORT) < minimumBalance)
return ValidationResult.NO_BALANCE;
} else {
if (creator.getConfirmedBalance(Asset.QORT) < this.deployATTransactionData.getFee())
if (creator.getConfirmedBalance(Asset.QORT) < this.deployAtTransactionData.getFee())
return ValidationResult.NO_BALANCE;
if (creator.getConfirmedBalance(assetId) < this.deployATTransactionData.getAmount())
if (creator.getConfirmedBalance(assetId) < this.deployAtTransactionData.getAmount())
return ValidationResult.NO_BALANCE;
}
// Check AT doesn't already exist
if (this.repository.getATRepository().exists(this.deployATTransactionData.getAtAddress()))
if (this.repository.getATRepository().exists(this.deployAtTransactionData.getAtAddress()))
return ValidationResult.AT_ALREADY_EXISTS;
return ValidationResult.OK;
@@ -195,40 +195,40 @@ public class DeployAtTransaction extends Transaction {
@Override
public void process() throws DataException {
this.ensureATAddress();
ensureATAddress(this.deployAtTransactionData);
// Deploy AT, saving into repository
AT at = new AT(this.repository, this.deployATTransactionData);
AT at = new AT(this.repository, this.deployAtTransactionData);
at.deploy();
long assetId = this.deployATTransactionData.getAssetId();
long assetId = this.deployAtTransactionData.getAssetId();
// Update creator's balance regarding initial payment to AT
Account creator = getCreator();
creator.modifyAssetBalance(assetId, - this.deployATTransactionData.getAmount());
creator.modifyAssetBalance(assetId, - this.deployAtTransactionData.getAmount());
// Update AT's reference, which also creates AT account
Account atAccount = this.getATAccount();
atAccount.setLastReference(this.deployATTransactionData.getSignature());
atAccount.setLastReference(this.deployAtTransactionData.getSignature());
// Update AT's balance
atAccount.setConfirmedBalance(assetId, this.deployATTransactionData.getAmount());
atAccount.setConfirmedBalance(assetId, this.deployAtTransactionData.getAmount());
}
@Override
public void orphan() throws DataException {
// Delete AT from repository
AT at = new AT(this.repository, this.deployATTransactionData);
AT at = new AT(this.repository, this.deployAtTransactionData);
at.undeploy();
long assetId = this.deployATTransactionData.getAssetId();
long assetId = this.deployAtTransactionData.getAssetId();
// Update creator's balance regarding initial payment to AT
Account creator = getCreator();
creator.modifyAssetBalance(assetId, this.deployATTransactionData.getAmount());
creator.modifyAssetBalance(assetId, this.deployAtTransactionData.getAmount());
// Delete AT's account (and hence its balance)
this.repository.getAccountRepository().delete(this.deployATTransactionData.getAtAddress());
this.repository.getAccountRepository().delete(this.deployAtTransactionData.getAtAddress());
}
}

View File

@@ -26,9 +26,26 @@ public class BitTwiddling {
return new byte[] { (byte) (value), (byte) (value >> 8), (byte) (value >> 16), (byte) (value >> 24) };
}
/** Convert int to big-endian byte array */
public static byte[] toBEByteArray(int value) {
return new byte[] { (byte) (value >> 24), (byte) (value >> 16), (byte) (value >> 8), (byte) (value) };
}
/** Convert long to big-endian byte array */
public static byte[] toBEByteArray(long value) {
return new byte[] { (byte) (value >> 56), (byte) (value >> 48), (byte) (value >> 40), (byte) (value >> 32),
(byte) (value >> 24), (byte) (value >> 16), (byte) (value >> 8), (byte) (value) };
}
/** Convert little-endian bytes to int */
public static int fromLEBytes(byte[] bytes, int offset) {
public static int intFromLEBytes(byte[] bytes, int offset) {
return (bytes[offset] & 0xff) | (bytes[offset + 1] & 0xff) << 8 | (bytes[offset + 2] & 0xff) << 16 | (bytes[offset + 3] & 0xff) << 24;
}
/** Convert big-endian bytes to long */
public static long longFromBEBytes(byte[] bytes, int start) {
return (bytes[start] & 0xffL) << 56 | (bytes[start + 1] & 0xffL) << 48 | (bytes[start + 2] & 0xffL) << 40 | (bytes[start + 3] & 0xffL) << 32
| (bytes[start + 4] & 0xffL) << 24 | (bytes[start + 5] & 0xffL) << 16 | (bytes[start + 6] & 0xffL) << 8 | (bytes[start + 7] & 0xffL);
}
}