diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 52b03cac..97b42960 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -43,6 +43,7 @@ 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.TradeOffersWebSocket; import org.qortal.settings.Settings; public class ApiService { @@ -197,6 +198,7 @@ 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"); // Start server this.server.start(); diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java new file mode 100644 index 00000000..c32d00aa --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -0,0 +1,72 @@ +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; + + protected CrossChainOfferSummary() { + /* For JAXB */ + } + + public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData) { + this.qortalAtAddress = crossChainTradeData.qortalAtAddress; + this.qortalCreator = crossChainTradeData.qortalCreator; + this.qortAmount = crossChainTradeData.qortAmount; + this.btcAmount = crossChainTradeData.expectedBitcoin; + this.tradeTimeout = crossChainTradeData.tradeTimeout; + this.mode = crossChainTradeData.mode; + } + + 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; + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index fd7853c8..880acfe3 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1126,10 +1126,15 @@ public class CrossChainResource { if (limit != null && limit > 100) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] codeHash = BTCACCT.CODE_BYTES_HASH; + final Boolean isFinished = Boolean.TRUE; + final Integer minimumFinalHeight = null; try (final Repository repository = RepositoryManager.getRepository()) { - List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.REDEEMED.value, limit, offset, reverse); + List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, + BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.REDEEMED.value, + minimumFinalHeight, + limit, offset, reverse); List crossChainTrades = new ArrayList<>(); for (ATStateData atState : atStates) { diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java new file mode 100644 index 00000000..24101eef --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -0,0 +1,100 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; +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.WebSocketServlet; +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; + +@WebSocket +@SuppressWarnings("serial") +public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSocket { + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(TradeOffersWebSocket.class); + } + + @OnWebSocketConnect + public void onWebSocketConnect(Session session) { + BlockNotifier.Listener listener = blockData -> onNotify(session, blockData); + BlockNotifier.getInstance().register(session, listener); + + this.onNotify(session, null); + } + + @OnWebSocketClose + public void onWebSocketClose(Session session, int statusCode, String reason) { + BlockNotifier.getInstance().deregister(session); + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + } + + private void onNotify(Session session, BlockData blockData) { + List crossChainTradeDataList = new ArrayList<>(); + + try (final Repository repository = RepositoryManager.getRepository()) { + Integer minimumFinalHeight; + if (blockData == null) { + // If blockData is null then we send all known trade offers + minimumFinalHeight = null; + } else { + // Find any new trade ATs since this block + minimumFinalHeight = blockData.getHeight(); + } + + final Boolean isFinished = Boolean.FALSE; + + List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, + BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.OFFERING.value, + minimumFinalHeight, + null, null, null); + + // Don't send anything if no results and this isn't initial on-connection message + if (atStates == null || (atStates.isEmpty() && blockData != null)) + return; + + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState); + crossChainTradeDataList.add(crossChainTradeData); + } + } catch (DataException e) { + // No output this time? + return; + } + + try { + List crossChainOffers = crossChainTradeDataList.stream().map(crossChainTradeData -> new CrossChainOfferSummary(crossChainTradeData)).collect(Collectors.toList()); + + StringWriter stringWriter = new StringWriter(); + + this.marshall(stringWriter, crossChainOffers); + + String output = stringWriter.toString(); + session.getRemote().sendString(output); + } catch (IOException e) { + // No output this time? + } + } + +} diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index c5ffea39..bd012cdc 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -2,10 +2,8 @@ package org.qortal.data.crosschain; import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; -import javax.xml.bind.annotation.XmlElement; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; -import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import io.swagger.v3.oas.annotations.media.Schema; @@ -55,7 +53,7 @@ public class CrossChainTradeData { public Long tradeModeTimestamp; @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)") - public int refundTimeout; + public Integer refundTimeout; @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)") public Integer tradeRefundHeight; @@ -81,24 +79,4 @@ public class CrossChainTradeData { public CrossChainTradeData() { } - // We can represent BitcoinPKH as an address - @XmlElement(name = "creatorBitcoinAddress") - @Schema(description = "AT creator's trading Bitcoin PKH in address form") - public String getCreatorBitcoinAddress() { - if (this.creatorBitcoinPKH == null) - return null; - - return BTC.getInstance().pkhToAddress(this.creatorBitcoinPKH); - } - - // We can represent BitcoinPKH as an address - @XmlElement(name = "recipientBitcoinAddress") - @Schema(description = "Trade partner's trading Bitcoin PKH in address form") - public String getRecipientBitcoinAddress() { - if (this.partnerBitcoinPKH == null) - return null; - - return BTC.getInstance().pkhToAddress(this.partnerBitcoinPKH); - } - } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 05d9fb21..f3c2b16d 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -71,8 +71,8 @@ public interface ATRepository { * Although expectedValue, if provided, is natively an unsigned long, * the data segment comparison is done via unsigned hex string. */ - public List getMatchingFinalATStates(byte[] codeHash, - Integer dataByteOffset, Long expectedValue, + public List getMatchingFinalATStates(byte[] codeHash, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, Integer limit, Integer offset, Boolean reverse) throws DataException; /** diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 3318d715..e223b760 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -288,8 +288,8 @@ public class HSQLDBATRepository implements ATRepository { } @Override - public List getMatchingFinalATStates(byte[] codeHash, - Integer dataByteOffset, Long expectedValue, + public List 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 " @@ -301,9 +301,15 @@ public class HSQLDBATRepository implements ATRepository { + "ORDER BY height DESC " + "LIMIT 1" + ") AS FinalATStates " - + "WHERE code_hash = ? AND is_finished "); + + "WHERE code_hash = ? "); - Object[] bindParams; + List 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)) = ? "); @@ -312,9 +318,13 @@ public class HSQLDBATRepository implements ATRepository { String expectedHexValue = String.format("%016x", expectedValue); // left-zero-padding and conversion // SQL binary data offsets start at 1 - bindParams = new Object[] { codeHash, dataByteOffset + 1, expectedHexValue }; - } else { - bindParams = new Object[] { codeHash }; + bindParams.add(dataByteOffset + 1); + bindParams.add(expectedHexValue); + } + + if (minimumFinalHeight != null) { + sql.append("AND height >= "); + sql.append(minimumFinalHeight); } sql.append(" ORDER BY height "); @@ -325,7 +335,7 @@ public class HSQLDBATRepository implements ATRepository { List atStates = new ArrayList<>(); - try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams)) { + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) { if (resultSet == null) return atStates;