From ead84d70d13dad0cca7138813e784e5e6386a485 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 8 Jun 2020 09:00:37 +0100 Subject: [PATCH 01/51] Initial skeleton code for Trade Bot --- .../org/qortal/controller/Controller.java | 4 +- .../java/org/qortal/controller/TradeBot.java | 27 ++++++ .../qortal/data/crosschain/TradeBotData.java | 97 +++++++++++++++++++ .../repository/CrossChainRepository.java | 11 +++ .../org/qortal/repository/Repository.java | 2 + .../hsqldb/HSQLDBCrossChainRepository.java | 52 ++++++++++ .../hsqldb/HSQLDBDatabaseUpdates.java | 7 ++ .../repository/hsqldb/HSQLDBRepository.java | 6 ++ 8 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/qortal/controller/TradeBot.java create mode 100644 src/main/java/org/qortal/data/crosschain/TradeBotData.java create mode 100644 src/main/java/org/qortal/repository/CrossChainRepository.java create mode 100644 src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 6c9bac27..2f1e4175 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -238,9 +238,11 @@ public class Controller extends Thread { return this.chainTip; } - /** Cache new blockchain tip. */ + /** Cache new blockchain tip, maybe call trade-bot. */ public void setChainTip(BlockData blockData) { this.chainTip = blockData; + + TradeBot.getInstance().onChainTipChange(); } public ReentrantLock getBlockchainLock() { diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java new file mode 100644 index 00000000..fe2b16d1 --- /dev/null +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -0,0 +1,27 @@ +package org.qortal.controller; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +public class TradeBot { + + private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); + + private static TradeBot instance; + + private TradeBot() { + + } + + public static synchronized TradeBot getInstance() { + if (instance == null) + instance = new TradeBot(); + + return instance; + } + + public void onChainTipChange() { + // Get repo for trade situations + } + +} diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java new file mode 100644 index 00000000..25c27b83 --- /dev/null +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -0,0 +1,97 @@ +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 io.swagger.v3.oas.annotations.media.Schema; + +// All properties to be converted to JSON via JAXB +@XmlAccessorType(XmlAccessType.FIELD) +public class TradeBotData { + + public enum State { + BOB_START(0), BOB_WAITING_FOR_P2SH_A(10), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(30), + ALICE_START(100), ALICE_WAITING_FOR_P2SH_A(110), ALICE_WAITING_FOR_AT_LOCK(120), ALICE_WATCH_P2SH_B(130); + + public final int value; + private static final Map 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; + + // Never expose this + @XmlTransient + @Schema(hidden = true) + private byte[] tradePrivateKey; + + private byte[] secret; + + private String atAddress; + + private byte[] lastTransactionSignature; + + public TradeBotData(byte[] tradePrivateKey, State tradeState, byte[] secret, String atAddress, + byte[] lastTransactionSignature) { + this.tradePrivateKey = tradePrivateKey; + this.tradeState = tradeState; + this.secret = secret; + this.atAddress = atAddress; + this.lastTransactionSignature = lastTransactionSignature; + } + + public TradeBotData(byte[] tradePrivateKey, State tradeState) { + this.tradePrivateKey = tradePrivateKey; + this.tradeState = tradeState; + } + + public State getState() { + return this.tradeState; + } + + public void setState(State state) { + this.tradeState = state; + } + + public byte[] getSecret() { + return this.secret; + } + + public void setSecret(byte[] secret) { + this.secret = secret; + } + + public byte[] getTradePrivateKey() { + return this.tradePrivateKey; + } + + public String getAtAddress() { + return this.atAddress; + } + + public void setAtAddress(String atAddress) { + this.atAddress = atAddress; + } + + public byte[] getLastTransactionSignature() { + return this.lastTransactionSignature; + } + + public void setLastTransactionSignature(byte[] lastTransactionSignature) { + this.lastTransactionSignature = lastTransactionSignature; + } + +} diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java new file mode 100644 index 00000000..2c0ef31b --- /dev/null +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -0,0 +1,11 @@ +package org.qortal.repository; + +import java.util.List; + +import org.qortal.data.crosschain.TradeBotData; + +public interface CrossChainRepository { + + public List getAllTradeBotData() throws DataException; + +} diff --git a/src/main/java/org/qortal/repository/Repository.java b/src/main/java/org/qortal/repository/Repository.java index 5c28253b..aecf4ef0 100644 --- a/src/main/java/org/qortal/repository/Repository.java +++ b/src/main/java/org/qortal/repository/Repository.java @@ -14,6 +14,8 @@ public interface Repository extends AutoCloseable { public ChatRepository getChatRepository(); + public CrossChainRepository getCrossChainRepository(); + public GroupRepository getGroupRepository(); public NameRepository getNameRepository(); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java new file mode 100644 index 00000000..07f29961 --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -0,0 +1,52 @@ +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 List getAllTradeBotData() throws DataException { + String sql = "SELECT trade_private_key, trade_state, secret, at_address, last_transaction_signature " + + "FROM TradeBotStates"; + + List 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"); + + byte[] secret = resultSet.getBytes(3); + String atAddress = resultSet.getString(4); + byte[] lastTransactionSignature = resultSet.getBytes(5); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, secret, atAddress, lastTransactionSignature); + allTradeBotData.add(tradeBotData); + } while (resultSet.next()); + + return allTradeBotData; + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading states from repository", e); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index c4751f44..e142e956 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -618,6 +618,13 @@ 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 QortalPrivateKey NOT NULL, trade_state TINYINT NOT NULL, " + + "secret VARBINARY(32) NOT NULL, at_address QortalAddress, " + + "last_transaction_signature Signature, PRIMARY KEY (trade_private_key)"); + break; + default: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 9c0ad9ab..dbee6cc0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -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); From cc13d1d0f1493e416a1f2e9a0ab7a461627956e4 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 8 Jun 2020 12:44:03 +0100 Subject: [PATCH 02/51] WIP commit --- .../api/resource/CrossChainResource.java | 35 ++++++++ .../java/org/qortal/controller/TradeBot.java | 87 ++++++++++++++++++- .../qortal/data/crosschain/TradeBotData.java | 41 +++++++-- .../repository/CrossChainRepository.java | 2 + .../hsqldb/HSQLDBCrossChainRepository.java | 39 +++++++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 5 +- 6 files changed, 194 insertions(+), 15 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index f60deb23..b7cbfb1d 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -19,6 +19,7 @@ import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -43,6 +44,7 @@ import org.qortal.api.model.CrossChainBitcoinRefundRequest; import org.qortal.api.model.CrossChainBitcoinTemplateRequest; import org.qortal.api.model.CrossChainBuildRequest; import org.qortal.asset.Asset; +import org.qortal.controller.TradeBot; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; @@ -717,6 +719,39 @@ public class CrossChainResource { } } + @POST + @Path("/tradebot/{ataddress}") + @Operation( + summary = "Respond to a trade offer", + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String tradeBotResponder(@PathParam("ataddress") String atAddress) { + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + // Extract data from cross-chain trading AT + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, null, atAddress); // null to skip creator check + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + if (crossChainTradeData.mode != Mode.OFFER) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + String p2shAddress = TradeBot.startResponse(repository, crossChainTradeData); + if (p2shAddress == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + + return p2shAddress; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index fe2b16d1..0a16bfbe 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -1,12 +1,34 @@ package org.qortal.controller; +import java.security.SecureRandom; +import java.util.List; +import java.util.Random; + import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.api.ApiError; +import org.qortal.api.ApiExceptionFactory; +import org.qortal.crosschain.BTC; +import org.qortal.crosschain.BTCACCT; +import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; +import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.crosschain.CrossChainTradeData.Mode; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; public class TradeBot { private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); - + private static final Random RANDOM = new SecureRandom(); + private static TradeBot instance; private TradeBot() { @@ -20,8 +42,71 @@ public class TradeBot { return instance; } + public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + BTC btc = BTC.getInstance(); + NetworkParameters params = btc.getNetworkParameters(); + + byte[] tradePrivateKey = generateTradePrivateKey(); + byte[] secret = generateSecret(); + byte[] secretHash = Crypto.digest(secret); + + byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, + tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.qortalAtAddress, null); + repository.getCrossChainRepository().save(tradeBotData); + + // P2SH_a to be funded + byte[] redeemScriptBytes = BTCACCT.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.foreignPublicKeyHash, secretHash); + byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); + + Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); + return p2shAddress.toString(); + } + + private static byte[] generateTradePrivateKey() { + byte[] seed = new byte[32]; + RANDOM.nextBytes(seed); + return seed; + } + + private static byte[] deriveTradeNativePublicKey(byte[] privateKey) { + return PrivateKeyAccount.toPublicKey(privateKey); + } + + private static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { + return ECKey.fromPrivate(privateKey).getPubKey(); + } + + private static byte[] generateSecret() { + byte[] secret = new byte[32]; + RANDOM.nextBytes(secret); + return secret; + } + public void onChainTipChange() { // Get repo for trade situations + try (final Repository repository = RepositoryManager.getRepository()) { + List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + + for (TradeBotData tradeBotData : allTradeBotData) + switch (tradeBotData.getState()) { + case ALICE_START: + handleAliceStart(repository, tradeBotData); + break; + } + } catch (DataException e) { + LOGGER.error("Couldn't run trade bot due to repository issue", e); + } + } + + private void handleAliceStart(Repository repository, TradeBotData tradeBotData) { + } } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 25c27b83..12ed14e9 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -38,24 +38,37 @@ public class TradeBotData { @Schema(hidden = true) private byte[] tradePrivateKey; + private byte[] tradeNativePublicKey; + private byte[] tradeNativePublicKeyHash; + private byte[] secret; + private byte[] secretHash; + + private byte[] tradeForeignPublicKey; + private byte[] tradeForeignPublicKeyHash; private String atAddress; private byte[] lastTransactionSignature; - public TradeBotData(byte[] tradePrivateKey, State tradeState, byte[] secret, String atAddress, + public TradeBotData(byte[] tradePrivateKey, State tradeState, + byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] secretHash, + byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, String atAddress, byte[] lastTransactionSignature) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; + this.tradeNativePublicKey = tradeNativePublicKey; + this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; this.secret = secret; + this.secretHash = secretHash; + this.tradeForeignPublicKey = tradeForeignPublicKey; + this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; this.atAddress = atAddress; this.lastTransactionSignature = lastTransactionSignature; } - public TradeBotData(byte[] tradePrivateKey, State tradeState) { - this.tradePrivateKey = tradePrivateKey; - this.tradeState = tradeState; + public byte[] getTradePrivateKey() { + return this.tradePrivateKey; } public State getState() { @@ -66,16 +79,28 @@ public class TradeBotData { this.tradeState = state; } + public byte[] getTradeNativePublicKey() { + return this.tradeNativePublicKey; + } + + public byte[] getTradeNativePublicKeyHash() { + return this.tradeNativePublicKeyHash; + } + public byte[] getSecret() { return this.secret; } - public void setSecret(byte[] secret) { - this.secret = secret; + public byte[] getSecretHash() { + return this.secretHash; } - public byte[] getTradePrivateKey() { - return this.tradePrivateKey; + public byte[] getTradeForeignPublicKey() { + return this.tradeForeignPublicKey; + } + + public byte[] getTradeForeignPublicKeyHash() { + return this.tradeForeignPublicKeyHash; } public String getAtAddress() { diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java index 2c0ef31b..e1b409a0 100644 --- a/src/main/java/org/qortal/repository/CrossChainRepository.java +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -8,4 +8,6 @@ public interface CrossChainRepository { public List getAllTradeBotData() throws DataException; + public void save(TradeBotData tradeBotData) throws DataException; + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 07f29961..4d91dd6e 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -19,7 +19,8 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public List getAllTradeBotData() throws DataException { - String sql = "SELECT trade_private_key, trade_state, secret, at_address, last_transaction_signature " + String sql = "SELECT trade_private_key, trade_state, trade_native_public_key, trade_native_public_key_hash, " + + "secret, secret_hash, trade_foreign_public_key, trade_foreign_public_key_hash, at_address, last_transaction_signature " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -35,11 +36,18 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { if (tradeState == null) throw new DataException("Illegal trade-bot trade-state fetched from repository"); - byte[] secret = resultSet.getBytes(3); - String atAddress = resultSet.getString(4); - byte[] lastTransactionSignature = resultSet.getBytes(5); + byte[] tradeNativePublicKey = resultSet.getBytes(3); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(4); + byte[] secret = resultSet.getBytes(5); + byte[] secretHash = resultSet.getBytes(6); + byte[] tradeForeignPublicKey = resultSet.getBytes(7); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(8); + String atAddress = resultSet.getString(9); + byte[] lastTransactionSignature = resultSet.getBytes(10); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, secret, atAddress, lastTransactionSignature); + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, + tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + tradeForeignPublicKey, tradeForeignPublicKeyHash, atAddress, lastTransactionSignature); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -49,4 +57,25 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { } } + @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("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) + .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) + .bind("secret", tradeBotData.getSecret()).bind("secret_hash", tradeBotData.getSecretHash()) + .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) + .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) + .bind("at_address", tradeBotData.getAtAddress()) + .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()); + + try { + saveHelper.execute(this.repository); + } catch (SQLException e) { + throw new DataException("Unable to save trade bot data into repository", e); + } + } + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index e142e956..4c70b825 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -621,7 +621,10 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalPrivateKey NOT NULL, trade_state TINYINT NOT NULL, " - + "secret VARBINARY(32) NOT NULL, at_address QortalAddress, " + + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + + "secret VARBINARY(32) NOT NULL, secret_hash VARBINARY(32) NOT NULL, " + + "trade_foreign_public_key QortalPublicKey NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " + + "at_address QortalAddress, " + "last_transaction_signature Signature, PRIMARY KEY (trade_private_key)"); break; From 65ccb80aa4786ff2b9181aec3aaeca6d2bd2e6c5 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 10 Jun 2020 09:10:24 +0100 Subject: [PATCH 03/51] AT-related changes: new Qortal functions, tests, etc. Added GET_MESSAGE_LENGTH_FROM_TX_IN_A and PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B. Replaced AT-1.3.4 with version including bug-fix for off-by-one data address bounds checking. Moved long-from-bytes method to BitTwiddling class. Renamed some methods to make it more obvious they work with little/big endian data. --- lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar | Bin 146000 -> 146003 bytes lib/org/ciyam/AT/maven-metadata-local.xml | 2 +- src/main/java/org/qortal/at/QortalATAPI.java | 56 ++--- .../org/qortal/at/QortalFunctionCode.java | 71 +++++- src/main/java/org/qortal/crosschain/BTC.java | 2 +- .../java/org/qortal/utils/BitTwiddling.java | 8 +- .../qortal/test/at/GetMessageLengthTests.java | 223 ++++++++++++++++++ .../test/at/GetPartialMessageTests.java | 221 +++++++++++++++++ .../qortal/test/btcacct/ElectrumXTests.java | 2 +- 9 files changed, 549 insertions(+), 36 deletions(-) create mode 100644 src/test/java/org/qortal/test/at/GetMessageLengthTests.java create mode 100644 src/test/java/org/qortal/test/at/GetPartialMessageTests.java diff --git a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar b/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar index 5abe2c77a5b209f52f7ce11f7cdc97e29f871890..611836faf3ae0de20dd5f4c33cf19e2bb73c269c 100644 GIT binary patch delta 5743 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 diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 2cf6d13a..680b4f78 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -7,6 +7,6 @@ 1.3.4 - 20200414162728 + 20200609101009 diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index bf7d2abc..8c6e4ba9 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -37,6 +37,7 @@ 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 +134,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); } @@ -186,9 +187,9 @@ public class QortalATAPI extends API { // 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)); + this.setA2(state, BitTwiddling.longFromBEBytes(signature, 8)); + this.setA3(state, BitTwiddling.longFromBEBytes(signature, 16)); + this.setA4(state, BitTwiddling.longFromBEBytes(signature, 24)); return; } @@ -282,7 +283,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,20 +297,7 @@ 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; - } + byte[] messageData = this.getMessageFromTransaction(transactionData); // Check data length is appropriate, i.e. not larger than B if (messageData.length > 4 * 8) @@ -457,12 +445,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 +455,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 +479,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 +559,8 @@ public class QortalATAPI extends API { super.setB(state, bBytes); } + protected void zeroB(MachineState state) { + super.zeroB(state); + } + } diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index cf6b1cfd..67ab5b98 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -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 { /** - * 0x0510
- * 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.
+ * 0x0501
+ * 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
+ * 0x0502 start-offset
+ * Copies up to 32 bytes of message data, starting at start-offset into B.
+ * If transaction has no 'message', or start-offset out of bounds, then zero B
+ * 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.
+ * 0x0510 */ CONVERT_B_TO_PKH(0x0510, 0, false) { @Override @@ -38,8 +101,8 @@ public enum QortalFunctionCode { } }, /** - * 0x0511
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to P2SH.
+ * 0x0511
* P2SH stored in lower 25 bytes of B. */ CONVERT_B_TO_P2SH(0x0511, 0, false) { @@ -51,8 +114,8 @@ public enum QortalFunctionCode { } }, /** - * 0x0512
* Convert 20-byte value in LSB of B1, and all of B2 & B3 to Qortal address.
+ * 0x0512
* Qortal address stored in lower 25 bytes of B. */ CONVERT_B_TO_QORTAL(0x0512, 0, false) { diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index ec53eb08..88428262 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -99,7 +99,7 @@ public class BTC { if (blockHeaders == null || blockHeaders.size() < 11) return null; - List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.fromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); + List blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList()); // Descending, but order shouldn't matter as we're picking median... blockTimestamps.sort((a, b) -> Integer.compare(b, a)); diff --git a/src/main/java/org/qortal/utils/BitTwiddling.java b/src/main/java/org/qortal/utils/BitTwiddling.java index f13300c5..4ba48bc8 100644 --- a/src/main/java/org/qortal/utils/BitTwiddling.java +++ b/src/main/java/org/qortal/utils/BitTwiddling.java @@ -27,8 +27,14 @@ public class BitTwiddling { } /** 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); + } + } diff --git a/src/test/java/org/qortal/test/at/GetMessageLengthTests.java b/src/test/java/org/qortal/test/at/GetMessageLengthTests.java new file mode 100644 index 00000000..730b441f --- /dev/null +++ b/src/test/java/org/qortal/test/at/GetMessageLengthTests.java @@ -0,0 +1,223 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Random; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.at.QortalFunctionCode; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.AccountUtils; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.utils.BitTwiddling; + +public class GetMessageLengthTests extends Common { + + private static final Random RANDOM = new Random(); + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + byte[] creationBytes = buildMessageLengthAT(); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + // Send messages with known length + checkMessageLength(repository, deployer, atAddress, 1); + checkMessageLength(repository, deployer, atAddress, 10); + checkMessageLength(repository, deployer, atAddress, 32); + checkMessageLength(repository, deployer, atAddress, 99); + + // Finally, send a payment instead and check returned length is -1 + AccountUtils.pay(repository, deployer, atAddress, 123L); + // Mint another block so AT can process payment + BlockUtils.mintBlock(repository); + + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + long extractedLength = BitTwiddling.longFromBEBytes(dataBytes, 0); + + assertEquals(-1L, extractedLength); + } + } + + private void checkMessageLength(Repository repository, PrivateKeyAccount sender, String atAddress, int messageLength) throws DataException { + byte[] testMessage = new byte[messageLength]; + RANDOM.nextBytes(testMessage); + + sendMessage(repository, sender, testMessage, atAddress); + // Mint another block so AT can process message + BlockUtils.mintBlock(repository); + + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + long extractedLength = BitTwiddling.longFromBEBytes(dataBytes, 0); + + assertEquals(messageLength, extractedLength); + } + + private byte[] buildMessageLengthAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Make result first for easier extraction + final int addrResult = addrCounter++; + final int addrLastTxTimestamp = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // Code labels + Integer labelCheckTx = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, OpCode.calcOffset(codeByteBuffer, labelCheckTx))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTx = codeByteBuffer.position(); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + // Save message length + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrResult)); + + // Stop and wait for next block (and hence more transactions) + codeByteBuffer.put(OpCode.STP_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + +} diff --git a/src/test/java/org/qortal/test/at/GetPartialMessageTests.java b/src/test/java/org/qortal/test/at/GetPartialMessageTests.java new file mode 100644 index 00000000..4bc9d9ea --- /dev/null +++ b/src/test/java/org/qortal/test/at/GetPartialMessageTests.java @@ -0,0 +1,221 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.at.QortalFunctionCode; +import org.qortal.data.at.ATStateData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; + +public class GetPartialMessageTests extends Common { + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetPartialMessage() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + byte[] messageData = "The quick brown fox jumped over the lazy dog.".getBytes(); + int[] offsets = new int[] { 0, 7, 32, 44, messageData.length }; + + byte[] creationBytes = buildGetPartialMessageAT(offsets); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + sendMessage(repository, deployer, messageData, atAddress); + + for (int offset : offsets) { + // Mint another block so AT can process message + BlockUtils.mintBlock(repository); + + byte[] expectedData = new byte[32]; + int byteCount = Math.min(32, messageData.length - offset); + System.arraycopy(messageData, offset, expectedData, 0, byteCount); + + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + byte[] actualData = new byte[32]; + System.arraycopy(dataBytes, MachineState.VALUE_SIZE, actualData, 0, 32); + + assertArrayEquals(expectedData, actualData); + } + } + } + + private byte[] buildGetPartialMessageAT(int... offsets) { + // Labels for data segment addresses + int addrCounter = 0; + + final int addrCopyOfBIndex = addrCounter++; + + // 2nd position for easy extraction + final int addrCopyOfB = addrCounter; + addrCounter += 4; + + final int addrResult = addrCounter++; + final int addrLastTxTimestamp = addrCounter++; + final int addrOffset = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + dataByteBuffer.putLong(addrCopyOfB); + + // Code labels + Integer labelCheckTx = null; + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); + // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, OpCode.calcOffset(codeByteBuffer, labelCheckTx))); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + /* Check transaction */ + labelCheckTx = codeByteBuffer.position(); + + // Generate code per offset + for (int i = 0; i < offsets.length; ++i) { + if (i > 0) + // Wait for next block + codeByteBuffer.put(OpCode.SLP_IMD.compile()); + + // Set offset + codeByteBuffer.put(OpCode.SET_VAL.compile(addrOffset, offsets[i])); + + // Extract partial message + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOffset)); + + // Copy B to data segment + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCopyOfBIndex)); + } + + // We're done + codeByteBuffer.put(OpCode.FIN_IMD.compile()); + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, messageTransactionData, sender); + + return messageTransaction; + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java index a8c3cb12..3a958c79 100644 --- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java +++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java @@ -61,7 +61,7 @@ public class ElectrumXTests { // Timestamp(int) is at 4 + 32 + 32 = 68 bytes offset int offset = 4 + 32 + 32; - int timestamp = BitTwiddling.fromLEBytes(blockHeader, offset); + int timestamp = BitTwiddling.intFromLEBytes(blockHeader, offset); System.out.println(String.format("Block %d timestamp: %d", height + i, timestamp)); } } From faa6e82befda07d3708b9da6b8dce576880f5e7d Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 10 Jun 2020 09:15:09 +0100 Subject: [PATCH 04/51] WIP on trade-bot --- .../qortal/api/model/CrossChainBuildRequest.java | 3 +++ .../qortal/api/resource/CrossChainResource.java | 3 ++- src/main/java/org/qortal/controller/TradeBot.java | 4 ---- src/main/java/org/qortal/crosschain/BTCACCT.java | 15 ++++++++++++++- .../data/crosschain/CrossChainTradeData.java | 2 ++ .../repository/hsqldb/HSQLDBDatabaseUpdates.java | 4 ++-- .../java/org/qortal/test/btcacct/AtTests.java | 5 +++-- .../java/org/qortal/test/btcacct/DeployAT.java | 10 ++++++++-- .../java/org/qortal/test/common/AccountUtils.java | 12 ++++++++---- 9 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java index c4fa097a..834eda6f 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -24,6 +24,9 @@ public class CrossChainBuildRequest { @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; diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index b7cbfb1d..f78f5000 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -180,7 +180,8 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); - byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.secretHash, tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.secretHash, + tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 0a16bfbe..d8e9b9e8 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -11,15 +11,11 @@ import org.bitcoinj.core.ECKey; import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.qortal.account.PrivateKeyAccount; -import org.qortal.api.ApiError; -import org.qortal.api.ApiExceptionFactory; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; -import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; -import org.qortal.data.crosschain.CrossChainTradeData.Mode; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index a0246d04..758c7b91 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -237,6 +237,7 @@ public class BTCACCT { * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. * * @param qortalCreator Qortal address for AT creator, also used for refunds + * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's bitcoin public key * @param secretHash 20-byte HASH160 of 32-byte secret * @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator * @param initialPayout how much QORT to pay trade partner upon switch to 'trade mode' @@ -244,7 +245,7 @@ public class BTCACCT { * @param bitcoinAmount how much BTC the AT creator is expecting to trade * @return */ - public static byte[] buildQortalAT(String qortalCreator, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) { + public static byte[] buildQortalAT(String qortalCreator, byte[] bitcoinPublicKeyHash, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) { // Labels for data segment addresses int addrCounter = 0; @@ -255,6 +256,9 @@ public class BTCACCT { final int addrQortalCreator3 = addrCounter++; final int addrQortalCreator4 = addrCounter++; + final int addrBitcoinPublickeyHash = addrCounter; + addrCounter += 4; + final int addrSecretHash = addrCounter; addrCounter += 4; @@ -303,6 +307,10 @@ public class BTCACCT { byte[] qortalCreatorBytes = Base58.decode(qortalCreator); dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0)); + // Bitcoin public key hash + assert dataByteBuffer.position() == addrBitcoinPublickeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0)); + // Hash of secret assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect"; dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0)); @@ -559,6 +567,11 @@ public class BTCACCT { // Skip AT creator address dataByteBuffer.position(dataByteBuffer.position() + 32); + // Bitcoin/foreign public key hash + tradeData.foreignPublicKeyHash = new byte[20]; + dataByteBuffer.get(tradeData.foreignPublicKeyHash); + dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes + // Hash of secret tradeData.secretHash = new byte[20]; dataByteBuffer.get(tradeData.secretHash); diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 8c9b6602..a19ef81d 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -59,6 +59,8 @@ public class CrossChainTradeData { @Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout") public Integer lockTime; + public byte[] foreignPublicKeyHash; + // Constructors // Necessary for JAXB diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 4c70b825..54f8f3c3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -620,12 +620,12 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot - stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalPrivateKey NOT NULL, trade_state TINYINT NOT NULL, " + stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + "secret VARBINARY(32) NOT NULL, secret_hash VARBINARY(32) NOT NULL, " + "trade_foreign_public_key QortalPublicKey NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " + "at_address QortalAddress, " - + "last_transaction_signature Signature, PRIMARY KEY (trade_private_key)"); + + "last_transaction_signature Signature, PRIMARY KEY (trade_private_key))"); break; default: diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 19fd7340..2026424f 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -44,6 +44,7 @@ import com.google.common.primitives.Bytes; public class AtTests extends Common { public static final byte[] secret = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] bitcoinPublicKeyHash = new byte[20]; // not used in tests public static final byte[] secretHash = Crypto.hash160(secret); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final int refundTimeout = 10; // blocks public static final long initialPayout = 100000L; @@ -60,7 +61,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -434,7 +435,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 98672164..dec9f563 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -34,11 +34,12 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("usage: DeployAT ")); System.err.println(String.format("example: DeployAT " + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" + "\t80.4020 \\\n" + "\t0.00864200 \\\n" + + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + "\t0.0001 \\\n" + "\t123.456 \\\n" @@ -56,6 +57,7 @@ public class DeployAT { byte[] refundPrivateKey = null; long redeemAmount = 0; long expectedBitcoin = 0; + byte[] bitcoinPublicKeyHash = null; byte[] secretHash = null; long initialPayout = 0; long fundingAmount = 0; @@ -75,6 +77,10 @@ public class DeployAT { if (expectedBitcoin <= 0) usage("Expected BTC amount must be positive"); + bitcoinPublicKeyHash = HashCode.fromString(args[argIndex++]).asBytes(); + if (bitcoinPublicKeyHash.length != 20) + usage("Bitcoin PKH must be 20 bytes"); + secretHash = HashCode.fromString(args[argIndex++]).asBytes(); if (secretHash.length != 20) usage("Hash of secret must be 20 bytes"); @@ -114,7 +120,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), secretHash, tradeTimeout, initialPayout, redeemAmount, expectedBitcoin); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, tradeTimeout, initialPayout, redeemAmount, expectedBitcoin); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); diff --git a/src/test/java/org/qortal/test/common/AccountUtils.java b/src/test/java/org/qortal/test/common/AccountUtils.java index 59722ae1..0e7ef020 100644 --- a/src/test/java/org/qortal/test/common/AccountUtils.java +++ b/src/test/java/org/qortal/test/common/AccountUtils.java @@ -20,15 +20,19 @@ public class AccountUtils { public static final int txGroupId = Group.NO_GROUP; public static final long fee = 1L * Amounts.MULTIPLIER; - public static void pay(Repository repository, String sender, String recipient, long amount) throws DataException { - PrivateKeyAccount sendingAccount = Common.getTestAccount(repository, sender); - PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, recipient); + public static void pay(Repository repository, String testSenderName, String testRecipientName, long amount) throws DataException { + PrivateKeyAccount sendingAccount = Common.getTestAccount(repository, testSenderName); + PrivateKeyAccount recipientAccount = Common.getTestAccount(repository, testRecipientName); + pay(repository, sendingAccount, recipientAccount.getAddress(), amount); + } + + public static void pay(Repository repository, PrivateKeyAccount sendingAccount, String recipientAddress, long amount) throws DataException { byte[] reference = sendingAccount.getLastReference(); long timestamp = repository.getTransactionRepository().fromSignature(reference).getTimestamp() + 1; BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, sendingAccount.getPublicKey(), fee, null); - TransactionData transactionData = new PaymentTransactionData(baseTransactionData, recipientAccount.getAddress(), amount); + TransactionData transactionData = new PaymentTransactionData(baseTransactionData, recipientAddress, amount); TransactionUtils.signAndMint(repository, transactionData, sendingAccount); } From 04d691991aafb285bc207a1ee57d768850eb33ae Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 11 Jun 2020 09:33:33 +0100 Subject: [PATCH 05/51] WIP: more work on trade-bot --- .../api/model/TradeBotCreateRequest.java | 33 ++++++++ .../api/resource/CrossChainResource.java | 31 +++++++ .../java/org/qortal/controller/TradeBot.java | 60 +++++++++++++- .../qortal/data/crosschain/TradeBotData.java | 4 +- .../transaction/DeployAtTransaction.java | 80 +++++++++---------- 5 files changed, 162 insertions(+), 46 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/TradeBotCreateRequest.java diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java new file mode 100644 index 00000000..8fb3a99a --- /dev/null +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -0,0 +1,33 @@ +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 = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + 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 = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080") + public Integer tradeTimeout; + + public TradeBotCreateRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index f78f5000..74136f50 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -38,6 +38,7 @@ import org.qortal.api.ApiExceptionFactory; import org.qortal.api.model.CrossChainCancelRequest; import org.qortal.api.model.CrossChainSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; +import org.qortal.api.model.TradeBotCreateRequest; import org.qortal.api.model.CrossChainBitcoinP2SHStatus; import org.qortal.api.model.CrossChainBitcoinRedeemRequest; import org.qortal.api.model.CrossChainBitcoinRefundRequest; @@ -720,6 +721,36 @@ public class CrossChainResource { } } + @POST + @Path("/tradebot") + @Operation( + summary = "Create a trade offer", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotCreateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { + try (final Repository repository = RepositoryManager.getRepository()) { + byte[] unsignedBytes = TradeBot.createTrade(repository, tradeBotCreateRequest); + + return Base58.encode(unsignedBytes); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + @POST @Path("/tradebot/{ataddress}") @Operation( diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index d8e9b9e8..460b29c7 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -11,14 +11,23 @@ import org.bitcoinj.core.ECKey; import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.qortal.account.PrivateKeyAccount; +import org.qortal.account.PublicKeyAccount; +import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.asset.Asset; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.utils.NTP; public class TradeBot { @@ -38,6 +47,49 @@ public class TradeBot { return instance; } + public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) { + BTC btc = BTC.getInstance(); + NetworkParameters params = btc.getNetworkParameters(); + + byte[] tradePrivateKey = generateTradePrivateKey(); + byte[] secret = generateSecret(); + byte[] secretHash = Crypto.digest(secret); + + byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + + byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + + PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + + // Deploy AT + long timestamp = NTP.getTime(); + byte[] reference = creator.getLastReference(); + long fee = 0L; + byte[] signature = null; + BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, Group.NO_GROUP, reference, creator.getPublicKey(), fee, signature); + + String name = "QORT/BTC ACCT"; + String description = "QORT/BTC cross-chain trade"; + String aTType = "ACCT"; + String tags = "ACCT QORT BTC"; + byte[] creationBytes = BTCACCT.buildQortalAT(creator.getAddress(), tradeNativePublicKeyHash, secretHash, tradeBotCreateRequest.tradeTimeout, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount); + long amount = tradeBotCreateRequest.fundingQortAmount; + + DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); + DeployAtTransaction.ensureATAddress(deployAtTransactionData); + String atAddress = deployAtTransactionData.getAtAddress(); + + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, + tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + tradeForeignPublicKey, tradeForeignPublicKeyHash, atAddress, null); + repository.getCrossChainRepository().save(tradeBotData); + + // Return to user for signing and broadcast as we don't have their Qortal private key + return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + } + public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -53,7 +105,7 @@ public class TradeBot { byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, - tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.qortalAtAddress, null); repository.getCrossChainRepository().save(tradeBotData); @@ -92,8 +144,8 @@ public class TradeBot { for (TradeBotData tradeBotData : allTradeBotData) switch (tradeBotData.getState()) { - case ALICE_START: - handleAliceStart(repository, tradeBotData); + case BOB_WAITING_FOR_MESSAGE: + handleBobWaitingForMessage(repository, tradeBotData); break; } } catch (DataException e) { @@ -101,7 +153,7 @@ public class TradeBot { } } - private void handleAliceStart(Repository repository, TradeBotData tradeBotData) { + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) { } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 12ed14e9..c6887060 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -16,8 +16,8 @@ import io.swagger.v3.oas.annotations.media.Schema; public class TradeBotData { public enum State { - BOB_START(0), BOB_WAITING_FOR_P2SH_A(10), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(30), - ALICE_START(100), ALICE_WAITING_FOR_P2SH_A(110), ALICE_WAITING_FOR_AT_LOCK(120), ALICE_WATCH_P2SH_B(130); + BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_WAITING_FOR_P2SH_A(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), + ALICE_WAITING_FOR_P2SH_A(110), ALICE_WAITING_FOR_AT_LOCK(120), ALICE_WATCH_P2SH_B(130); public final int value; private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); diff --git a/src/main/java/org/qortal/transaction/DeployAtTransaction.java b/src/main/java/org/qortal/transaction/DeployAtTransaction.java index 46ad9e3e..fea63cde 100644 --- a/src/main/java/org/qortal/transaction/DeployAtTransaction.java +++ b/src/main/java/org/qortal/transaction/DeployAtTransaction.java @@ -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 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()); } } From 593b61ea4b57e21c0dbe5532712fef71cda9f53e Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 11 Jun 2020 14:16:49 +0100 Subject: [PATCH 06/51] Reduce bitcoinj exposure to classes outside of org.qortal.crosschain package. BTC.getBalance() now returns Long instead of Coin. BTC.FORMAT.format(Coin) changed to BTC.format(Coin or long). Added BTC.deriveP2shAddress(byte[] redeemScriptBytes). --- .../api/resource/CrossChainResource.java | 18 ++++++------- src/main/java/org/qortal/crosschain/BTC.java | 26 ++++++++++++++----- .../org/qortal/test/btcacct/BuildP2SH.java | 4 +-- .../org/qortal/test/btcacct/CheckP2SH.java | 8 +++--- .../java/org/qortal/test/btcacct/Redeem.java | 12 ++++----- .../java/org/qortal/test/btcacct/Refund.java | 12 ++++----- 6 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 74136f50..9ac04308 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -498,17 +498,17 @@ public class CrossChainResource { // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus(); p2shStatus.bitcoinP2shAddress = p2shAddress.toString(); - p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance.value, 8); + p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - if (p2shBalance.value >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { + if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { p2shStatus.canRedeem = now >= medianBlockTime * 1000L; p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L; } @@ -591,7 +591,7 @@ public class CrossChainResource { // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); @@ -603,10 +603,10 @@ public class CrossChainResource { if (!canRefund) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - if (p2shBalance.value < crossChainTradeData.expectedBitcoin) + if (p2shBalance < crossChainTradeData.expectedBitcoin) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - Coin refundAmount = p2shBalance.subtract(Coin.valueOf(refundRequest.bitcoinMinerFee.unscaledValue().longValue())); + Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); org.bitcoinj.core.Transaction refundTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); @@ -692,11 +692,11 @@ public class CrossChainResource { long now = NTP.getTime(); // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); - if (p2shBalance.value < crossChainTradeData.expectedBitcoin) + if (p2shBalance < crossChainTradeData.expectedBitcoin) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -707,7 +707,7 @@ public class CrossChainResource { if (!canRedeem) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - Coin redeemAmount = p2shBalance.subtract(Coin.valueOf(redeemRequest.bitcoinMinerFee.unscaledValue().longValue())); + Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); org.bitcoinj.core.Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 88428262..65af8781 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -8,6 +8,7 @@ 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.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionOutput; @@ -16,13 +17,13 @@ import org.bitcoinj.params.RegTestParams; import org.bitcoinj.params.TestNet3Params; import org.bitcoinj.script.ScriptBuilder; import org.bitcoinj.utils.MonetaryFormat; +import org.qortal.crypto.Crypto; import org.qortal.settings.Settings; import org.qortal.utils.BitTwiddling; import org.qortal.utils.Pair; 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 +31,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 { @@ -88,6 +90,20 @@ 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 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(); @@ -107,12 +123,8 @@ public class BTC { 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 getUnspentOutputs(String base58Address) { diff --git a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java index 25a95d8b..25f0430e 100644 --- a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java @@ -98,7 +98,7 @@ public class BuildP2SH { System.out.println(String.format("Bitcoin redeem amount: %s", bitcoinAmount.toPlainString())); System.out.println(String.format("Redeem Bitcoin address: %s", redeemBitcoinAddress)); - System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Redeem miner's fee: %s", BTC.format(bitcoinFee))); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash))); @@ -115,7 +115,7 @@ public class BuildP2SH { // Fund P2SH System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)", - p2shAddress.toString(), BTC.FORMAT.format(bitcoinAmount), BTC.FORMAT.format(bitcoinFee))); + p2shAddress.toString(), BTC.format(bitcoinAmount), BTC.format(bitcoinFee))); System.out.println("Once this is done, responder should run Respond to check P2SH funding and create AT"); } catch (DataException e) { diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index 8313d573..25f20c68 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -106,7 +106,7 @@ public class CheckP2SH { System.out.println(String.format("Bitcoin redeem amount: %s", bitcoinAmount.toPlainString())); System.out.println(String.format("Redeem Bitcoin address: %s", refundBitcoinAddress)); - System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Redeem miner's fee: %s", BTC.format(bitcoinFee))); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash))); @@ -135,12 +135,12 @@ public class CheckP2SH { System.out.println(String.format("Too soon (%s) to redeem based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC))); // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); } - System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.FORMAT.format(p2shBalance))); + System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -152,7 +152,7 @@ public class CheckP2SH { System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); for (TransactionOutput fundingOutput : fundingOutputs) - System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue()))); + System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.format(fundingOutput.getValue()))); if (fundingOutputs.isEmpty()) { System.err.println(String.format("Can't redeem spent/unfunded P2SH")); diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 091f2234..5a14906b 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -107,7 +107,7 @@ public class Redeem { System.out.println("Confirm the following is correct based on the info you've given:"); System.out.println(String.format("Redeem PRIVATE key: %s", HashCode.fromBytes(redeemPrivateKey))); - System.out.println(String.format("Redeem miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Redeem miner's fee: %s", BTC.format(bitcoinFee))); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); // New/derived info @@ -147,12 +147,12 @@ public class Redeem { } // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); } - System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString())); + System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -164,7 +164,7 @@ public class Redeem { System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); for (TransactionOutput fundingOutput : fundingOutputs) - System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue()))); + System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.format(fundingOutput.getValue()))); if (fundingOutputs.isEmpty()) { System.err.println(String.format("Can't redeem spent/unfunded P2SH")); @@ -179,8 +179,8 @@ public class Redeem { for (TransactionOutput fundingOutput : fundingOutputs) System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex())); - Coin redeemAmount = p2shBalance.subtract(bitcoinFee); - System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.FORMAT.format(redeemAmount), BTC.FORMAT.format(bitcoinFee))); + Coin redeemAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); + System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(redeemAmount), BTC.format(bitcoinFee))); Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret); diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index a694ee14..b4ab94b5 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -110,7 +110,7 @@ public class Refund { System.out.println(String.format("Redeem Bitcoin address: %s", redeemBitcoinAddress)); System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("P2SH address: %s", p2shAddress)); - System.out.println(String.format("Refund miner's fee: %s", BTC.FORMAT.format(bitcoinFee))); + System.out.println(String.format("Refund miner's fee: %s", BTC.format(bitcoinFee))); // New/derived info @@ -151,12 +151,12 @@ public class Refund { } // Check P2SH is funded - Coin p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); + Long p2shBalance = BTC.getInstance().getBalance(p2shAddress.toString()); if (p2shBalance == null) { System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress)); System.exit(2); } - System.out.println(String.format("P2SH address %s balance: %s BTC", p2shAddress, p2shBalance.toPlainString())); + System.out.println(String.format("P2SH address %s balance: %s", p2shAddress, BTC.format(p2shBalance))); // Grab all P2SH funding transactions (just in case there are more than one) List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); @@ -168,7 +168,7 @@ public class Refund { System.out.println(String.format("Found %d output%s for P2SH", fundingOutputs.size(), (fundingOutputs.size() != 1 ? "s" : ""))); for (TransactionOutput fundingOutput : fundingOutputs) - System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.FORMAT.format(fundingOutput.getValue()))); + System.out.println(String.format("Output %s:%d amount %s", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(), BTC.format(fundingOutput.getValue()))); if (fundingOutputs.isEmpty()) { System.err.println(String.format("Can't refund spent/unfunded P2SH")); @@ -183,8 +183,8 @@ public class Refund { for (TransactionOutput fundingOutput : fundingOutputs) System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex())); - Coin refundAmount = p2shBalance.subtract(bitcoinFee); - System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.FORMAT.format(refundAmount), BTC.FORMAT.format(bitcoinFee))); + Coin refundAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); + System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(refundAmount), BTC.format(bitcoinFee))); Transaction redeemTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); From a6fa4fc6134e5f0f33b90994d36418f73e3acd23 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 11 Jun 2020 14:54:41 +0100 Subject: [PATCH 07/51] WIP: trade-bot MESSAGE support --- .../java/org/qortal/controller/TradeBot.java | 104 +++++++++++++++++- .../qortal/data/crosschain/TradeBotData.java | 2 +- .../repository/TransactionRepository.java | 13 +++ .../HSQLDBTransactionRepository.java | 38 +++++++ 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 460b29c7..4965ddae 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -1,31 +1,39 @@ package org.qortal.controller; import java.security.SecureRandom; +import java.util.Arrays; import java.util.List; import java.util.Random; 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.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.asset.Asset; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; +import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction.TransactionType; +import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.NTP; @@ -141,20 +149,112 @@ public class TradeBot { // Get repo for trade situations try (final Repository repository = RepositoryManager.getRepository()) { List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); - + for (TradeBotData tradeBotData : allTradeBotData) switch (tradeBotData.getState()) { + case BOB_WAITING_FOR_AT_CONFIRM: + handleBobWaitingForAtConfirm(repository, tradeBotData); + break; + case BOB_WAITING_FOR_MESSAGE: handleBobWaitingForMessage(repository, tradeBotData); break; + + default: + LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name())); } } catch (DataException e) { LOGGER.error("Couldn't run trade bot due to repository issue", e); } } + private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { + if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) + return; + + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); + repository.getCrossChainRepository().save(tradeBotData); + } + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) { - + // Fetch AT so we can determine trade start timestamp + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.error(String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + return; + } + + long tradeStartTimestamp = atData.getCreation(); + + String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); + + // Skip past previously processed messages + if (tradeBotData.getLastTransactionSignature() != null) + for (int i = 0; i < messageTransactionsData.size(); ++i) + if (Arrays.equals(messageTransactionsData.get(i).getSignature(), tradeBotData.getLastTransactionSignature())) { + messageTransactionsData.subList(0, i + 1).clear(); + break; + } + + while (!messageTransactionsData.isEmpty()) { + MessageTransactionData messageTransactionData = messageTransactionsData.remove(0); + tradeBotData.setLastTransactionSignature(messageTransactionData.getSignature()); + + if (messageTransactionData.isText()) + continue; + + // Could enforce encryption here + + // We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash + byte[] messageData = messageTransactionData.getData(); + + if (messageData.length != 40) + continue; + + byte[] aliceSecretHash = new byte[20]; + System.arraycopy(messageData, 0, aliceSecretHash, 0, 20); + + byte[] aliceForeignPublicKeyHash = new byte[20]; + System.arraycopy(messageData, 20, aliceForeignPublicKeyHash, 0, 20); + + // Determine P2SH address and confirm funded + int lockTime = (int) (tradeStartTimestamp / 1000L + tradeBotData.getTradeTimeout() / 4 * 60); // First P2SH locktime is ¼ of timeout period + byte[] redeemScript = BTCACCT.buildScript(aliceForeignPublicKeyHash, lockTime, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); + + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < tradeBotData.getBitcoinAmount()) + continue; + + // Good to go - send MESSAGE to AT + + byte[] aliceNativePublicKeyHash = Crypto.hash160(messageTransactionData.getCreatorPublicKey()); + + // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume + byte[] outgoingMessageData = new byte[96]; + System.arraycopy(aliceSecretHash, 0, outgoingMessageData, 0, 20); + System.arraycopy(aliceForeignPublicKeyHash, 0, outgoingMessageData, 32, 20); + System.arraycopy(aliceNativePublicKeyHash, 0, outgoingMessageData, 64, 20); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); + + outgoingMessageTransaction.computeNonce(); + outgoingMessageTransaction.sign(sender); + + ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.error(String.format("Unable to send MESSAGE to AT '%s': %s", tradeBotData.getAtAddress(), result.name())); + return; + } + + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); + break; + } + + repository.getCrossChainRepository().save(tradeBotData); } } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index c6887060..3c8b4f63 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -16,7 +16,7 @@ import io.swagger.v3.oas.annotations.media.Schema; public class TradeBotData { public enum State { - BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_WAITING_FOR_P2SH_A(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), + BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_SENDING_MESSAGE_TO_AT(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), ALICE_WAITING_FOR_P2SH_A(110), ALICE_WAITING_FOR_AT_LOCK(120), ALICE_WATCH_P2SH_B(130); public final int value; diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 56e51be1..38856d24 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -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 getMessagesByRecipient(String recipient, + Integer limit, Integer offset, Boolean reverse) throws DataException; + /** * Returns list of transactions relating to specific asset ID. * diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index 0ab6ed94..bf9c88aa 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -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 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 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 getAssetTransactions(long assetId, ConfirmationStatus confirmationStatus, Integer limit, Integer offset, Boolean reverse) throws DataException { From da254058c537ad161dbccd8e5ce1b277bce95f36 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 16 Jun 2020 13:52:26 +0100 Subject: [PATCH 08/51] WIP: split P2SH from BTCACCT, add more fields to TradeBotData, remove initial QORT payout --- .../api/model/CrossChainBuildRequest.java | 6 +- .../api/resource/CrossChainResource.java | 24 +- .../java/org/qortal/controller/TradeBot.java | 27 +- .../java/org/qortal/crosschain/BTCACCT.java | 251 +----------------- .../java/org/qortal/crosschain/BTCP2SH.java | 232 ++++++++++++++++ .../data/crosschain/CrossChainTradeData.java | 8 +- .../qortal/data/crosschain/TradeBotData.java | 46 ++-- .../hsqldb/HSQLDBCrossChainRepository.java | 33 ++- .../hsqldb/HSQLDBDatabaseUpdates.java | 4 +- .../java/org/qortal/test/btcacct/AtTests.java | 59 +--- .../org/qortal/test/btcacct/BtcTests.java | 4 +- .../org/qortal/test/btcacct/BuildP2SH.java | 4 +- .../org/qortal/test/btcacct/CheckP2SH.java | 4 +- .../org/qortal/test/btcacct/DeployAT.java | 12 +- .../java/org/qortal/test/btcacct/Redeem.java | 6 +- .../java/org/qortal/test/btcacct/Refund.java | 6 +- 16 files changed, 350 insertions(+), 376 deletions(-) create mode 100644 src/main/java/org/qortal/crosschain/BTCP2SH.java diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java index 834eda6f..76fafc9c 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -12,13 +12,9 @@ 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) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 9ac04308..405d44b8 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -48,6 +48,7 @@ import org.qortal.asset.Asset; import org.qortal.controller.TradeBot; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; @@ -162,17 +163,14 @@ public class CrossChainResource { if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - if (tradeRequest.initialQortAmount < 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (tradeRequest.finalQortAmount <= 0) + if (tradeRequest.qortAmount <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.fundingQortAmount <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); // funding amount must exceed initial + final - if (tradeRequest.fundingQortAmount <= tradeRequest.initialQortAmount + tradeRequest.finalQortAmount) + if (tradeRequest.fundingQortAmount <= tradeRequest.qortAmount) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.bitcoinAmount <= 0) @@ -182,7 +180,7 @@ public class CrossChainResource { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.secretHash, - tradeRequest.tradeTimeout, tradeRequest.initialQortAmount, tradeRequest.finalQortAmount, tradeRequest.bitcoinAmount); + tradeRequest.tradeTimeout, tradeRequest.qortAmount, tradeRequest.bitcoinAmount); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); @@ -434,7 +432,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -485,7 +483,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -516,7 +514,7 @@ public class CrossChainResource { if (now >= medianBlockTime * 1000L) { // See if we can extract secret List rawTransactions = BTC.getInstance().getAddressTransactions(p2shStatus.bitcoinP2shAddress); - p2shStatus.secret = BTCACCT.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions); + p2shStatus.secret = BTCP2SH.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions); } return p2shStatus; @@ -582,7 +580,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -608,7 +606,7 @@ public class CrossChainResource { Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction refundTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime); + org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); if (!wasBroadcast) @@ -680,7 +678,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCACCT.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -709,7 +707,7 @@ public class CrossChainResource { Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret); + org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); if (!wasBroadcast) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 4965ddae..a398559c 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -8,17 +8,16 @@ import java.util.Random; 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.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.TradeBotCreateRequest; -import org.qortal.api.resource.TransactionsResource.ConfirmationStatus; import org.qortal.asset.Asset; import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; @@ -32,8 +31,8 @@ import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; -import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; +import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.NTP; @@ -55,7 +54,7 @@ public class TradeBot { return instance; } - public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) { + public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -90,12 +89,18 @@ public class TradeBot { String atAddress = deployAtTransactionData.getAtAddress(); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, + atAddress, tradeBotCreateRequest.tradeTimeout, tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, - tradeForeignPublicKey, tradeForeignPublicKeyHash, atAddress, null); + tradeForeignPublicKey, tradeForeignPublicKeyHash, + tradeBotCreateRequest.bitcoinAmount, null); repository.getCrossChainRepository().save(tradeBotData); // Return to user for signing and broadcast as we don't have their Qortal private key - return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + try { + return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); + } catch (TransformationException e) { + throw new DataException("Failed to transform DEPLOY_AT transaction?", e); + } } public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { @@ -113,12 +118,14 @@ public class TradeBot { byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, + crossChainTradeData.qortalAtAddress, crossChainTradeData.tradeTimeout, tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, - tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.qortalAtAddress, null); + tradeForeignPublicKey, tradeForeignPublicKeyHash, + crossChainTradeData.expectedBitcoin, null); repository.getCrossChainRepository().save(tradeBotData); // P2SH_a to be funded - byte[] redeemScriptBytes = BTCACCT.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.foreignPublicKeyHash, secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.foreignPublicKeyHash, secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -176,7 +183,7 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); } - private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) { + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { // Fetch AT so we can determine trade start timestamp ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -220,7 +227,7 @@ public class TradeBot { // Determine P2SH address and confirm funded int lockTime = (int) (tradeStartTimestamp / 1000L + tradeBotData.getTradeTimeout() / 4 * 60); // First P2SH locktime is ¼ of timeout period - byte[] redeemScript = BTCACCT.buildScript(aliceForeignPublicKeyHash, lockTime, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); + byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTime, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); Long balance = BTC.getInstance().getBalance(p2shAddress); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 758c7b91..a11c3ee6 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -4,23 +4,7 @@ import static org.ciyam.at.OpCode.calcOffset; import java.nio.ByteBuffer; import java.util.Arrays; -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.ciyam.at.API; import org.ciyam.at.CompilationException; import org.ciyam.at.FunctionCode; @@ -40,7 +24,6 @@ import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Base58; -import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; @@ -72,164 +55,6 @@ public class BTCACCT { public static final int MIN_LOCKTIME = 1500000000; public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes - /* - * 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) - * OP_EQUAL (does PKH match refund PKH?) - * OP_IF - * OP_DROP (no need for duplicate PKH) - * - * OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is so script passes) - * OP_ELSE - * OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails) - * OP_HASH160 (hash 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. - *

- * See comments in {@link BTCACCT} 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 - * @return Signed Bitcoin transaction for spending P2SH - */ - public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder) { - NetworkParameters params = BTC.getInstance().getNetworkParameters(); - - Transaction transaction = new Transaction(params); - transaction.setVersion(2); - - // Output is back to P2SH funder - transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(spendKey.getPubKeyHash())); - - 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, so no lockTime and no RBF - else - input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not 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 fundingOutputs, byte[] redeemScriptBytes, long lockTime) { - Function 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(); - }; - - return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder); - } - - /** - * 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 - * @return Signed Bitcoin transaction for redeeming P2SH - */ - public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret) { - Function 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); - } - /** * Returns Qortal AT creation bytes for cross-chain trading AT. *

@@ -240,12 +65,11 @@ public class BTCACCT { * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's bitcoin public key * @param secretHash 20-byte HASH160 of 32-byte secret * @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator - * @param initialPayout how much QORT to pay trade partner upon switch to 'trade mode' - * @param redeemPayout how much QORT to pay trade partner if they send correct 32-byte secret to AT + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secret to AT * @param bitcoinAmount how much BTC the AT creator is expecting to trade * @return */ - public static byte[] buildQortalAT(String qortalCreator, byte[] bitcoinPublicKeyHash, byte[] secretHash, int tradeTimeout, long initialPayout, long redeemPayout, long bitcoinAmount) { + public static byte[] buildQortalAT(String qortalCreator, byte[] bitcoinPublicKeyHash, byte[] secretHash, int tradeTimeout, long qortAmount, long bitcoinAmount) { // Labels for data segment addresses int addrCounter = 0; @@ -263,8 +87,7 @@ public class BTCACCT { addrCounter += 4; final int addrTradeTimeout = addrCounter++; - final int addrInitialPayoutAmount = addrCounter++; - final int addrRedeemPayoutAmount = addrCounter++; + final int addrQortAmount = addrCounter++; final int addrBitcoinAmount = addrCounter++; final int addrMessageTxType = addrCounter++; @@ -319,13 +142,9 @@ public class BTCACCT { assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; dataByteBuffer.putLong(tradeTimeout); - // Initial payout amount - assert dataByteBuffer.position() == addrInitialPayoutAmount * MachineState.VALUE_SIZE : "addrInitialPayoutAmount incorrect"; - dataByteBuffer.putLong(initialPayout); - - // Redeem payout amount - assert dataByteBuffer.position() == addrRedeemPayoutAmount * MachineState.VALUE_SIZE : "addrRedeemPayoutAmount incorrect"; - dataByteBuffer.putLong(redeemPayout); + // Redeem Qort amount + assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; + dataByteBuffer.putLong(qortAmount); // Expected Bitcoin amount assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect"; @@ -433,9 +252,6 @@ public class BTCACCT { /* Switch to 'trade mode' */ labelTradeMode = codeByteBuffer.position(); - // Send initial payment to recipient so they have enough funds to message AT if all goes well - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrInitialPayoutAmount)); - // Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout)); @@ -504,7 +320,7 @@ public class BTCACCT { // Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer)); // Pay AT's balance to recipient - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrRedeemPayoutAmount)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); // Fall-through to refunding any remaining balance back to AT creator /* Refund balance back to AT creator */ @@ -578,13 +394,10 @@ public class BTCACCT { dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes // Trade timeout - tradeData.tradeRefundTimeout = dataByteBuffer.getLong(); - - // Initial payout - tradeData.initialPayout = dataByteBuffer.getLong(); + tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); // Redeem payout - tradeData.redeemPayout = dataByteBuffer.getLong(); + tradeData.qortAmount = dataByteBuffer.getLong(); // Expected BTC amount tradeData.expectedBitcoin = dataByteBuffer.getLong(); @@ -623,12 +436,12 @@ public class BTCACCT { // We'll suggest half of trade timeout CiyamAtSettings ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings(); - int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeRefundTimeout / ciyamAtSettings.minutesPerBlock); + int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeTimeout / ciyamAtSettings.minutesPerBlock); BlockData blockData = repository.getBlockRepository().fromHeight(tradeModeSwitchHeight); if (blockData != null) { tradeData.tradeModeTimestamp = blockData.getTimestamp(); // NOTE: milliseconds from epoch - tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeRefundTimeout / 2 * 60); + tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeTimeout / 2 * 60); } } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; @@ -637,46 +450,4 @@ public class BTCACCT { return tradeData; } - public static byte[] findP2shSecret(String p2shAddress, List 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 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 != BTCACCT.SECRET_LENGTH) - continue; - - return secret; - } - } - - return null; - } - } diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java new file mode 100644 index 00000000..90e77710 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -0,0 +1,232 @@ +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) + * OP_EQUAL (does PKH match refund PKH?) + * OP_IF + * OP_DROP (no need for duplicate PKH) + * + * OP_CHECKLOCKTIMEVERIFY (if this passes, leftover stack is so script passes) + * OP_ELSE + * OP_EQUALVERIFY (duplicate PKH must match redeem PKH or script fails) + * OP_HASH160 (hash 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. + *

+ * 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 + * @return Signed Bitcoin transaction for spending P2SH + */ + public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder) { + NetworkParameters params = BTC.getInstance().getNetworkParameters(); + + Transaction transaction = new Transaction(params); + transaction.setVersion(2); + + // Output is back to P2SH funder + transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(spendKey.getPubKeyHash())); + + 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, so no lockTime and no RBF + else + input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not 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 fundingOutputs, byte[] redeemScriptBytes, long lockTime) { + Function 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(); + }; + + return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder); + } + + /** + * 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 + * @return Signed Bitcoin transaction for redeeming P2SH + */ + public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret) { + Function 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); + } + + /** Returns 'secret', if any, given list of raw bitcoin transactions. */ + public static byte[] findP2shSecret(String p2shAddress, List 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 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; + } + +} diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index a19ef81d..e994df14 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -30,13 +30,9 @@ public class CrossChainTradeData { @Schema(description = "HASH160 of 32-byte secret") public byte[] secretHash; - @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 = "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; @@ -45,7 +41,7 @@ public class CrossChainTradeData { public Long tradeModeTimestamp; @Schema(description = "How long from beginning trade until AT triggers automatic refund to AT creator (minutes)") - public long tradeRefundTimeout; + public int tradeTimeout; @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)") public Integer tradeRefundHeight; diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 3c8b4f63..6d5c1fb8 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -15,6 +15,11 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class TradeBotData { + // Never expose this + @XmlTransient + @Schema(hidden = true) + private byte[] tradePrivateKey; + public enum State { BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_SENDING_MESSAGE_TO_AT(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), ALICE_WAITING_FOR_P2SH_A(110), ALICE_WAITING_FOR_AT_LOCK(120), ALICE_WATCH_P2SH_B(130); @@ -30,13 +35,10 @@ public class TradeBotData { return map.get(value); } } - private State tradeState; - // Never expose this - @XmlTransient - @Schema(hidden = true) - private byte[] tradePrivateKey; + private String atAddress; + private int tradeTimeout; private byte[] tradeNativePublicKey; private byte[] tradeNativePublicKeyHash; @@ -47,23 +49,25 @@ public class TradeBotData { private byte[] tradeForeignPublicKey; private byte[] tradeForeignPublicKeyHash; - private String atAddress; + private long bitcoinAmount; private byte[] lastTransactionSignature; - public TradeBotData(byte[] tradePrivateKey, State tradeState, - byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] secretHash, - byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, String atAddress, - byte[] lastTransactionSignature) { + public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, int tradeTimeout, + byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] secretHash, + byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, + long bitcoinAmount, byte[] lastTransactionSignature) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; + this.atAddress = atAddress; + this.tradeTimeout = tradeTimeout; this.tradeNativePublicKey = tradeNativePublicKey; this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; this.secret = secret; this.secretHash = secretHash; this.tradeForeignPublicKey = tradeForeignPublicKey; this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; - this.atAddress = atAddress; + this.bitcoinAmount = bitcoinAmount; this.lastTransactionSignature = lastTransactionSignature; } @@ -79,6 +83,18 @@ public class TradeBotData { this.tradeState = state; } + public String getAtAddress() { + return this.atAddress; + } + + public void setAtAddress(String atAddress) { + this.atAddress = atAddress; + } + + public int getTradeTimeout() { + return this.tradeTimeout; + } + public byte[] getTradeNativePublicKey() { return this.tradeNativePublicKey; } @@ -103,12 +119,8 @@ public class TradeBotData { return this.tradeForeignPublicKeyHash; } - public String getAtAddress() { - return this.atAddress; - } - - public void setAtAddress(String atAddress) { - this.atAddress = atAddress; + public long getBitcoinAmount() { + return this.bitcoinAmount; } public byte[] getLastTransactionSignature() { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 4d91dd6e..f6a302e3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -19,8 +19,11 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public List getAllTradeBotData() throws DataException { - String sql = "SELECT trade_private_key, trade_state, trade_native_public_key, trade_native_public_key_hash, " - + "secret, secret_hash, trade_foreign_public_key, trade_foreign_public_key_hash, at_address, last_transaction_signature " + String sql = "SELECT trade_private_key, trade_state, at_address, trade_timeout, " + + "trade_native_public_key, trade_native_public_key_hash, " + + "secret, secret_hash, " + + "trade_foreign_public_key, trade_foreign_public_key_hash, " + + "bitcoin_amount, last_transaction_signature " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -36,18 +39,22 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { if (tradeState == null) throw new DataException("Illegal trade-bot trade-state fetched from repository"); - byte[] tradeNativePublicKey = resultSet.getBytes(3); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(4); - byte[] secret = resultSet.getBytes(5); - byte[] secretHash = resultSet.getBytes(6); - byte[] tradeForeignPublicKey = resultSet.getBytes(7); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(8); - String atAddress = resultSet.getString(9); - byte[] lastTransactionSignature = resultSet.getBytes(10); + String atAddress = resultSet.getString(3); + int tradeTimeout = resultSet.getInt(4); + byte[] tradeNativePublicKey = resultSet.getBytes(5); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(6); + byte[] secret = resultSet.getBytes(7); + byte[] secretHash = resultSet.getBytes(8); + byte[] tradeForeignPublicKey = resultSet.getBytes(9); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(10); + long bitcoinAmount = resultSet.getLong(11); + byte[] lastTransactionSignature = resultSet.getBytes(12); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, + atAddress, tradeTimeout, tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, - tradeForeignPublicKey, tradeForeignPublicKeyHash, atAddress, lastTransactionSignature); + tradeForeignPublicKey, tradeForeignPublicKeyHash, + bitcoinAmount, lastTransactionSignature); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -63,12 +70,14 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) .bind("trade_state", tradeBotData.getState().value) + .bind("at_address", tradeBotData.getAtAddress()) + .bind("trade_timeout", tradeBotData.getTradeTimeout()) .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) .bind("secret", tradeBotData.getSecret()).bind("secret_hash", tradeBotData.getSecretHash()) .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) - .bind("at_address", tradeBotData.getAtAddress()) + .bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()); try { diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 54f8f3c3..54a816ab 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -621,11 +621,11 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + + "at_address QortalAddress, trade_timeout INT NOT NULL, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + "secret VARBINARY(32) NOT NULL, secret_hash VARBINARY(32) NOT NULL, " + "trade_foreign_public_key QortalPublicKey NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " - + "at_address QortalAddress, " - + "last_transaction_signature Signature, PRIMARY KEY (trade_private_key))"); + + "bitcoin_amount BIGINT NOT NULL, last_transaction_signature Signature, PRIMARY KEY (trade_private_key))"); break; default: diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 2026424f..7c6d10ec 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -47,7 +47,6 @@ public class AtTests extends Common { public static final byte[] bitcoinPublicKeyHash = new byte[20]; // not used in tests public static final byte[] secretHash = Crypto.hash160(secret); // daf59884b4d1aec8c1b17102530909ee43c0151a public static final int refundTimeout = 10; // blocks - public static final long initialPayout = 100000L; public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; @@ -61,7 +60,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -156,44 +155,6 @@ public class AtTests extends Common { } } - @SuppressWarnings("unused") - @Test - public void testInitialPayment() throws DataException { - try (final Repository repository = RepositoryManager.getRepository()) { - PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); - - long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); - - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); - Account at = deployAtTransaction.getATAccount(); - String atAddress = at.getAddress(); - - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); - - // Initial payment should happen 1st block after receiving recipient address - BlockUtils.mintBlock(repository); - - long expectedBalance = recipientsInitialBalance + initialPayout; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); - - assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); - - describeAt(repository, atAddress); - - // Test orphaning - BlockUtils.orphanLastBlock(repository); - - expectedBalance = recipientsInitialBalance; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); - - assertEquals("Recipient's pre-initial-payout balance incorrect", expectedBalance, actualBalance); - } - } - // TEST SENDING RECIPIENT ADDRESS BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) @SuppressWarnings("unused") @Test @@ -294,7 +255,7 @@ public class AtTests extends Common { ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee() + redeemAmount; + long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance); @@ -304,7 +265,7 @@ public class AtTests extends Common { // Orphan redeem BlockUtils.orphanLastBlock(repository); - expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee(); + expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee(); actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); @@ -347,7 +308,7 @@ public class AtTests extends Common { ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout; + long expectedBalance = recipientsInitialBalance; long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); @@ -389,7 +350,7 @@ public class AtTests extends Common { ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance + initialPayout - messageTransaction.getTransactionData().getFee(); + long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee(); long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); @@ -435,7 +396,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, initialPayout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -501,7 +462,7 @@ public class AtTests extends Common { // We don't bother to exactly calculate QORT spent running AT for several blocks, but we do know the expected range long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - initialPayout; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -521,7 +482,6 @@ public class AtTests extends Common { + "\tcreation timestamp: %s,\n" + "\tcurrent balance: %s QORT,\n" + "\tHASH160 of secret: %s,\n" - + "\tinitial payout: %s QORT,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" + "\ttrade timeout: %d minutes (from trade start),\n" @@ -531,10 +491,9 @@ public class AtTests extends Common { epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), HashCode.fromBytes(tradeData.secretHash).toString().substring(0, 40), - Amounts.prettyAmount(tradeData.initialPayout), - Amounts.prettyAmount(tradeData.redeemPayout), + Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), - tradeData.tradeRefundTimeout, + tradeData.tradeTimeout, currentBlockHeight)); // Are we in 'offer' or 'trade' stage? diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index d0530c47..bd5211fc 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -10,7 +10,7 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.repository.DataException; import org.qortal.test.common.Common; @@ -56,7 +56,7 @@ public class BtcTests extends Common { List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); byte[] expectedSecret = AtTests.secret; - byte[] secret = BTCACCT.findP2shSecret(p2shAddress, rawTransactions); + byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions); assertNotNull(secret); assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); diff --git a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java index 25f0430e..6b6b16e1 100644 --- a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java @@ -13,7 +13,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -103,7 +103,7 @@ public class BuildP2SH { System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime)); System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(secretHash))); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java index 25f20c68..935d83eb 100644 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java @@ -15,7 +15,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -113,7 +113,7 @@ public class CheckP2SH { System.out.println(String.format("P2SH address: %s", p2shAddress)); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index dec9f563..0aa0b762 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -34,21 +34,20 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("usage: DeployAT ")); System.err.println(String.format("example: DeployAT " + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" + "\t80.4020 \\\n" + "\t0.00864200 \\\n" + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" - + "\t0.0001 \\\n" + "\t123.456 \\\n" + "\t10")); System.exit(1); } public static void main(String[] args) { - if (args.length != 8) + if (args.length != 7) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); @@ -59,7 +58,6 @@ public class DeployAT { long expectedBitcoin = 0; byte[] bitcoinPublicKeyHash = null; byte[] secretHash = null; - long initialPayout = 0; long fundingAmount = 0; int tradeTimeout = 0; @@ -85,10 +83,6 @@ public class DeployAT { if (secretHash.length != 20) usage("Hash of secret must be 20 bytes"); - initialPayout = Long.parseLong(args[argIndex++]); - if (initialPayout < 0) - usage("Initial QORT payout must be positive"); - fundingAmount = Long.parseLong(args[argIndex++]); if (fundingAmount <= redeemAmount) usage("AT funding amount must be greater than QORT redeem amount"); @@ -120,7 +114,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, tradeTimeout, initialPayout, redeemAmount, expectedBitcoin); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, tradeTimeout, redeemAmount, expectedBitcoin); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 5a14906b..40968450 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -18,7 +18,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -121,7 +121,7 @@ public class Redeem { System.out.println(String.format("P2SH address: %s", p2shAddress)); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundBitcoinAddress.getHash(), lockTime, redeemAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); @@ -182,7 +182,7 @@ public class Redeem { Coin redeemAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(redeemAmount), BTC.format(bitcoinFee))); - Transaction redeemTransaction = BTCACCT.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret); + Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret); byte[] redeemBytes = redeemTransaction.bitcoinSerialize(); diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java index b4ab94b5..c6fd88ed 100644 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ b/src/test/java/org/qortal/test/btcacct/Refund.java @@ -18,7 +18,7 @@ import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.controller.Controller; import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -120,7 +120,7 @@ public class Refund { Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH); System.out.println(String.format("Refund recipient (PKH): %s (%s)", refundAddress, HashCode.fromBytes(refundAddress.getHash()))); - byte[] redeemScriptBytes = BTCACCT.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash); System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes))); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); @@ -186,7 +186,7 @@ public class Refund { Coin refundAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(refundAmount), BTC.format(bitcoinFee))); - Transaction redeemTransaction = BTCACCT.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); + Transaction redeemTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); byte[] redeemBytes = redeemTransaction.bitcoinSerialize(); From 23062c59cde396b2e7b1972d0da3a499171d307a Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 17 Jun 2020 16:56:49 +0100 Subject: [PATCH 09/51] WIP: trade-bot, particularly the new two-P2SH Qortal AT code --- .../api/resource/CrossChainResource.java | 8 +- .../java/org/qortal/controller/TradeBot.java | 19 +- .../java/org/qortal/crosschain/BTCACCT.java | 394 +++++++++++++----- .../data/crosschain/CrossChainTradeData.java | 21 +- .../java/org/qortal/test/btcacct/AtTests.java | 6 +- 5 files changed, 335 insertions(+), 113 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 405d44b8..847718a2 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -432,7 +432,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -483,7 +483,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -580,7 +580,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -678,7 +678,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.hashOfSecretB); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index a398559c..7ea1fe22 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -34,6 +34,7 @@ import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.utils.Base58; import org.qortal.utils.NTP; public class TradeBot { @@ -55,9 +56,6 @@ public class TradeBot { } public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { - BTC btc = BTC.getInstance(); - NetworkParameters params = btc.getNetworkParameters(); - byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secret = generateSecret(); byte[] secretHash = Crypto.digest(secret); @@ -125,7 +123,7 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); // P2SH_a to be funded - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.foreignPublicKeyHash, secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.creatorBitcoinPKH, secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -187,7 +185,7 @@ public class TradeBot { // Fetch AT so we can determine trade start timestamp ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.error(String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); return; } @@ -226,7 +224,8 @@ public class TradeBot { System.arraycopy(messageData, 20, aliceForeignPublicKeyHash, 0, 20); // Determine P2SH address and confirm funded - int lockTime = (int) (tradeStartTimestamp / 1000L + tradeBotData.getTradeTimeout() / 4 * 60); // First P2SH locktime is ¼ of timeout period + // First P2SH refund timeout is last in chain, so add all of tradeTimeout + int lockTime = (int) (tradeStartTimestamp / 1000L + tradeBotData.getTradeTimeout() * 60); byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTime, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); @@ -236,13 +235,13 @@ public class TradeBot { // Good to go - send MESSAGE to AT - byte[] aliceNativePublicKeyHash = Crypto.hash160(messageTransactionData.getCreatorPublicKey()); + byte[] aliceNativeAddress = Base58.decode(Crypto.toAddress(messageTransactionData.getCreatorPublicKey())); // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume byte[] outgoingMessageData = new byte[96]; - System.arraycopy(aliceSecretHash, 0, outgoingMessageData, 0, 20); + System.arraycopy(aliceNativeAddress, 0, outgoingMessageData, 0, aliceNativeAddress.length); System.arraycopy(aliceForeignPublicKeyHash, 0, outgoingMessageData, 32, 20); - System.arraycopy(aliceNativePublicKeyHash, 0, outgoingMessageData, 64, 20); + System.arraycopy(aliceSecretHash, 0, outgoingMessageData, 64, 20); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); @@ -253,7 +252,7 @@ public class TradeBot { ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.error(String.format("Unable to send MESSAGE to AT '%s': %s", tradeBotData.getAtAddress(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", tradeBotData.getAtAddress(), result.name())); return; } diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index a11c3ee6..9ce84abf 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -14,12 +14,10 @@ import org.ciyam.at.Timestamp; import org.qortal.account.Account; import org.qortal.asset.Asset; import org.qortal.at.QortalAtLoggerFactory; -import org.qortal.block.BlockChain; -import org.qortal.block.BlockChain.CiyamAtSettings; +import org.qortal.at.QortalFunctionCode; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; 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; @@ -28,32 +26,71 @@ import org.qortal.utils.Base58; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; -/* - * Bob generates Bitcoin private key - * private key required to sign P2SH redeem tx - * private key can be used to create 'secret' (e.g. double-SHA256) - * encrypted private key could be stored in Qortal AT for access by Bob from any node - * Bob creates Qortal AT - * Alice finds Qortal AT and wants to trade - * Alice generates Bitcoin private key - * Alice will need to send Bob her Qortal address and Bitcoin refund address - * Bob sends Alice's Qortal address to Qortal AT - * Qortal AT sends initial QORT payment to Alice (so she has QORT to send message to AT and claim funds) - * Alice receives funds and checks Qortal AT to confirm it's locked to her - * Alice creates/funds Bitcoin P2SH - * Alice requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime - * Bob checks P2SH is funded - * Bob requires: Bob's redeem Bitcoin address, Alice's refund Bitcoin address, derived locktime - * Bob uses secret to redeem P2SH - * Qortal core/UI will need to create, and sign, this transaction - * Alice scans P2SH redeem tx and uses secret to redeem Qortal AT +/** + * Cross-chain trade AT + * + *

+ *

    + *
  • Bob generates Bitcoin & Qortal 'trade' keys, and secret-b + *
      + *
    • private key required to sign P2SH redeem tx
    • + *
    • private key can be used to create 'secret' (e.g. double-SHA256)
    • + *
    • encrypted private key could be stored in Qortal AT for access by Bob from any node
    • + *
    + *
  • + *
  • Bob deploys Qortal AT + *
      + *
    + *
  • + *
  • Alice finds Qortal AT and wants to trade + *
      + *
    • Alice generates Bitcoin & Qortal 'trade' keys
    • + *
    • Alice funds Bitcoin P2SH-a
    • + *
    • Alice MESSAGEs Bob from her Qortal trade address, sending secret-hash-a and Bitcoin PKH
    • + *
    + *
  • + *
  • Bob receives MESSAGE + *
      + *
    • Checks Alice's P2SH-a
    • + *
    • Sends MESSAGE to Qortal AT from his trade address, containing: + *
        + *
      • Alice's trade Qortal address
      • + *
      • Alice's trade Bitcoin PKH
      • + *
      • secret-hash-a
      • + *
      + *
    • + *
    + *
  • + *
  • Alice checks Qortal AT to confirm it's locked to her + *
      + *
    • Alice creates/funds Bitcoin P2SH-b
    • + *
    + *
  • + *
  • Bob checks P2SH-b is funded + *
      + *
    • Bob redeems P2SH-b using his Bitcoin trade key and secret-b
    • + *
    + *
  • + *
  • Alice scans P2SH-b redeem tx to extract secret-b + *
      + *
    • Alice MESSAGEs Qortal AT from her trade address, sending secret-a & secret-b
    • + *
    + *
  • + *
  • Bob checks AT, extracts secret-a + *
      + *
    • Bob redeems P2SH-a using his Bitcoin trade key and secret-a
    • + *
    + *
  • + *
*/ - public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("edcdb1feb36e079c5f956faff2f24219b12e5fbaaa05654335e615e33218282f").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("ae1c6749b08465a5dec0224ab25e7551947f900df404bfed434a02fdad102b03").asBytes(); // SHA256 of AT code bytes + + private BTCACCT() { + } /** * Returns Qortal AT creation bytes for cross-chain trading AT. @@ -63,39 +100,50 @@ public class BTCACCT { * * @param qortalCreator Qortal address for AT creator, also used for refunds * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's bitcoin public key - * @param secretHash 20-byte HASH160 of 32-byte secret - * @param tradeTimeout how many minutes, from start of 'trade mode' until AT auto-refunds AT creator + * @param hashOfSecretB 20-byte HASH160 of 32-byte secret + * @param tradeTimeout how many minutes, from AT creation, until AT auto-refunds AT creator * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secret to AT * @param bitcoinAmount how much BTC the AT creator is expecting to trade * @return */ - public static byte[] buildQortalAT(String qortalCreator, byte[] bitcoinPublicKeyHash, byte[] secretHash, int tradeTimeout, long qortAmount, long bitcoinAmount) { + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, int tradeTimeout, long qortAmount, long bitcoinAmount) { // Labels for data segment addresses int addrCounter = 0; // Constants (with corresponding dataByteBuffer.put*() calls below) - final int addrQortalCreator1 = addrCounter++; - final int addrQortalCreator2 = addrCounter++; - final int addrQortalCreator3 = addrCounter++; - final int addrQortalCreator4 = addrCounter++; + final int addrCreatorTradeAddress1 = addrCounter++; + final int addrCreatorTradeAddress2 = addrCounter++; + final int addrCreatorTradeAddress3 = addrCounter++; + final int addrCreatorTradeAddress4 = addrCounter++; - final int addrBitcoinPublickeyHash = addrCounter; + final int addrBitcoinPublicKeyHash = addrCounter; addrCounter += 4; - final int addrSecretHash = addrCounter; + final int addrHashOfSecretB = addrCounter; addrCounter += 4; final int addrTradeTimeout = addrCounter++; + final int addrRefundTimeout = addrCounter++; final int addrQortAmount = addrCounter++; final int addrBitcoinAmount = addrCounter++; final int addrMessageTxType = addrCounter++; + final int addrExpectedOfferMessageLength = addrCounter++; + final int addrExpectedTradeMessageLength = addrCounter++; - final int addrSecretHashPointer = addrCounter++; + final int addrCreatorAddressPointer = addrCounter++; + final int addrHashOfSecretBPointer = addrCounter++; final int addrQortalRecipientPointer = addrCounter++; final int addrMessageSenderPointer = addrCounter++; + final int addrOfferMessageRecipientBitcoinPKHOffset = addrCounter++; + final int addrRecipientBitcoinPKHPointer = addrCounter++; + final int addrOfferMessageHashOfSecretAOffset = addrCounter++; + final int addrHashOfSecretAPointer = addrCounter++; + + final int addrTradeMessageSecretBOffset = addrCounter++; + final int addrMessageDataPointer = addrCounter++; final int addrMessageDataLength = addrCounter++; @@ -103,12 +151,17 @@ public class BTCACCT { // Variables + final int addrCreatorAddress1 = addrCounter++; + final int addrCreatorAddress2 = addrCounter++; + final int addrCreatorAddress3 = addrCounter++; + final int addrCreatorAddress4 = addrCounter++; + final int addrQortalRecipient1 = addrCounter++; final int addrQortalRecipient2 = addrCounter++; final int addrQortalRecipient3 = addrCounter++; final int addrQortalRecipient4 = addrCounter++; - final int addrTradeRefundTimestamp = addrCounter++; + final int addrRefundTimestamp = addrCounter++; final int addrLastTxTimestamp = addrCounter++; final int addrBlockTimestamp = addrCounter++; final int addrTxType = addrCounter++; @@ -119,29 +172,43 @@ public class BTCACCT { final int addrMessageSender3 = addrCounter++; final int addrMessageSender4 = addrCounter++; + final int addrMessageLength = addrCounter++; + final int addrMessageData = addrCounter; addrCounter += 4; + final int addrHashOfSecretA = addrCounter; + addrCounter += 4; + + final int addrRecipientBitcoinPKH = addrCounter; + addrCounter += 4; + + final int addrMode = addrCounter++; + // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); // AT creator's Qortal address, decoded from Base58 - assert dataByteBuffer.position() == addrQortalCreator1 * MachineState.VALUE_SIZE : "addrQortalCreator1 incorrect"; - byte[] qortalCreatorBytes = Base58.decode(qortalCreator); - dataByteBuffer.put(Bytes.ensureCapacity(qortalCreatorBytes, 32, 0)); + assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; + byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); + dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); // Bitcoin public key hash - assert dataByteBuffer.position() == addrBitcoinPublickeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect"; + assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect"; dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0)); // Hash of secret - assert dataByteBuffer.position() == addrSecretHash * MachineState.VALUE_SIZE : "addrSecretHash incorrect"; - dataByteBuffer.put(Bytes.ensureCapacity(secretHash, 32, 0)); + assert dataByteBuffer.position() == addrHashOfSecretB * MachineState.VALUE_SIZE : "addrHashOfSecretB incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(hashOfSecretB, 32, 0)); // Trade timeout in minutes assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; dataByteBuffer.putLong(tradeTimeout); + // Refund timeout in minutes (¾ of trade-timeout) + assert dataByteBuffer.position() == addrRefundTimeout * MachineState.VALUE_SIZE : "addrRefundTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout * 3 / 4); + // Redeem Qort amount assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; dataByteBuffer.putLong(qortAmount); @@ -154,9 +221,21 @@ public class BTCACCT { assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect"; dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); + // Expected length of OFFER MESSAGE data from AT creator + assert dataByteBuffer.position() == addrExpectedOfferMessageLength * MachineState.VALUE_SIZE : "addrExpectedOfferMessageLength incorrect"; + dataByteBuffer.putLong(32L + 32L + 32L); + + // Expected length of TRADE MESSAGE data from trade partner / "recipient" + assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; + dataByteBuffer.putLong(32L + 32L); + + // Index into data segment of AT creator's address, used by GET_B_IND + assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; + dataByteBuffer.putLong(addrCreatorAddress1); + // Index into data segment of hash, used by GET_B_IND - assert dataByteBuffer.position() == addrSecretHashPointer * MachineState.VALUE_SIZE : "addrSecretHashPointer incorrect"; - dataByteBuffer.putLong(addrSecretHash); + assert dataByteBuffer.position() == addrHashOfSecretBPointer * MachineState.VALUE_SIZE : "addrHashOfSecretBPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretB); // Index into data segment of recipient address, used by SET_B_IND assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect"; @@ -166,6 +245,26 @@ public class BTCACCT { assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; dataByteBuffer.putLong(addrMessageSender1); + // Offset into OFFER MESSAGE data payload for extracting recipient's Bitcoin PKH + assert dataByteBuffer.position() == addrOfferMessageRecipientBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrOfferMessageRecipientBitcoinPKHOffset incorrect"; + dataByteBuffer.putLong(32L); + + // Index into data segment of hash, used by SET_B_IND + assert dataByteBuffer.position() == addrRecipientBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrRecipientBitcoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrRecipientBitcoinPKH); + + // Offset into OFFER MESSAGE data payload for extracting hash-of-secret-A + assert dataByteBuffer.position() == addrOfferMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrOfferMessageHashOfSecretAOffset incorrect"; + dataByteBuffer.putLong(64L); + + // Index into data segment of hash, used by GET_B_IND + assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; + dataByteBuffer.putLong(addrHashOfSecretA); + + // Offset into TRADE MESSAGE data payload for extracting secret-B + assert dataByteBuffer.position() == addrTradeMessageSecretBOffset * MachineState.VALUE_SIZE : "addrTradeMessageSecretBOffset incorrect"; + dataByteBuffer.putLong(64L); + // Source location and length for hashing any passed secret assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; dataByteBuffer.putLong(addrMessageData); @@ -180,9 +279,13 @@ public class BTCACCT { Integer labelOfferTxLoop = null; Integer labelCheckOfferTx = null; - Integer labelTradeMode = null; + Integer labelCheckNonRefundOfferTx = null; + Integer labelOfferTxExtract = null; Integer labelTradeTxLoop = null; Integer labelCheckTradeTx = null; + Integer labelCheckTradeSender = null; + Integer labelCheckSecretB = null; + Integer labelPayout = null; ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); @@ -196,10 +299,17 @@ public class BTCACCT { // Use AT creation 'timestamp' as starting point for finding transactions sent to AT codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to AT creation 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxTimestamp, addrRefundTimeout)); + + // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); + // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); - /* Loop, waiting for message from AT owner containing trade partner details, or AT owner's address to cancel offer */ + /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ /* Transaction processing loop */ labelOfferTxLoop = codeByteBuffer.position(); @@ -223,17 +333,17 @@ public class BTCACCT { // If transaction type is not MESSAGE type then go look for another transaction codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop))); - /* Check transaction's sender */ + /* Check transaction's sender. We're expecting AT creator's trade address. */ // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalCreator1, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalCreator2, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalCreator3, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalCreator4, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelOfferTxLoop))); /* Extract trade partner info from message */ @@ -242,28 +352,43 @@ public class BTCACCT { // Save B register into data segment starting at addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer)); // Compare each of recipient address with creator's address (for offer-cancel scenario). If they don't match, assume recipient is trade partner. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrQortalCreator1, calcOffset(codeByteBuffer, labelTradeMode))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrQortalCreator2, calcOffset(codeByteBuffer, labelTradeMode))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrQortalCreator3, calcOffset(codeByteBuffer, labelTradeMode))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrQortalCreator4, calcOffset(codeByteBuffer, labelTradeMode))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); // Recipient address is AT creator's address, so cancel offer and finish. codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); - /* Switch to 'trade mode' */ - labelTradeMode = codeByteBuffer.position(); + /* Possible switch-to-trade-mode message */ + labelCheckNonRefundOfferTx = codeByteBuffer.position(); - // Calculate trade timeout refund 'timestamp' by adding addrTradeTimeout minutes to above message's 'timestamp', then save into addrTradeRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrTradeRefundTimestamp, addrLastTxTimestamp, addrTradeTimeout)); + // Not off-cancel scenario so check we received expected number of message bytes + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedOfferMessageLength, calcOffset(codeByteBuffer, labelOfferTxExtract))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelOfferTxLoop == null ? 0 : labelOfferTxLoop)); + + labelOfferTxExtract = codeByteBuffer.position(); + + // Message is expected length, extract recipient's Bitcoin PKH + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOfferMessageRecipientBitcoinPKHOffset)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrRecipientBitcoinPKHPointer)); + + // Extract hash-of-secret-a + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOfferMessageHashOfSecretAOffset)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); + + /* We are in 'trade mode' */ + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, 1)); // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); - /* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret */ + /* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret-a and secret-b */ // Fetch current block 'timestamp' codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); // If we're not past refund 'timestamp' then look for next transaction - codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrTradeRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop))); // We're past refund 'timestamp' so go refund everything back to AT creator codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); @@ -289,8 +414,15 @@ public class BTCACCT { // If transaction type is not MESSAGE type then go look for another transaction codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop))); + /* Check message payload length */ + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelCheckTradeSender))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelOfferTxLoop == null ? 0 : labelOfferTxLoop)); + /* Check transaction's sender */ + labelCheckTradeSender = codeByteBuffer.position(); + // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) @@ -301,21 +433,40 @@ public class BTCACCT { codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop))); codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop))); - /* Check 'secret' in transaction's message */ + /* Check 'secret-a' in transaction's message */ - // Extract message from transaction into B register + // Extract secret-A from first 32 bytes of message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); - // Load B register with expected hash result (as pointed to by addrSecretHashPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrSecretHashPointer)); + // Load B register with expected hash result (as pointed to by addrHashOfSecretAPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretAPointer)); // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). // Save the equality result (1 if they match, 0 otherwise) into addrResult. codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); // If hashes don't match, addrResult will be zero so go find another transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckSecretB))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxLoop == null ? 0 : labelTradeTxLoop)); + + /* Check 'secret-b' in transaction's message */ + + labelCheckSecretB = codeByteBuffer.position(); + + // Extract secret-B from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageSecretBOffset)); + // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); + // Load B register with expected hash result (as pointed to by addrHashOfSecretBPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrHashOfSecretBPointer)); + // Perform HASH160 using source data at addrMessageData. (Location and length specified via addrMessageDataPointer and addrMessageDataLength). + // Save the equality result (1 if they match, 0 otherwise) into addrResult. + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); + // If hashes don't match, addrResult will be zero so go find another transaction + codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxLoop == null ? 0 : labelTradeTxLoop)); /* Success! Pay arranged amount to intended recipient */ + labelPayout = codeByteBuffer.position(); // Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer)); @@ -378,24 +529,29 @@ public class BTCACCT { tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); - byte[] addressBytes = new byte[32]; + byte[] addressBytes = new byte[25]; - // Skip AT creator address - dataByteBuffer.position(dataByteBuffer.position() + 32); + // Skip creator's trade address + dataByteBuffer.get(addressBytes); + tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); - // Bitcoin/foreign public key hash - tradeData.foreignPublicKeyHash = new byte[20]; - dataByteBuffer.get(tradeData.foreignPublicKeyHash); - dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes + // Creator's Bitcoin/foreign public key hash + tradeData.creatorBitcoinPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes - // Hash of secret - tradeData.secretHash = new byte[20]; - dataByteBuffer.get(tradeData.secretHash); - dataByteBuffer.position(dataByteBuffer.position() + 32 - 20); // skip to 32 bytes + // Hash of secret B + tradeData.hashOfSecretB = new byte[20]; + dataByteBuffer.get(tradeData.hashOfSecretB); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.hashOfSecretB.length); // skip to 32 bytes // Trade timeout tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); + // AT refund timeout (probably only useful for debugging) + tradeData.refundTimeout = (int) dataByteBuffer.getLong(); + // Redeem payout tradeData.qortAmount = dataByteBuffer.getLong(); @@ -405,7 +561,16 @@ public class BTCACCT { // Skip MESSAGE transaction type dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip pointer to secretHash + // Skip expected OFFER message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip expected TRADE message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-B dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to Qortal recipient @@ -414,35 +579,78 @@ public class BTCACCT { // Skip pointer to message sender dataByteBuffer.position(dataByteBuffer.position() + 8); + // Skip OFFER message data offset for recipient's bitcoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to recipient's bitcoin PKH + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip OFFER message data offset for hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip pointer to hash-of-secret-A + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip TRADE message data offset for secret-B + dataByteBuffer.position(dataByteBuffer.position() + 8); + // Skip pointer to message data dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip message data length dataByteBuffer.position(dataByteBuffer.position() + 8); - // Qortal recipient (if any) + /* End of constants */ + + // Skip AT creator's address + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Recipient's trade address (if present) dataByteBuffer.get(addressBytes); + String qortalRecipient = Base58.encode(addressBytes); + dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); // Trade offer timeout (AT 'timestamp' converted to Qortal block height) long tradeRefundTimestamp = dataByteBuffer.getLong(); - if (tradeRefundTimestamp != 0) { + // Last transaction timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip block timestamp + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip transaction type + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary result + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message sender + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Skip message length + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip temporary message data + dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); + + // Potential hash of secret A + byte[] hashOfSecretA = new byte[32]; + dataByteBuffer.get(hashOfSecretA); + + // Potential recipient's Bitcoin PKH + byte[] recipientBitcoinPKH = new byte[20]; + dataByteBuffer.get(recipientBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - recipientBitcoinPKH.length); // skip to 32 bytes + + long mode = dataByteBuffer.getLong(); + + if (mode != 0) { tradeData.mode = CrossChainTradeData.Mode.TRADE; tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; - - if (addressBytes[0] != 0) - tradeData.qortalRecipient = Base58.encode(Arrays.copyOf(addressBytes, Account.ADDRESS_LENGTH)); - - // We'll suggest half of trade timeout - CiyamAtSettings ciyamAtSettings = BlockChain.getInstance().getCiyamAtSettings(); - - int tradeModeSwitchHeight = (int) (tradeData.tradeRefundHeight - tradeData.tradeTimeout / ciyamAtSettings.minutesPerBlock); - - BlockData blockData = repository.getBlockRepository().fromHeight(tradeModeSwitchHeight); - if (blockData != null) { - tradeData.tradeModeTimestamp = blockData.getTimestamp(); // NOTE: milliseconds from epoch - tradeData.lockTime = (int) (tradeData.tradeModeTimestamp / 1000L + tradeData.tradeTimeout / 2 * 60); - } + tradeData.qortalRecipient = qortalRecipient; + tradeData.hashOfSecretA = hashOfSecretA; + tradeData.recipientBitcoinPKH = recipientBitcoinPKH; } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index e994df14..d5a1a7ff 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -20,6 +20,12 @@ 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 public-key-hash (PKH)") + public byte[] creatorBitcoinPKH; + @Schema(description = "Timestamp when AT was created (milliseconds since epoch)") public long creationTimestamp; @@ -27,8 +33,11 @@ public class CrossChainTradeData { @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 = "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) @@ -40,9 +49,12 @@ public class CrossChainTradeData { @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)") + @Schema(description = "General trade timeout (minutes) used to derive P2SH locktimes and AT refund timeout") public int tradeTimeout; + @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)") + public int refundTimeout; + @Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)") public Integer tradeRefundHeight; @@ -55,7 +67,8 @@ public class CrossChainTradeData { @Schema(description = "Suggested Bitcoin P2SH nLockTime based on trade timeout") public Integer lockTime; - public byte[] foreignPublicKeyHash; + @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)") + public byte[] recipientBitcoinPKH; // Constructors diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 7c6d10ec..3ae7de53 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -481,7 +481,7 @@ public class AtTests extends Common { + "\tcreator: %s,\n" + "\tcreation timestamp: %s,\n" + "\tcurrent balance: %s QORT,\n" - + "\tHASH160 of secret: %s,\n" + + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" + "\ttrade timeout: %d minutes (from trade start),\n" @@ -490,7 +490,7 @@ public class AtTests extends Common { tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), - HashCode.fromBytes(tradeData.secretHash).toString().substring(0, 40), + HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), tradeData.tradeTimeout, @@ -504,9 +504,11 @@ public class AtTests extends Common { // Trade System.out.println(String.format("\tstatus: 'trade mode',\n" + "\ttrade timeout: block %d,\n" + + "\tHASH160 of secret-A: %s,\n" + "\tBitcoin P2SH nLockTime: %d (%s),\n" + "\ttrade recipient: %s", tradeData.tradeRefundHeight, + HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), tradeData.lockTime, epochMilliFormatter.apply(tradeData.lockTime * 1000L), tradeData.qortalRecipient)); } From 886c9156a523f0b748c61793958549bdd85c1cec Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 18 Jun 2020 12:22:35 +0100 Subject: [PATCH 10/51] WIP: cross-chain trading AT passes AtTests now --- .../api/resource/CrossChainResource.java | 163 ++++++++++++-- src/main/java/org/qortal/at/QortalATAPI.java | 9 +- .../java/org/qortal/controller/TradeBot.java | 20 +- .../java/org/qortal/crosschain/BTCACCT.java | 60 ++++- .../data/crosschain/CrossChainTradeData.java | 7 +- .../java/org/qortal/test/btcacct/AtTests.java | 207 +++++++++++++----- .../org/qortal/test/btcacct/BtcTests.java | 2 +- 7 files changed, 374 insertions(+), 94 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 847718a2..73f9b100 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -13,6 +13,8 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.function.Function; +import java.util.function.ToIntFunction; import javax.servlet.http.HttpServletRequest; import javax.ws.rs.DELETE; @@ -392,9 +394,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh") + @Path("/p2sh/a") @Operation( - summary = "Returns Bitcoin P2SH address based on trade info", + summary = "Returns Bitcoin P2SH-A address based on trade info", requestBody = @RequestBody( required = true, content = @Content( @@ -411,7 +413,35 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b") + @Operation( + summary = "Returns Bitcoin P2SH-B address based on trade info", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinTemplateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -432,7 +462,7 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData)); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -443,9 +473,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/check") + @Path("/p2sh/a/check") @Operation( - summary = "Checks Bitcoin P2SH address based on trade info", + summary = "Checks Bitcoin P2SH-A address based on trade info", requestBody = @RequestBody( required = true, content = @Content( @@ -462,7 +492,35 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - public CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest) { + public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b/check") + @Operation( + summary = "Checks Bitcoin P2SH-B address based on trade info", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinTemplateRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class)) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) + public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -483,7 +541,10 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, crossChainTradeData.lockTime, templateRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTime, templateRequest.redeemPublicKeyHash, hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -508,7 +569,7 @@ public class CrossChainResource { if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { p2shStatus.canRedeem = now >= medianBlockTime * 1000L; - p2shStatus.canRefund = now >= crossChainTradeData.lockTime * 1000L; + p2shStatus.canRefund = now >= lockTime * 1000L; } if (now >= medianBlockTime * 1000L) { @@ -524,9 +585,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/refund") + @Path("/p2sh/a/refund") @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH address", + summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-A address", requestBody = @RequestBody( required = true, content = @Content( @@ -544,7 +605,36 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - public String refundP2sh(CrossChainBitcoinRefundRequest refundRequest) { + public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) { + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b/refund") + @Operation( + summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-B address", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinRefundRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, + ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) { + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private String refundP2sh(CrossChainBitcoinRefundRequest refundRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -580,7 +670,10 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), crossChainTradeData.lockTime, refundRequest.redeemPublicKeyHash, crossChainTradeData.hashOfSecretB); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), lockTime, refundRequest.redeemPublicKeyHash, hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -597,7 +690,7 @@ public class CrossChainResource { if (fundingOutputs.isEmpty()) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - boolean canRefund = now >= crossChainTradeData.lockTime * 1000L; + boolean canRefund = now >= lockTime * 1000L; if (!canRefund) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); @@ -606,7 +699,7 @@ public class CrossChainResource { Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTime); + org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(refundTransaction); if (!wasBroadcast) @@ -619,9 +712,9 @@ public class CrossChainResource { } @POST - @Path("/p2sh/redeem") + @Path("/p2sh/a/redeem") @Operation( - summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH address", + summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-A address", requestBody = @RequestBody( required = true, content = @Content( @@ -639,7 +732,36 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - public String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest) { + public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) { + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); + } + + @POST + @Path("/p2sh/b/redeem") + @Operation( + summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-B address", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainBitcoinRedeemRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, + ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) + public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) { + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); + } + + private String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { BTC btc = BTC.getInstance(); NetworkParameters params = btc.getNetworkParameters(); @@ -678,7 +800,10 @@ public class CrossChainResource { if (crossChainTradeData.mode == Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, crossChainTradeData.lockTime, redeemKey.getPubKeyHash(), crossChainTradeData.hashOfSecretB); + int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); + byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, lockTime, redeemKey.getPubKeyHash(), hashOfSecret); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 8c6e4ba9..0975bacb 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -299,15 +299,12 @@ public class QortalATAPI extends API { byte[] messageData = this.getMessageFromTransaction(transactionData); - // Check data length is appropriate, i.e. not larger than B - if (messageData.length > 4 * 8) - return; - // 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 diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 7ea1fe22..373ceb81 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -34,7 +34,6 @@ import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; -import org.qortal.utils.Base58; import org.qortal.utils.NTP; public class TradeBot { @@ -123,7 +122,7 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); // P2SH_a to be funded - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTime, crossChainTradeData.creatorBitcoinPKH, secretHash); + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, secretHash); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); @@ -189,7 +188,7 @@ public class TradeBot { return; } - long tradeStartTimestamp = atData.getCreation(); + long atCreationTimestamp = atData.getCreation(); String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); @@ -223,10 +222,10 @@ public class TradeBot { byte[] aliceForeignPublicKeyHash = new byte[20]; System.arraycopy(messageData, 20, aliceForeignPublicKeyHash, 0, 20); - // Determine P2SH address and confirm funded - // First P2SH refund timeout is last in chain, so add all of tradeTimeout - int lockTime = (int) (tradeStartTimestamp / 1000L + tradeBotData.getTradeTimeout() * 60); - byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTime, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); + // Determine P2SH-A address and confirm funded + // First P2SH-A refund timeout is last in chain, so add all of tradeTimeout + int lockTimeA = BTCACCT.calcLockTimeA(atCreationTimestamp, tradeBotData.getTradeTimeout()); + byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); Long balance = BTC.getInstance().getBalance(p2shAddress); @@ -235,13 +234,10 @@ public class TradeBot { // Good to go - send MESSAGE to AT - byte[] aliceNativeAddress = Base58.decode(Crypto.toAddress(messageTransactionData.getCreatorPublicKey())); + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume - byte[] outgoingMessageData = new byte[96]; - System.arraycopy(aliceNativeAddress, 0, outgoingMessageData, 0, aliceNativeAddress.length); - System.arraycopy(aliceForeignPublicKeyHash, 0, outgoingMessageData, 32, 20); - System.arraycopy(aliceSecretHash, 0, outgoingMessageData, 64, 20); + byte[] outgoingMessageData = BTCACCT.buildOfferMessage(aliceNativeAddress, aliceForeignPublicKeyHash, aliceSecretHash); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 9ce84abf..e113c1aa 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -107,6 +107,8 @@ public class BTCACCT { * @return */ public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, int tradeTimeout, long qortAmount, long bitcoinAmount) { + int refundTimeout = calcRefundTimeout(tradeTimeout); + // Labels for data segment addresses int addrCounter = 0; @@ -207,7 +209,7 @@ public class BTCACCT { // Refund timeout in minutes (¾ of trade-timeout) assert dataByteBuffer.position() == addrRefundTimeout * MachineState.VALUE_SIZE : "addrRefundTimeout incorrect"; - dataByteBuffer.putLong(tradeTimeout * 3 / 4); + dataByteBuffer.putLong(refundTimeout); // Redeem Qort amount assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; @@ -263,7 +265,7 @@ public class BTCACCT { // Offset into TRADE MESSAGE data payload for extracting secret-B assert dataByteBuffer.position() == addrTradeMessageSecretBOffset * MachineState.VALUE_SIZE : "addrTradeMessageSecretBOffset incorrect"; - dataByteBuffer.putLong(64L); + dataByteBuffer.putLong(32L); // Source location and length for hashing any passed secret assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; @@ -634,9 +636,10 @@ public class BTCACCT { // Skip temporary message data dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); - // Potential hash of secret A - byte[] hashOfSecretA = new byte[32]; + // Potential hash160 of secret A + byte[] hashOfSecretA = new byte[20]; dataByteBuffer.get(hashOfSecretA); + dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes // Potential recipient's Bitcoin PKH byte[] recipientBitcoinPKH = new byte[20]; @@ -651,6 +654,8 @@ public class BTCACCT { tradeData.qortalRecipient = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; tradeData.recipientBitcoinPKH = recipientBitcoinPKH; + tradeData.lockTimeA = calcLockTimeA(tradeData.creationTimestamp, tradeData.tradeTimeout); + tradeData.lockTimeB = calcLockTimeB(tradeData.creationTimestamp, tradeData.tradeTimeout); } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; } @@ -658,4 +663,51 @@ public class BTCACCT { return tradeData; } + /** Returns trade-info MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildOfferMessage(String recipientQortalAddress, byte[] recipientBitcoinPKH, byte[] hashOfSecretA) { + byte[] data = new byte[32 + 32 + 32]; + byte[] recipientQortalAddressBytes = Base58.decode(recipientQortalAddress); + + System.arraycopy(recipientQortalAddressBytes, 0, data, 0, recipientQortalAddressBytes.length); + System.arraycopy(recipientBitcoinPKH, 0, data, 32, recipientBitcoinPKH.length); + System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + + return data; + } + + /** Returns refund MESSAGE payload for AT creator to cancel trade AT. */ + public static byte[] buildRefundMessage(String creatorQortalAddress) { + byte[] data = new byte[32]; + byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); + + System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); + + return data; + } + + /** Returns redeem MESSAGE payload for trade partner/recipient to send to AT. */ + public static byte[] buildTradeMessage(byte[] secretA, byte[] secretB) { + byte[] data = new byte[32 + 32]; + + System.arraycopy(secretA, 0, data, 0, secretA.length); + System.arraycopy(secretB, 0, data, 32, secretB.length); + + return data; + } + + /** Returns AT refundTimeout (minutes) based on tradeTimeout. */ + public static int calcRefundTimeout(int tradeTimeout) { + return tradeTimeout * 3 / 4; + } + + /** Returns P2SH-A lockTime (epoch seconds). */ + public static int calcLockTimeA(long atCreationTimestamp, int tradeTimeout) { + return (int) (atCreationTimestamp / 1000L + tradeTimeout * 60); + } + + /** Returns P2SH-B lockTime (epoch seconds). */ + public static int calcLockTimeB(long atCreationTimestamp, int tradeTimeout) { + return (int) (atCreationTimestamp / 1000L + tradeTimeout / 2 * 60); + } + } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index d5a1a7ff..11207dd5 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -64,8 +64,11 @@ public class CrossChainTradeData { public 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[] recipientBitcoinPKH; diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 3ae7de53..79a6edd1 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -1,7 +1,6 @@ package org.qortal.test.btcacct; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import java.time.Instant; import java.time.LocalDateTime; @@ -10,6 +9,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.function.Function; import org.bitcoinj.core.Base58; @@ -18,6 +18,7 @@ import org.junit.Test; import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; +import org.qortal.block.Block; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -43,10 +44,12 @@ import com.google.common.primitives.Bytes; public class AtTests extends Common { - public static final byte[] secret = "This string is exactly 32 bytes!".getBytes(); - public static final byte[] bitcoinPublicKeyHash = new byte[20]; // not used in tests - public static final byte[] secretHash = Crypto.hash160(secret); // daf59884b4d1aec8c1b17102530909ee43c0151a - public static final int refundTimeout = 10; // blocks + public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes(); + public static final byte[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a + public static final byte[] secretB = "This string is roughly 32 bytes?".getBytes(); + public static final byte[] hashOfSecretB = Crypto.hash160(secretB); // 31f0dd71decf59bbc8ef0661f4030479255cfa58 + public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); + public static final int tradeTimeout = 12; // blocks public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; @@ -60,7 +63,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, tradeTimeout, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -145,6 +148,10 @@ public class AtTests extends Common { describeAt(repository, atAddress); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + // Test orphaning BlockUtils.orphanLastBlock(repository); @@ -155,7 +162,59 @@ public class AtTests extends Common { } } - // TEST SENDING RECIPIENT ADDRESS BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) + @SuppressWarnings("unused") + @Test + public void testTradingInfoProcessing() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long messageFee = messageTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee - messageFee; + + describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should be in TRADE mode + assertEquals(CrossChainTradeData.Mode.TRADE, tradeData.mode); + + // Check hashOfSecretA was extracted correctly + assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); + + // Check trade partner/recipient Qortal address was extracted correctly + assertEquals(recipient.getAddress(), tradeData.qortalRecipient); + + // Check trade partner/recipient's Bitcoin PKH was extracted correctly + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.recipientBitcoinPKH)); + + // Test orphaning + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + + long expectedBalance = deployersPostDeploymentBalance; + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance); + } + } + + // TEST SENDING TRADING INFO BUT NOT FROM AT CREATOR (SHOULD BE IGNORED) @SuppressWarnings("unused") @Test public void testIncorrectTradeSender() throws DataException { @@ -171,11 +230,10 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT BUT NOT FROM AT CREATOR - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, bystander, recipientAddressBytes, atAddress); + // Send trade info to AT BUT NOT FROM AT CREATOR + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); - // Initial payment should NOT happen BlockUtils.mintBlock(repository); long expectedBalance = recipientsInitialBalance; @@ -184,6 +242,12 @@ public class AtTests extends Common { assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); describeAt(repository, atAddress); + + ATData atData = repository.getATRepository().fromATAddress(atAddress); + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should still be in OFFER mode + assertEquals(CrossChainTradeData.Mode.OFFER, tradeData.mode); } } @@ -201,12 +265,12 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address - BlockUtils.mintBlock(repository); + Block postDeploymentBlock = BlockUtils.mintBlock(repository); + int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); long messageFee = messageTransaction.getTransactionData().getFee(); @@ -216,9 +280,12 @@ public class AtTests extends Common { describeAt(repository, atAddress); + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + // Test orphaning - BlockUtils.orphanLastBlock(repository); - BlockUtils.orphanLastBlock(repository); + BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); long expectedBalance = deployersPostDeploymentBalance; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -229,7 +296,7 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testCorrectSecretCorrectSender() throws DataException { + public void testCorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -241,27 +308,32 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT - messageTransaction = sendMessage(repository, recipient, secret, atAddress); + // Send correct secrets to AT, from correct account + messageData = BTCACCT.buildTradeMessage(secretA, secretB); + messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertTrue(atData.getIsFinished()); + long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); - // Orphan redeem BlockUtils.orphanLastBlock(repository); @@ -279,7 +351,7 @@ public class AtTests extends Common { @SuppressWarnings("unused") @Test - public void testCorrectSecretIncorrectSender() throws DataException { + public void testCorrectSecretsIncorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -294,34 +366,39 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT, but from wrong account - messageTransaction = sendMessage(repository, bystander, secret, atAddress); + // Send correct secrets to AT, but from wrong account + messageData = BTCACCT.buildTradeMessage(secretA, secretB); + messageTransaction = sendMessage(repository, bystander, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + long expectedBalance = recipientsInitialBalance; long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); - describeAt(repository, atAddress); - checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @SuppressWarnings("unused") @Test - public void testIncorrectSecretCorrectSender() throws DataException { + public void testIncorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); @@ -335,28 +412,53 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - // Send recipient's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(recipient.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + // Send trade info to AT + byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); - // Initial payment should happen 1st block after receiving recipient address + // Give AT time to process message BlockUtils.mintBlock(repository); - // Send correct secret to AT, but from wrong account - byte[] wrongSecret = Crypto.digest(secret); - messageTransaction = sendMessage(repository, recipient, wrongSecret, atAddress); + // Send incorrect secrets to AT, from correct account + byte[] wrongSecret = new byte[32]; + Random random = new Random(); + random.nextBytes(wrongSecret); + messageData = BTCACCT.buildTradeMessage(wrongSecret, secretB); + messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee(); long actualBalance = recipient.getConfirmedBalance(Asset.QORT); assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + // Send incorrect secrets to AT, from correct account + messageData = BTCACCT.buildTradeMessage(secretA, wrongSecret); + messageTransaction = sendMessage(repository, recipient, messageData, atAddress); + + // AT should NOT send funds in the next block + BlockUtils.mintBlock(repository); + describeAt(repository, atAddress); + // Check AT is NOT finished + atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() * 2; + actualBalance = recipient.getConfirmedBalance(Asset.QORT); + + assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -396,7 +498,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, secretHash, refundTimeout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, tradeTimeout, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -455,6 +557,7 @@ public class AtTests extends Common { private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + int refundTimeout = BTCACCT.calcRefundTimeout(tradeTimeout); // AT should automatically refund deployer after 'refundTimeout' blocks for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) @@ -481,15 +584,17 @@ public class AtTests extends Common { + "\tcreator: %s,\n" + "\tcreation timestamp: %s,\n" + "\tcurrent balance: %s QORT,\n" + + "\tis finished: %b,\n" + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" - + "\ttrade timeout: %d minutes (from trade start),\n" + + "\ttrade timeout: %d minutes (from AT creation),\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), + atData.getIsFinished(), HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), @@ -503,13 +608,15 @@ public class AtTests extends Common { } else { // Trade System.out.println(String.format("\tstatus: 'trade mode',\n" - + "\ttrade timeout: block %d,\n" + + "\trefund height: block %d,\n" + "\tHASH160 of secret-A: %s,\n" - + "\tBitcoin P2SH nLockTime: %d (%s),\n" + + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" + + "\tBitcoin P2SH-B nLockTime: %d (%s),\n" + "\ttrade recipient: %s", tradeData.tradeRefundHeight, HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), - tradeData.lockTime, epochMilliFormatter.apply(tradeData.lockTime * 1000L), + tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), + tradeData.lockTimeB, epochMilliFormatter.apply(tradeData.lockTimeB * 1000L), tradeData.qortalRecipient)); } } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index bd5211fc..b9f7869a 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -55,7 +55,7 @@ public class BtcTests extends Common { List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); - byte[] expectedSecret = AtTests.secret; + byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions); assertNotNull(secret); From c3eb3850665b7ebc2d02ab150e046556fb81e0c3 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 18 Jun 2020 18:08:46 +0100 Subject: [PATCH 11/51] WIP: cross-chain trading with new lockTimes, requires AT v1.3.5 --- pom.xml | 2 +- .../api/model/CrossChainBuildRequest.java | 2 +- .../api/resource/CrossChainResource.java | 6 +- .../java/org/qortal/controller/TradeBot.java | 35 ++--- .../java/org/qortal/crosschain/BTCACCT.java | 140 +++++++++++------- .../data/crosschain/CrossChainTradeData.java | 3 - .../qortal/data/crosschain/TradeBotData.java | 8 +- .../hsqldb/HSQLDBCrossChainRepository.java | 22 ++- .../java/org/qortal/utils/BitTwiddling.java | 11 ++ .../java/org/qortal/test/btcacct/AtTests.java | 58 ++++++-- .../org/qortal/test/btcacct/DeployAT.java | 14 +- 11 files changed, 172 insertions(+), 129 deletions(-) diff --git a/pom.xml b/pom.xml index a0b05ed6..5c616c83 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ 0.15.5 1.64 ${maven.build.timestamp} - 1.3.4 + 1.3.5 3.6 1.8 1.2.2 diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java index 76fafc9c..e8d38703 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -24,7 +24,7 @@ public class CrossChainBuildRequest { 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) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 73f9b100..41089e91 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -156,7 +156,7 @@ public class CrossChainResource { if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - if (tradeRequest.secretHash == null || tradeRequest.secretHash.length != BTC.HASH160_LENGTH) + if (tradeRequest.hashOfSecretB == null || tradeRequest.hashOfSecretB.length != BTC.HASH160_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); if (tradeRequest.tradeTimeout == null) @@ -181,8 +181,8 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); - byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.secretHash, - tradeRequest.tradeTimeout, tradeRequest.qortAmount, tradeRequest.bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB, + tradeRequest.qortAmount, tradeRequest.bitcoinAmount); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 373ceb81..806805dc 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -56,11 +56,12 @@ public class TradeBot { public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); - byte[] secret = generateSecret(); - byte[] secretHash = Crypto.digest(secret); + byte[] secretB = generateSecret(); + byte[] hashOfSecretB = Crypto.digest(secretB); byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeAddress = Crypto.toAddress(tradeNativePublicKey); byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); @@ -78,7 +79,7 @@ public class TradeBot { String description = "QORT/BTC cross-chain trade"; String aTType = "ACCT"; String tags = "ACCT QORT BTC"; - byte[] creationBytes = BTCACCT.buildQortalAT(creator.getAddress(), tradeNativePublicKeyHash, secretHash, tradeBotCreateRequest.tradeTimeout, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -86,8 +87,8 @@ public class TradeBot { String atAddress = deployAtTransactionData.getAtAddress(); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, - atAddress, tradeBotCreateRequest.tradeTimeout, - tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + atAddress, + tradeNativePublicKey, tradeNativePublicKeyHash, secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, tradeBotCreateRequest.bitcoinAmount, null); repository.getCrossChainRepository().save(tradeBotData); @@ -115,7 +116,7 @@ public class TradeBot { byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, - crossChainTradeData.qortalAtAddress, crossChainTradeData.tradeTimeout, + crossChainTradeData.qortalAtAddress, tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.expectedBitcoin, null); @@ -188,8 +189,6 @@ public class TradeBot { return; } - long atCreationTimestamp = atData.getCreation(); - String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); @@ -212,20 +211,15 @@ public class TradeBot { // We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash byte[] messageData = messageTransactionData.getData(); - - if (messageData.length != 40) + BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); + if (offerMessageData == null) continue; - byte[] aliceSecretHash = new byte[20]; - System.arraycopy(messageData, 0, aliceSecretHash, 0, 20); - - byte[] aliceForeignPublicKeyHash = new byte[20]; - System.arraycopy(messageData, 20, aliceForeignPublicKeyHash, 0, 20); - + byte[] aliceForeignPublicKeyHash = offerMessageData.recipientBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; // Determine P2SH-A address and confirm funded - // First P2SH-A refund timeout is last in chain, so add all of tradeTimeout - int lockTimeA = BTCACCT.calcLockTimeA(atCreationTimestamp, tradeBotData.getTradeTimeout()); - byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), aliceSecretHash); + byte[] redeemScript = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScript); Long balance = BTC.getInstance().getBalance(p2shAddress); @@ -235,9 +229,10 @@ public class TradeBot { // Good to go - send MESSAGE to AT String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume - byte[] outgoingMessageData = BTCACCT.buildOfferMessage(aliceNativeAddress, aliceForeignPublicKeyHash, aliceSecretHash); + byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), outgoingMessageData, false, false); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index e113c1aa..172d24f9 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -22,6 +22,7 @@ import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Base58; +import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; @@ -87,7 +88,13 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("ae1c6749b08465a5dec0224ab25e7551947f900df404bfed434a02fdad102b03").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("ca0dc643fdaba4d12cd5550800a8353746f40a0d9824d8c10d8b4bd0324eac0d").asBytes(); // SHA256 of AT code bytes + + public static class OfferMessageData { + public byte[] recipientBitcoinPKH; + public byte[] hashOfSecretA; + public long lockTimeA; + } private BTCACCT() { } @@ -98,17 +105,14 @@ public class BTCACCT { * tradeTimeout (minutes) is the time window for the recipient to send the * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. * - * @param qortalCreator Qortal address for AT creator, also used for refunds - * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's bitcoin public key - * @param hashOfSecretB 20-byte HASH160 of 32-byte secret - * @param tradeTimeout how many minutes, from AT creation, until AT auto-refunds AT creator - * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secret to AT + * @param creatorTradeAddress AT creator's trade Qortal address, also used for refunds + * @param bitcoinPublicKeyHash 20-byte HASH160 of creator's trade Bitcoin public key + * @param hashOfSecretB 20-byte HASH160 of 32-byte secret-B + * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT * @param bitcoinAmount how much BTC the AT creator is expecting to trade * @return */ - public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, int tradeTimeout, long qortAmount, long bitcoinAmount) { - int refundTimeout = calcRefundTimeout(tradeTimeout); - + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount) { // Labels for data segment addresses int addrCounter = 0; @@ -125,8 +129,6 @@ public class BTCACCT { final int addrHashOfSecretB = addrCounter; addrCounter += 4; - final int addrTradeTimeout = addrCounter++; - final int addrRefundTimeout = addrCounter++; final int addrQortAmount = addrCounter++; final int addrBitcoinAmount = addrCounter++; @@ -163,6 +165,9 @@ public class BTCACCT { final int addrQortalRecipient3 = addrCounter++; final int addrQortalRecipient4 = addrCounter++; + final int addrLockTimeA = addrCounter++; + final int addrLockTimeB = addrCounter++; + final int addrRefundTimeout = addrCounter++; final int addrRefundTimestamp = addrCounter++; final int addrLastTxTimestamp = addrCounter++; final int addrBlockTimestamp = addrCounter++; @@ -190,7 +195,7 @@ public class BTCACCT { // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); - // AT creator's Qortal address, decoded from Base58 + // AT creator's trade Qortal address, decoded from Base58 assert dataByteBuffer.position() == addrCreatorTradeAddress1 * MachineState.VALUE_SIZE : "addrCreatorTradeAddress1 incorrect"; byte[] creatorTradeAddressBytes = Base58.decode(creatorTradeAddress); dataByteBuffer.put(Bytes.ensureCapacity(creatorTradeAddressBytes, 32, 0)); @@ -199,18 +204,10 @@ public class BTCACCT { assert dataByteBuffer.position() == addrBitcoinPublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinPublicKeyHash incorrect"; dataByteBuffer.put(Bytes.ensureCapacity(bitcoinPublicKeyHash, 32, 0)); - // Hash of secret + // Hash of secret-B assert dataByteBuffer.position() == addrHashOfSecretB * MachineState.VALUE_SIZE : "addrHashOfSecretB incorrect"; dataByteBuffer.put(Bytes.ensureCapacity(hashOfSecretB, 32, 0)); - // Trade timeout in minutes - assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; - dataByteBuffer.putLong(tradeTimeout); - - // Refund timeout in minutes (¾ of trade-timeout) - assert dataByteBuffer.position() == addrRefundTimeout * MachineState.VALUE_SIZE : "addrRefundTimeout incorrect"; - dataByteBuffer.putLong(refundTimeout); - // Redeem Qort amount assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect"; dataByteBuffer.putLong(qortAmount); @@ -235,7 +232,7 @@ public class BTCACCT { assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; dataByteBuffer.putLong(addrCreatorAddress1); - // Index into data segment of hash, used by GET_B_IND + // Index into data segment of hash of secret B, used by GET_B_IND assert dataByteBuffer.position() == addrHashOfSecretBPointer * MachineState.VALUE_SIZE : "addrHashOfSecretBPointer incorrect"; dataByteBuffer.putLong(addrHashOfSecretB); @@ -251,7 +248,7 @@ public class BTCACCT { assert dataByteBuffer.position() == addrOfferMessageRecipientBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrOfferMessageRecipientBitcoinPKHOffset incorrect"; dataByteBuffer.putLong(32L); - // Index into data segment of hash, used by SET_B_IND + // Index into data segment of recipient's Bitcoin PKH, used by GET_B_IND assert dataByteBuffer.position() == addrRecipientBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrRecipientBitcoinPKHPointer incorrect"; dataByteBuffer.putLong(addrRecipientBitcoinPKH); @@ -259,7 +256,7 @@ public class BTCACCT { assert dataByteBuffer.position() == addrOfferMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrOfferMessageHashOfSecretAOffset incorrect"; dataByteBuffer.putLong(64L); - // Index into data segment of hash, used by GET_B_IND + // Index into data segment of hash of secret A, used by GET_B_IND assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; dataByteBuffer.putLong(addrHashOfSecretA); @@ -289,7 +286,7 @@ public class BTCACCT { Integer labelCheckSecretB = null; Integer labelPayout = null; - ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + ByteBuffer codeByteBuffer = ByteBuffer.allocate(768); // Two-pass version for (int pass = 0; pass < 2; ++pass) { @@ -301,9 +298,6 @@ public class BTCACCT { // Use AT creation 'timestamp' as starting point for finding transactions sent to AT codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); - // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to AT creation 'timestamp', then save into addrRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxTimestamp, addrRefundTimeout)); - // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrCreatorAddressPointer)); @@ -371,13 +365,28 @@ public class BTCACCT { labelOfferTxExtract = codeByteBuffer.position(); - // Message is expected length, extract recipient's Bitcoin PKH + // Message is expected length, grab next 32 bytes codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOfferMessageRecipientBitcoinPKHOffset)); - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrRecipientBitcoinPKHPointer)); - // Extract hash-of-secret-a + // Extract recipient's Bitcoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrRecipientBitcoinPKHPointer)); + // Also extract lockTimeB + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeB)); + + // Grab next 32 bytes codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOfferMessageHashOfSecretAOffset)); + + // Extract hash-of-secret-a (we only really use values from B1-B3) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); + // Extract lockTimeA (from B4) + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA)); + + // Calculate trade refund timeout: (lockTimeA - lockTimeB) / 2 / 60 + codeByteBuffer.put(OpCode.SET_DAT.compile(addrRefundTimeout, addrLockTimeA)); // refundTimeout = lockTimeA + codeByteBuffer.put(OpCode.SUB_DAT.compile(addrRefundTimeout, addrLockTimeB)); // refundTimeout -= lockTimeB + codeByteBuffer.put(OpCode.DIV_VAL.compile(addrRefundTimeout, 2L * 60L)); // refundTimeout /= 2 * 60 + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this tx 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxTimestamp, addrRefundTimeout)); /* We are in 'trade mode' */ codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, 1)); @@ -533,6 +542,8 @@ public class BTCACCT { ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); byte[] addressBytes = new byte[25]; + /* Constants */ + // Skip creator's trade address dataByteBuffer.get(addressBytes); tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes); @@ -548,12 +559,6 @@ public class BTCACCT { dataByteBuffer.get(tradeData.hashOfSecretB); dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.hashOfSecretB.length); // skip to 32 bytes - // Trade timeout - tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); - - // AT refund timeout (probably only useful for debugging) - tradeData.refundTimeout = (int) dataByteBuffer.getLong(); - // Redeem payout tradeData.qortAmount = dataByteBuffer.getLong(); @@ -602,7 +607,7 @@ public class BTCACCT { // Skip message data length dataByteBuffer.position(dataByteBuffer.position() + 8); - /* End of constants */ + /* End of constants / begin variables */ // Skip AT creator's address dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); @@ -612,10 +617,19 @@ public class BTCACCT { String qortalRecipient = Base58.encode(addressBytes); dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); + // Potential lockTimeA (if in trade mode) + int lockTimeA = (int) dataByteBuffer.getLong(); + + // Potential lockTimeB (if in trade mode) + int lockTimeB = (int) dataByteBuffer.getLong(); + + // AT refund timeout (probably only useful for debugging) + tradeData.refundTimeout = (int) dataByteBuffer.getLong(); + // Trade offer timeout (AT 'timestamp' converted to Qortal block height) long tradeRefundTimestamp = dataByteBuffer.getLong(); - // Last transaction timestamp + // Skip last transaction timestamp dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip block timestamp @@ -654,8 +668,8 @@ public class BTCACCT { tradeData.qortalRecipient = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; tradeData.recipientBitcoinPKH = recipientBitcoinPKH; - tradeData.lockTimeA = calcLockTimeA(tradeData.creationTimestamp, tradeData.tradeTimeout); - tradeData.lockTimeB = calcLockTimeB(tradeData.creationTimestamp, tradeData.tradeTimeout); + tradeData.lockTimeA = lockTimeA; + tradeData.lockTimeB = lockTimeB; } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; } @@ -663,14 +677,37 @@ public class BTCACCT { return tradeData; } + /** Returns trade-info MESSAGE payload for trade partner/recipient to send to AT creator's trade address. */ + public static byte[] buildOfferMessage(byte[] recipientBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + return Bytes.concat(recipientBitcoinPKH, hashOfSecretA, lockTimeABytes); + } + + /** Returns trade-info extracted from MESSAGE payload sent by trade partner/recipient, or null if not valid. */ + public static OfferMessageData extractOfferMessageData(byte[] messageData) { + if (messageData == null || messageData.length != 32 + 32 + 8) + return null; + + OfferMessageData offerMessageData = new OfferMessageData(); + offerMessageData.recipientBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20); + offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); + offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); + + return offerMessageData; + } + /** Returns trade-info MESSAGE payload for AT creator to send to AT. */ - public static byte[] buildOfferMessage(String recipientQortalAddress, byte[] recipientBitcoinPKH, byte[] hashOfSecretA) { + public static byte[] buildTradeMessage(String recipientQortalAddress, byte[] recipientBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) { byte[] data = new byte[32 + 32 + 32]; byte[] recipientQortalAddressBytes = Base58.decode(recipientQortalAddress); + byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); + byte[] lockTimeBBytes = BitTwiddling.toBEByteArray((long) lockTimeB); System.arraycopy(recipientQortalAddressBytes, 0, data, 0, recipientQortalAddressBytes.length); System.arraycopy(recipientBitcoinPKH, 0, data, 32, recipientBitcoinPKH.length); + System.arraycopy(lockTimeBBytes, 0, data, 56, lockTimeBBytes.length); System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); + System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); return data; } @@ -686,7 +723,7 @@ public class BTCACCT { } /** Returns redeem MESSAGE payload for trade partner/recipient to send to AT. */ - public static byte[] buildTradeMessage(byte[] secretA, byte[] secretB) { + public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB) { byte[] data = new byte[32 + 32]; System.arraycopy(secretA, 0, data, 0, secretA.length); @@ -695,19 +732,10 @@ public class BTCACCT { return data; } - /** Returns AT refundTimeout (minutes) based on tradeTimeout. */ - public static int calcRefundTimeout(int tradeTimeout) { - return tradeTimeout * 3 / 4; - } - - /** Returns P2SH-A lockTime (epoch seconds). */ - public static int calcLockTimeA(long atCreationTimestamp, int tradeTimeout) { - return (int) (atCreationTimestamp / 1000L + tradeTimeout * 60); - } - - /** Returns P2SH-B lockTime (epoch seconds). */ - public static int calcLockTimeB(long atCreationTimestamp, int tradeTimeout) { - return (int) (atCreationTimestamp / 1000L + tradeTimeout / 2 * 60); + /** Returns P2SH-B lockTime (epoch seconds) based on trade partner/recipient's MESSAGE timestamp and P2SH-A locktime. */ + public static int calcLockTimeB(long recipientMessageTimestamp, int lockTimeA) { + // lockTimeB is halfway between recipientMessageTimesamp and lockTimeA + return (int) ((lockTimeA + (recipientMessageTimestamp / 1000L)) / 2L); } } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 11207dd5..1c047c13 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -49,9 +49,6 @@ public class CrossChainTradeData { @Schema(description = "Timestamp when AT switched to trade mode") public Long tradeModeTimestamp; - @Schema(description = "General trade timeout (minutes) used to derive P2SH locktimes and AT refund timeout") - public int tradeTimeout; - @Schema(description = "How long from AT creation until AT triggers automatic refund to AT creator (minutes)") public int refundTimeout; diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 6d5c1fb8..c149ead0 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -38,7 +38,6 @@ public class TradeBotData { private State tradeState; private String atAddress; - private int tradeTimeout; private byte[] tradeNativePublicKey; private byte[] tradeNativePublicKeyHash; @@ -53,14 +52,13 @@ public class TradeBotData { private byte[] lastTransactionSignature; - public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, int tradeTimeout, + public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] secretHash, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, long bitcoinAmount, byte[] lastTransactionSignature) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; this.atAddress = atAddress; - this.tradeTimeout = tradeTimeout; this.tradeNativePublicKey = tradeNativePublicKey; this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; this.secret = secret; @@ -91,10 +89,6 @@ public class TradeBotData { this.atAddress = atAddress; } - public int getTradeTimeout() { - return this.tradeTimeout; - } - public byte[] getTradeNativePublicKey() { return this.tradeNativePublicKey; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index f6a302e3..2debbc67 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -19,7 +19,7 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public List getAllTradeBotData() throws DataException { - String sql = "SELECT trade_private_key, trade_state, at_address, trade_timeout, " + String sql = "SELECT trade_private_key, trade_state, at_address, " + "trade_native_public_key, trade_native_public_key_hash, " + "secret, secret_hash, " + "trade_foreign_public_key, trade_foreign_public_key_hash, " @@ -40,18 +40,17 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { throw new DataException("Illegal trade-bot trade-state fetched from repository"); String atAddress = resultSet.getString(3); - int tradeTimeout = resultSet.getInt(4); - byte[] tradeNativePublicKey = resultSet.getBytes(5); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(6); - byte[] secret = resultSet.getBytes(7); - byte[] secretHash = resultSet.getBytes(8); - byte[] tradeForeignPublicKey = resultSet.getBytes(9); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(10); - long bitcoinAmount = resultSet.getLong(11); - byte[] lastTransactionSignature = resultSet.getBytes(12); + byte[] tradeNativePublicKey = resultSet.getBytes(4); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(5); + byte[] secret = resultSet.getBytes(6); + byte[] secretHash = resultSet.getBytes(7); + byte[] tradeForeignPublicKey = resultSet.getBytes(8); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); + long bitcoinAmount = resultSet.getLong(10); + byte[] lastTransactionSignature = resultSet.getBytes(11); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, - atAddress, tradeTimeout, + atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, tradeForeignPublicKey, tradeForeignPublicKeyHash, bitcoinAmount, lastTransactionSignature); @@ -71,7 +70,6 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) .bind("trade_state", tradeBotData.getState().value) .bind("at_address", tradeBotData.getAtAddress()) - .bind("trade_timeout", tradeBotData.getTradeTimeout()) .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) .bind("secret", tradeBotData.getSecret()).bind("secret_hash", tradeBotData.getSecretHash()) diff --git a/src/main/java/org/qortal/utils/BitTwiddling.java b/src/main/java/org/qortal/utils/BitTwiddling.java index 4ba48bc8..eda5b4f6 100644 --- a/src/main/java/org/qortal/utils/BitTwiddling.java +++ b/src/main/java/org/qortal/utils/BitTwiddling.java @@ -26,6 +26,17 @@ 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 intFromLEBytes(byte[] bytes, int offset) { return (bytes[offset] & 0xff) | (bytes[offset + 1] & 0xff) << 8 | (bytes[offset + 2] & 0xff) << 16 | (bytes[offset + 3] & 0xff) << 24; diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 79a6edd1..5d5b5f2c 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -49,7 +49,7 @@ public class AtTests extends Common { public static final byte[] secretB = "This string is roughly 32 bytes?".getBytes(); public static final byte[] hashOfSecretB = Crypto.hash160(secretB); // 31f0dd71decf59bbc8ef0661f4030479255cfa58 public static final byte[] bitcoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes(); - public static final int tradeTimeout = 12; // blocks + public static final int tradeTimeout = 20; // blocks public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; @@ -63,7 +63,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, tradeTimeout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -176,8 +176,12 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + long recipientMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + // Send trade info to AT - byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); Block postDeploymentBlock = BlockUtils.mintBlock(repository); @@ -230,8 +234,12 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + long recipientMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); BlockUtils.mintBlock(repository); @@ -265,8 +273,12 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + long recipientMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + // Send trade info to AT - byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); Block postDeploymentBlock = BlockUtils.mintBlock(repository); @@ -308,15 +320,19 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + long recipientMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + // Send trade info to AT - byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); // Send correct secrets to AT, from correct account - messageData = BTCACCT.buildTradeMessage(secretA, secretB); + messageData = BTCACCT.buildRedeemMessage(secretA, secretB); messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should send funds in the next block @@ -366,15 +382,19 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + long recipientMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + // Send trade info to AT - byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); // Send correct secrets to AT, but from wrong account - messageData = BTCACCT.buildTradeMessage(secretA, secretB); + messageData = BTCACCT.buildRedeemMessage(secretA, secretB); messageTransaction = sendMessage(repository, bystander, messageData, atAddress); // AT should NOT send funds in the next block @@ -412,8 +432,12 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); + long recipientMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + // Send trade info to AT - byte[] messageData = BTCACCT.buildOfferMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA); + byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); // Give AT time to process message @@ -423,7 +447,7 @@ public class AtTests extends Common { byte[] wrongSecret = new byte[32]; Random random = new Random(); random.nextBytes(wrongSecret); - messageData = BTCACCT.buildTradeMessage(wrongSecret, secretB); + messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB); messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should NOT send funds in the next block @@ -442,7 +466,7 @@ public class AtTests extends Common { assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); // Send incorrect secrets to AT, from correct account - messageData = BTCACCT.buildTradeMessage(secretA, wrongSecret); + messageData = BTCACCT.buildRedeemMessage(secretA, wrongSecret); messageTransaction = sendMessage(repository, recipient, messageData, atAddress); // AT should NOT send funds in the next block @@ -497,8 +521,12 @@ public class AtTests extends Common { } } + private int calcTestLockTimeA(long messageTimestamp) { + return (int) (messageTimestamp / 1000L + tradeTimeout * 60); + } + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, tradeTimeout, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -557,7 +585,7 @@ public class AtTests extends Common { private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException { long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - int refundTimeout = BTCACCT.calcRefundTimeout(tradeTimeout); + int refundTimeout = tradeTimeout * 3 / 4 + 1; // close enough // AT should automatically refund deployer after 'refundTimeout' blocks for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount) @@ -588,7 +616,6 @@ public class AtTests extends Common { + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" - + "\ttrade timeout: %d minutes (from AT creation),\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, tradeData.qortalCreator, @@ -598,7 +625,6 @@ public class AtTests extends Common { HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), - tradeData.tradeTimeout, currentBlockHeight)); // Are we in 'offer' or 'trade' stage? diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 0aa0b762..74233e25 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -34,20 +34,19 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("usage: DeployAT ")); System.err.println(String.format("example: DeployAT " + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" + "\t80.4020 \\\n" + "\t0.00864200 \\\n" + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" - + "\t123.456 \\\n" - + "\t10")); + + "\t123.456")); System.exit(1); } public static void main(String[] args) { - if (args.length != 7) + if (args.length != 6) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); @@ -59,7 +58,6 @@ public class DeployAT { byte[] bitcoinPublicKeyHash = null; byte[] secretHash = null; long fundingAmount = 0; - int tradeTimeout = 0; int argIndex = 0; try { @@ -86,10 +84,6 @@ public class DeployAT { fundingAmount = Long.parseLong(args[argIndex++]); if (fundingAmount <= redeemAmount) usage("AT funding amount must be greater than QORT redeem amount"); - - tradeTimeout = Integer.parseInt(args[argIndex++]); - if (tradeTimeout < 10 || tradeTimeout > 50000) - usage("AT trade timeout should be between 10 and 50,000 minutes"); } catch (IllegalArgumentException e) { usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); } @@ -114,7 +108,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, tradeTimeout, redeemAmount, expectedBitcoin); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); From 11bf5ac6fc4599fdd418912fdcccda274a3a0b16 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 19 Jun 2020 17:30:21 +0100 Subject: [PATCH 12/51] WIP: remove trade_timeout from DB TradeBotStates & TradeBotCreateRequest --- .../java/org/qortal/api/model/TradeBotCreateRequest.java | 9 +++------ .../qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java index 8fb3a99a..609f1735 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -9,10 +9,10 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class TradeBotCreateRequest { - @Schema(description = "Trade creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + @Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB") public byte[] creatorPublicKey; - @Schema(description = "QORT amount paid out on successful trade", example = "80.40200000") + @Schema(description = "QORT amount paid out on successful trade", example = "8040200000") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long qortAmount; @@ -20,13 +20,10 @@ public class TradeBotCreateRequest { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fundingQortAmount; - @Schema(description = "Bitcoin amount wanted in return", example = "0.00864200") + @Schema(description = "Bitcoin amount wanted in return", example = "000864200") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long bitcoinAmount; - @Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080") - public Integer tradeTimeout; - public TradeBotCreateRequest() { } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 54a816ab..f1f13e3f 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -621,7 +621,7 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " - + "at_address QortalAddress, trade_timeout INT NOT NULL, " + + "at_address QortalAddress, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + "secret VARBINARY(32) NOT NULL, secret_hash VARBINARY(32) NOT NULL, " + "trade_foreign_public_key QortalPublicKey NOT NULL, trade_foreign_public_key_hash VARBINARY(32) NOT NULL, " From ee5119e4dd9a1f1cc1ea5b55956ebe3938659b4a Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 23 Jun 2020 16:53:08 +0100 Subject: [PATCH 13/51] WIP: trade-bot. move trade-bot hook, fix bugs, etc. Controller now calls TradeBot.onChainTipChange() inside thread started by Controller.onNewBlock(), instead of blocking Controller.setChainTip(). DB TradeBotStates has trade_foreign_public_key changed to VARBINARY(33) as Bitcoin pubkeys aren't uniformly 32 bytes! Also, trade_state changed from TINYINT to SMALLINT to cover enum value range. TradeBot.createTrade() incorrectly used Crypto.digest() to create hash-of-secret instead of Crypto.hash160(). Also corrected tradeState to BOB_WAITING_FOR_AT_CONFIRM. Also added missing fee calculation. Added missing repository.saveChanges() to TradeBot methods. Added balance check to API POST /crosschain/tradebot before passing request to TradeBot.createTrade(), which also ensures there's a usable account last-reference too. --- .../org/qortal/api/model/TradeBotCreateRequest.java | 4 ++-- .../org/qortal/api/resource/CrossChainResource.java | 6 ++++++ src/main/java/org/qortal/controller/Controller.java | 7 ++++--- src/main/java/org/qortal/controller/TradeBot.java | 13 +++++++++++-- .../repository/hsqldb/HSQLDBDatabaseUpdates.java | 4 ++-- 5 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java index 609f1735..c1db35e7 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -12,7 +12,7 @@ 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 = "8040200000") + @Schema(description = "QORT amount paid out on successful trade", example = "80.40200000") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long qortAmount; @@ -20,7 +20,7 @@ public class TradeBotCreateRequest { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long fundingQortAmount; - @Schema(description = "Bitcoin amount wanted in return", example = "000864200") + @Schema(description = "Bitcoin amount wanted in return", example = "0.00864200") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long bitcoinAmount; diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 41089e91..477a3ef9 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -32,6 +32,7 @@ import org.bitcoinj.core.ECKey; import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.TransactionOutput; +import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; @@ -866,6 +867,11 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { try (final Repository repository = RepositoryManager.getRepository()) { + // Do some simple checking first + Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); + if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount) + throw TransactionsResource.createTransactionInvalidException(request, ValidationResult.NO_BALANCE); + byte[] unsignedBytes = TradeBot.createTrade(repository, tradeBotCreateRequest); return Base58.encode(unsignedBytes); diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 2f1e4175..948b46cc 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -238,11 +238,9 @@ public class Controller extends Thread { return this.chainTip; } - /** Cache new blockchain tip, maybe call trade-bot. */ + /** Cache new blockchain tip. */ public void setChainTip(BlockData blockData) { this.chainTip = blockData; - - TradeBot.getInstance().onChainTipChange(); } public ReentrantLock getBlockchainLock() { @@ -793,6 +791,9 @@ public class Controller extends Thread { this.notifyGroupMembershipChange = false; ChatNotifier.getInstance().onGroupMembershipChange(); } + + // Trade-bot might want to perform some actions too + TradeBot.getInstance().onChainTipChange(); }); } diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 806805dc..8c8369e4 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -57,7 +57,7 @@ public class TradeBot { public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretB = generateSecret(); - byte[] hashOfSecretB = Crypto.digest(secretB); + byte[] hashOfSecretB = Crypto.hash160(secretB); byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); @@ -83,15 +83,21 @@ public class TradeBot { long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + DeployAtTransaction.ensureATAddress(deployAtTransactionData); String atAddress = deployAtTransactionData.getAtAddress(); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, tradeBotCreateRequest.bitcoinAmount, null); repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); // Return to user for signing and broadcast as we don't have their Qortal private key try { @@ -121,6 +127,7 @@ public class TradeBot { tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.expectedBitcoin, null); repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); // P2SH_a to be funded byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, secretHash); @@ -179,6 +186,7 @@ public class TradeBot { tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); } private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { @@ -252,6 +260,7 @@ public class TradeBot { } repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); } } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index f1f13e3f..346a1daa 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -620,11 +620,11 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot - stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state SMALLINT NOT NULL, " + "at_address QortalAddress, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + "secret VARBINARY(32) NOT NULL, secret_hash VARBINARY(32) NOT NULL, " - + "trade_foreign_public_key QortalPublicKey NOT NULL, trade_foreign_public_key_hash 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, last_transaction_signature Signature, PRIMARY KEY (trade_private_key))"); break; From f17913996770f7f04636cdff7bc328123a5f0abb Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 24 Jun 2020 17:16:04 +0100 Subject: [PATCH 14/51] WIP: trade-bot: Alice P2SH_a progress Qortal AT now includes suggested tradeTimeout again as a constant so trade partner/recipient can use that to calculate a suitable lockTimeA. CODE_HASH changed! Renamed some secret_hash to hash_of_secret. Changed TradeBotStates.trade_state back to TINYINT and adjusted values in TradeBotData.State enum to suit. Added lockTimeA to TradeBotData & repository. Added JAXB-only extra representations of Bitcoin PKHs as addresses. Fixed incorrect expected length in BTCACCT.extractOfferMessageData(). CrossChainTradeData.refundTimeout now only present in TRADE mode. Added BTC.pkhToAddress(). Added initial TradeBot.handleAliceWaitingForP2shA(). Enforce only one TradeBot thread running using 'activeFlag' atomic boolean. Replace incorrect SHA256 with HASH160 for hashOfSecretA in TradeBot.startResponse(). --- .../api/model/TradeBotCreateRequest.java | 3 + .../api/resource/CrossChainResource.java | 5 +- .../java/org/qortal/controller/TradeBot.java | 103 ++++++++++++++---- src/main/java/org/qortal/crosschain/BTC.java | 4 + .../java/org/qortal/crosschain/BTCACCT.java | 17 ++- .../data/crosschain/CrossChainTradeData.java | 26 +++++ .../qortal/data/crosschain/TradeBotData.java | 25 +++-- .../hsqldb/HSQLDBCrossChainRepository.java | 16 ++- .../hsqldb/HSQLDBDatabaseUpdates.java | 7 +- .../java/org/qortal/test/btcacct/AtTests.java | 4 +- .../org/qortal/test/btcacct/DeployAT.java | 14 ++- 11 files changed, 174 insertions(+), 50 deletions(-) diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java index c1db35e7..1898a989 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -24,6 +24,9 @@ public class TradeBotCreateRequest { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) public long bitcoinAmount; + @Schema(description = "Suggested trade timeout (minutes)", example = "10080") + public int tradeTimeout; + public TradeBotCreateRequest() { } diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 477a3ef9..92cf4096 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -183,7 +183,7 @@ public class CrossChainResource { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB, - tradeRequest.qortAmount, tradeRequest.bitcoinAmount); + tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); @@ -866,6 +866,9 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { + if (tradeBotCreateRequest.tradeTimeout < 600) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + try (final Repository repository = RepositoryManager.getRepository()) { // Do some simple checking first Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 8c8369e4..38c85c31 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -4,13 +4,11 @@ import java.security.SecureRandom; import java.util.Arrays; import java.util.List; import java.util.Random; +import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.bitcoinj.core.Address; import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.LegacyAddress; -import org.bitcoinj.core.NetworkParameters; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.TradeBotCreateRequest; @@ -43,8 +41,10 @@ public class TradeBot { private static TradeBot instance; + /** To help ensure only TradeBot is only active on one thread. */ + private AtomicBoolean activeFlag = new AtomicBoolean(false); + private TradeBot() { - } public static synchronized TradeBot getInstance() { @@ -79,7 +79,7 @@ public class TradeBot { String description = "QORT/BTC cross-chain trade"; String aTType = "ACCT"; String tags = "ACCT QORT BTC"; - byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -95,7 +95,7 @@ public class TradeBot { atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null); + tradeBotCreateRequest.bitcoinAmount, null, null); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -108,12 +108,9 @@ public class TradeBot { } public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { - BTC btc = BTC.getInstance(); - NetworkParameters params = btc.getNetworkParameters(); - byte[] tradePrivateKey = generateTradePrivateKey(); - byte[] secret = generateSecret(); - byte[] secretHash = Crypto.digest(secret); + byte[] secretA = generateSecret(); + byte[] hashOfSecretA = Crypto.hash160(secretA); byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); @@ -121,20 +118,20 @@ public class TradeBot { byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + // We need to generate lockTimeA: halfway of refundTimeout from now + int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, crossChainTradeData.qortalAtAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + tradeNativePublicKey, tradeNativePublicKeyHash, secretA, hashOfSecretA, tradeForeignPublicKey, tradeForeignPublicKeyHash, - crossChainTradeData.expectedBitcoin, null); + crossChainTradeData.expectedBitcoin, null, lockTimeA); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); // P2SH_a to be funded - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, secretHash); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - return p2shAddress.toString(); + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); + return BTC.getInstance().deriveP2shAddress(redeemScriptBytes); } private static byte[] generateTradePrivateKey() { @@ -158,11 +155,17 @@ public class TradeBot { } public void onChainTipChange() { + if (!activeFlag.compareAndSet(false, true)) + // Trade bot already active on another thread + return; + // Get repo for trade situations try (final Repository repository = RepositoryManager.getRepository()) { List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); - for (TradeBotData tradeBotData : allTradeBotData) + for (TradeBotData tradeBotData : allTradeBotData) { + repository.discardChanges(); + switch (tradeBotData.getState()) { case BOB_WAITING_FOR_AT_CONFIRM: handleBobWaitingForAtConfirm(repository, tradeBotData); @@ -172,11 +175,18 @@ public class TradeBot { handleBobWaitingForMessage(repository, tradeBotData); break; + case ALICE_WAITING_FOR_P2SH_A: + handleAliceWaitingForP2shA(repository, tradeBotData); + break; + default: LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name())); } + } } catch (DataException e) { LOGGER.error("Couldn't run trade bot due to repository issue", e); + } finally { + activeFlag.set(false); } } @@ -200,10 +210,12 @@ public class TradeBot { String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); + final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature(); + // Skip past previously processed messages - if (tradeBotData.getLastTransactionSignature() != null) + if (originalLastTransactionSignature != null) for (int i = 0; i < messageTransactionsData.size(); ++i) - if (Arrays.equals(messageTransactionsData.get(i).getSignature(), tradeBotData.getLastTransactionSignature())) { + if (Arrays.equals(messageTransactionsData.get(i).getSignature(), originalLastTransactionSignature)) { messageTransactionsData.subList(0, i + 1).clear(); break; } @@ -248,17 +260,62 @@ public class TradeBot { outgoingMessageTransaction.computeNonce(); outgoingMessageTransaction.sign(sender); + // reset repository state to prevent deadlock + repository.discardChanges(); ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", tradeBotData.getAtAddress(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", outgoingMessageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); - break; + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + return; } + // Don't resave if we don't need to + if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) { + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + } + + private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < crossChainTradeData.expectedBitcoin) + return; + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", messageTransaction.getRecipient(), result.name())); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 65af8781..6bf00073 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -98,6 +98,10 @@ public class BTC { return format(Coin.valueOf(amount)); } + 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); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 172d24f9..ad185d87 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -88,7 +88,7 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("ca0dc643fdaba4d12cd5550800a8353746f40a0d9824d8c10d8b4bd0324eac0d").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("14ee2cb9899f582037901c384bab9ccdd41e48d8c98bf7df5cf79f4e8c236286").asBytes(); // SHA256 of AT code bytes public static class OfferMessageData { public byte[] recipientBitcoinPKH; @@ -110,9 +110,10 @@ public class BTCACCT { * @param hashOfSecretB 20-byte HASH160 of 32-byte secret-B * @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT * @param bitcoinAmount how much BTC the AT creator is expecting to trade + * @param tradeTimeout suggested timeout for entire trade * @return */ - public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount) { + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) { // Labels for data segment addresses int addrCounter = 0; @@ -131,6 +132,7 @@ public class BTCACCT { final int addrQortAmount = addrCounter++; final int addrBitcoinAmount = addrCounter++; + final int addrTradeTimeout = addrCounter++; final int addrMessageTxType = addrCounter++; final int addrExpectedOfferMessageLength = addrCounter++; @@ -216,6 +218,10 @@ public class BTCACCT { assert dataByteBuffer.position() == addrBitcoinAmount * MachineState.VALUE_SIZE : "addrBitcoinAmount incorrect"; dataByteBuffer.putLong(bitcoinAmount); + // Suggested trade timeout (minutes) + assert dataByteBuffer.position() == addrTradeTimeout * MachineState.VALUE_SIZE : "addrTradeTimeout incorrect"; + dataByteBuffer.putLong(tradeTimeout); + // We're only interested in MESSAGE transactions assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect"; dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); @@ -565,6 +571,8 @@ public class BTCACCT { // Expected BTC amount tradeData.expectedBitcoin = dataByteBuffer.getLong(); + tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); + // Skip MESSAGE transaction type dataByteBuffer.position(dataByteBuffer.position() + 8); @@ -624,7 +632,7 @@ public class BTCACCT { int lockTimeB = (int) dataByteBuffer.getLong(); // AT refund timeout (probably only useful for debugging) - tradeData.refundTimeout = (int) dataByteBuffer.getLong(); + int refundTimeout = (int) dataByteBuffer.getLong(); // Trade offer timeout (AT 'timestamp' converted to Qortal block height) long tradeRefundTimestamp = dataByteBuffer.getLong(); @@ -664,6 +672,7 @@ public class BTCACCT { if (mode != 0) { tradeData.mode = CrossChainTradeData.Mode.TRADE; + tradeData.refundTimeout = refundTimeout; tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; tradeData.qortalRecipient = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; @@ -685,7 +694,7 @@ public class BTCACCT { /** Returns trade-info extracted from MESSAGE payload sent by trade partner/recipient, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { - if (messageData == null || messageData.length != 32 + 32 + 8) + if (messageData == null || messageData.length != 20 + 20 + 8) return null; OfferMessageData offerMessageData = new OfferMessageData(); diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 1c047c13..99a7f5e5 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -2,8 +2,11 @@ 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 io.swagger.v3.oas.annotations.media.Schema; // All properties to be converted to JSON via JAXB @@ -29,6 +32,9 @@ public class CrossChainTradeData { @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; @@ -76,4 +82,24 @@ public class CrossChainTradeData { public CrossChainTradeData() { } + // We can represent BitcoinPKH as an address + @XmlElement(name = "creatorBitcoinAddress") + @Schema(description = "AT creator's 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 Bitcoin PKH in address form") + public String getRecipientBitcoinAddress() { + if (this.recipientBitcoinPKH == null) + return null; + + return BTC.getInstance().pkhToAddress(this.recipientBitcoinPKH); + } + } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index c149ead0..4441212c 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -22,7 +22,7 @@ public class TradeBotData { public enum State { BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_SENDING_MESSAGE_TO_AT(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), - ALICE_WAITING_FOR_P2SH_A(110), ALICE_WAITING_FOR_AT_LOCK(120), ALICE_WATCH_P2SH_B(130); + ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(90), ALICE_WATCH_P2SH_B(100); public final int value; private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); @@ -43,7 +43,7 @@ public class TradeBotData { private byte[] tradeNativePublicKeyHash; private byte[] secret; - private byte[] secretHash; + private byte[] hashOfSecret; private byte[] tradeForeignPublicKey; private byte[] tradeForeignPublicKeyHash; @@ -52,21 +52,24 @@ public class TradeBotData { private byte[] lastTransactionSignature; + private Integer lockTimeA; + public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, - byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] secretHash, + byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] hashOfSecret, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, byte[] lastTransactionSignature) { + long bitcoinAmount, byte[] lastTransactionSignature, Integer lockTimeA) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; this.atAddress = atAddress; this.tradeNativePublicKey = tradeNativePublicKey; this.tradeNativePublicKeyHash = tradeNativePublicKeyHash; this.secret = secret; - this.secretHash = secretHash; + this.hashOfSecret = hashOfSecret; this.tradeForeignPublicKey = tradeForeignPublicKey; this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; this.bitcoinAmount = bitcoinAmount; this.lastTransactionSignature = lastTransactionSignature; + this.lockTimeA = lockTimeA; } public byte[] getTradePrivateKey() { @@ -101,8 +104,8 @@ public class TradeBotData { return this.secret; } - public byte[] getSecretHash() { - return this.secretHash; + public byte[] getHashOfSecret() { + return this.hashOfSecret; } public byte[] getTradeForeignPublicKey() { @@ -125,4 +128,12 @@ public class TradeBotData { this.lastTransactionSignature = lastTransactionSignature; } + public Integer getLockTimeA() { + return this.lockTimeA; + } + + public void setLockTimeA(Integer lockTimeA) { + this.lockTimeA = lockTimeA; + } + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 2debbc67..392f42b1 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -21,9 +21,9 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { public List getAllTradeBotData() throws DataException { String sql = "SELECT trade_private_key, trade_state, at_address, " + "trade_native_public_key, trade_native_public_key_hash, " - + "secret, secret_hash, " + + "secret, hash_of_secret, " + "trade_foreign_public_key, trade_foreign_public_key_hash, " - + "bitcoin_amount, last_transaction_signature " + + "bitcoin_amount, last_transaction_signature, locktime_a " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -43,17 +43,20 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { byte[] tradeNativePublicKey = resultSet.getBytes(4); byte[] tradeNativePublicKeyHash = resultSet.getBytes(5); byte[] secret = resultSet.getBytes(6); - byte[] secretHash = resultSet.getBytes(7); + byte[] hashOfSecret = resultSet.getBytes(7); byte[] tradeForeignPublicKey = resultSet.getBytes(8); byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); long bitcoinAmount = resultSet.getLong(10); byte[] lastTransactionSignature = resultSet.getBytes(11); + Integer lockTimeA = resultSet.getInt(12); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, atAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secret, secretHash, + tradeNativePublicKey, tradeNativePublicKeyHash, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, lastTransactionSignature); + bitcoinAmount, lastTransactionSignature, lockTimeA); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -70,9 +73,10 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) .bind("trade_state", tradeBotData.getState().value) .bind("at_address", tradeBotData.getAtAddress()) + .bind("locktime_a", tradeBotData.getLockTimeA()) .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) - .bind("secret", tradeBotData.getSecret()).bind("secret_hash", tradeBotData.getSecretHash()) + .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()) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 346a1daa..df08efcb 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -620,12 +620,13 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot - stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state SMALLINT NOT NULL, " + stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + "at_address QortalAddress, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " - + "secret VARBINARY(32) NOT NULL, secret_hash VARBINARY(32) 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, last_transaction_signature Signature, PRIMARY KEY (trade_private_key))"); + + "bitcoin_amount BIGINT NOT NULL, last_transaction_signature Signature, locktime_a BIGINT, " + + "PRIMARY KEY (trade_private_key))"); break; default: diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 5d5b5f2c..3f0fe919 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -63,7 +63,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -526,7 +526,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 74233e25..56e75150 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -34,19 +34,20 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("usage: DeployAT 50000) + usage("Trade timeout (minutes) must be between 60 and 50000"); } catch (IllegalArgumentException e) { usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); } @@ -108,7 +114,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); From e729571a2133c12c6fed55fa13f77c9e0d3f524a Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 24 Jun 2020 17:31:30 +0100 Subject: [PATCH 15/51] WIP: trade-bot: do not run trade-bot if not up-to-date --- src/main/java/org/qortal/controller/TradeBot.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 38c85c31..18ada4b8 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -155,6 +155,10 @@ public class TradeBot { } public void onChainTipChange() { + // No point doing anything on old/stale data + if (!Controller.getInstance().isUpToDate()) + return; + if (!activeFlag.compareAndSet(false, true)) // Trade bot already active on another thread return; From 579645d6b773f5682a9c73f8ac86b0825da8e0d5 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 8 Jul 2020 16:35:02 +0100 Subject: [PATCH 16/51] WIP: trade-bot now does complete end-to-end trade (more work needed) bitcoinj now uses ElectrumX as an UTXO provider in order to keep track of coins in BIP32 deterministic wallet. Trade responder (Alice) needs to pass a BIP32 extended private key to API so trade-bot can create unattended spends. Both Alice and Bob can find their final funds in accounts using the ephemeral 'tradePrivateKey' from trade-bot state data. Most cross-chain API calls are now only allowed from localhost. Most Bitcoin fees pegged at 0.00001000 BTC. More work needed to handle refunds in case of trade failures. (See XXX comment tags in TradeBot.java) --- .../api/model/CrossChainSecretRequest.java | 2 +- .../api/model/TradeBotRespondRequest.java | 20 ++ .../api/resource/CrossChainResource.java | 155 ++++++++- .../java/org/qortal/controller/TradeBot.java | 309 ++++++++++++++++-- src/main/java/org/qortal/crosschain/BTC.java | 174 +++++++++- .../java/org/qortal/crosschain/BTCACCT.java | 47 +++ .../java/org/qortal/crosschain/ElectrumX.java | 24 +- .../qortal/data/crosschain/TradeBotData.java | 32 +- .../repository/CrossChainRepository.java | 5 + .../hsqldb/HSQLDBCrossChainRepository.java | 87 ++++- .../hsqldb/HSQLDBDatabaseUpdates.java | 4 +- .../org/qortal/test/btcacct/BtcTests.java | 11 + .../qortal/test/btcacct/ElectrumXTests.java | 8 +- 13 files changed, 802 insertions(+), 76 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/TradeBotRespondRequest.java diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index 99820022..f0b5d0d1 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -14,7 +14,7 @@ public class CrossChainSecretRequest { @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "32-byte secret", example = "6gVbAXCVzJXAWwtAVGAfgAkkXpeXvPUwSciPmCfSfXJG") + @Schema(description = "secret-A + secret-B (64 bytes)", example = "2gt2nSVBFknLfdU5buKtScLuTibkt9C3x6PZVqnA3AJ6BdEf3A9RbSj5Hn5QkvavdTTfmttNEaYEVw34TZdz135Q") public byte[] secret; public CrossChainSecretRequest() { diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java new file mode 100644 index 00000000..2c319fd9 --- /dev/null +++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java @@ -0,0 +1,20 @@ +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; + + public TradeBotRespondRequest() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 92cf4096..d1cbcd8f 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -21,7 +21,6 @@ import javax.ws.rs.DELETE; import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; -import javax.ws.rs.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; @@ -36,12 +35,13 @@ import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; -import org.qortal.api.ApiException; import org.qortal.api.ApiExceptionFactory; +import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; import org.qortal.api.model.CrossChainSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.api.model.TradeBotRespondRequest; import org.qortal.api.model.CrossChainBitcoinP2SHStatus; import org.qortal.api.model.CrossChainBitcoinRedeemRequest; import org.qortal.api.model.CrossChainBitcoinRefundRequest; @@ -55,6 +55,7 @@ import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.crosschain.CrossChainTradeData.Mode; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; @@ -123,8 +124,6 @@ public class CrossChainResource { } return crossChainTradesData; - } catch (ApiException e) { - throw e; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } @@ -152,6 +151,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_DATA, ApiError.INVALID_REFERENCE, ApiError.TRANSFORMATION_ERROR, ApiError.REPOSITORY_ISSUE}) public String buildTrade(CrossChainBuildRequest tradeRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -245,6 +246,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = tradeRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -277,8 +280,8 @@ public class CrossChainResource { @POST @Path("/tradeoffer/secret") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that sends secret to AT, releasing funds to recipient", - description = "Specify address of cross-chain AT that needs to be messaged, and 32-byte secret.
" + summary = "Builds raw, unsigned MESSAGE transaction that sends secrets to AT, releasing funds to recipient", + description = "Specify address of cross-chain AT that needs to be messaged, and both 32-byte secrets.
" + "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" + "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( @@ -302,6 +305,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String sendSecret(CrossChainSecretRequest secretRequest) { + Security.checkApiCallAllowed(request); + byte[] recipientPublicKey = secretRequest.recipientPublicKey; if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -310,7 +315,7 @@ public class CrossChainResource { if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH) + if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH * 2) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); try (final Repository repository = RepositoryManager.getRepository()) { @@ -365,6 +370,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { + Security.checkApiCallAllowed(request); + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) @@ -415,6 +422,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -439,6 +448,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -494,6 +505,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -518,6 +531,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { + Security.checkApiCallAllowed(request); + return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -607,6 +622,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) { + Security.checkApiCallAllowed(request); + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -632,6 +649,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) { + Security.checkApiCallAllowed(request); + return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -716,6 +735,7 @@ public class CrossChainResource { @Path("/p2sh/a/redeem") @Operation( summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-A address", + description = "Secret payload needs to be secret-A (64 bytes)", requestBody = @RequestBody( required = true, content = @Content( @@ -734,6 +754,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) { + Security.checkApiCallAllowed(request); + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); } @@ -741,6 +763,7 @@ public class CrossChainResource { @Path("/p2sh/b/redeem") @Operation( summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-B address", + description = "Secret payload needs to be secret-B (32 bytes)", requestBody = @RequestBody( required = true, content = @Content( @@ -759,6 +782,8 @@ public class CrossChainResource { @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) { + Security.checkApiCallAllowed(request); + return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); } @@ -845,8 +870,35 @@ public class CrossChainResource { } } - @POST + @GET @Path("/tradebot") + @Operation( + summary = "List current trade-bot states", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = TradeBotData.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.REPOSITORY_ISSUE}) + public List getTradeBotStates() { + Security.checkApiCallAllowed(request); + + try (final Repository repository = RepositoryManager.getRepository()) { + return repository.getCrossChainRepository().getAllTradeBotData(); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @POST + @Path("/tradebot/create") @Operation( summary = "Create a trade offer", requestBody = @RequestBody( @@ -866,6 +918,8 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { + Security.checkApiCallAllowed(request); + if (tradeBotCreateRequest.tradeTimeout < 600) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -884,9 +938,19 @@ public class CrossChainResource { } @POST - @Path("/tradebot/{ataddress}") + @Path("/tradebot/respond") @Operation( - summary = "Respond to a trade offer", + summary = "Respond to a trade offer (WILL SPEND BITCOIN!)", + description = "Start a new trade-bot entry to respond to chosen trade offer. Trade-bot starts by funding Bitcoin side of trade!", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = TradeBotRespondRequest.class + ) + ) + ), responses = { @ApiResponse( content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) @@ -894,10 +958,24 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - public String tradeBotResponder(@PathParam("ataddress") String atAddress) { + public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { + Security.checkApiCallAllowed(request); + + final String atAddress = tradeBotRespondRequest.atAddress; + if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + final byte[] xprv; + try { + xprv = Base58.decode(tradeBotRespondRequest.xprv58); + + if (xprv.length != 4 + 1 + 4 + 4 + 32 + 33 + 4) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } + // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, null, atAddress); // null to skip creator check @@ -906,11 +984,58 @@ public class CrossChainResource { if (crossChainTradeData.mode != Mode.OFFER) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - String p2shAddress = TradeBot.startResponse(repository, crossChainTradeData); - if (p2shAddress == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + boolean result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58); - return p2shAddress; + return result ? "true" : "false"; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Path("/tradebot/trade") + @Operation( + summary = "Delete completed trade", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + example = "Au6kioR6XT2CPxT6qsyQ1WjS9zNYg7tpwSrFeVqCDdMR" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + public String tradeBotDelete(String tradePrivateKey58) { + Security.checkApiCallAllowed(request); + + final byte[] tradePrivateKey; + try { + tradePrivateKey = Base58.decode(tradePrivateKey58); + + if (tradePrivateKey.length != 32) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } catch (NumberFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + } + + try (final Repository repository = RepositoryManager.getRepository()) { + TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + + if (tradeBotData.getState() != TradeBotData.State.ALICE_DONE && tradeBotData.getState() != TradeBotData.State.BOB_DONE) + return "false"; + + repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); + repository.saveChanges(); + + return "true"; } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 18ada4b8..61dbb39c 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -8,7 +8,10 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionOutput; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.TradeBotCreateRequest; @@ -38,7 +41,8 @@ public class TradeBot { private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); private static final Random RANDOM = new SecureRandom(); - + private static final long FEE_AMOUNT = 1000L; + private static TradeBot instance; /** To help ensure only TradeBot is only active on one thread. */ @@ -61,7 +65,7 @@ public class TradeBot { byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); - String tradeAddress = Crypto.toAddress(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); @@ -79,7 +83,7 @@ public class TradeBot { String description = "QORT/BTC cross-chain trade"; String aTType = "ACCT"; String tags = "ACCT QORT BTC"; - byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -93,9 +97,10 @@ public class TradeBot { TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, atAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secretB, hashOfSecretB, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null, null); + tradeBotCreateRequest.bitcoinAmount, null, null, null); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -107,13 +112,14 @@ public class TradeBot { } } - public static String startResponse(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretA = generateSecret(); byte[] hashOfSecretA = Crypto.hash160(secretA); byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); + String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); @@ -123,15 +129,36 @@ public class TradeBot { TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, crossChainTradeData.qortalAtAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secretA, hashOfSecretA, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secretA, hashOfSecretA, tradeForeignPublicKey, tradeForeignPublicKeyHash, - crossChainTradeData.expectedBitcoin, null, lockTimeA); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA); + + // Check we have enough funds via xprv58 to fund both P2SH to cover expectedBitcoin + String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); + + long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-a */ + FEE_AMOUNT /* P2SH-b */; + + Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); + if (fundingCheckTransaction == null) + return false; // P2SH_a to be funded byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); - return BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + // Fund P2SH-a + Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT); + if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { + // We couldn't fund P2SH-a at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-a funding transaction?")); + return false; + } + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + return true; } private static byte[] generateTradePrivateKey() { @@ -175,12 +202,32 @@ public class TradeBot { handleBobWaitingForAtConfirm(repository, tradeBotData); break; + case ALICE_WAITING_FOR_P2SH_A: + handleAliceWaitingForP2shA(repository, tradeBotData); + break; + case BOB_WAITING_FOR_MESSAGE: handleBobWaitingForMessage(repository, tradeBotData); break; - case ALICE_WAITING_FOR_P2SH_A: - handleAliceWaitingForP2shA(repository, tradeBotData); + case ALICE_WAITING_FOR_AT_LOCK: + handleAliceWaitingForAtLock(repository, tradeBotData); + break; + + case BOB_WAITING_FOR_P2SH_B: + handleBobWaitingForP2shB(repository, tradeBotData); + break; + + case ALICE_WATCH_P2SH_B: + handleAliceWatchingP2shB(repository, tradeBotData); + break; + + case BOB_WAITING_FOR_AT_REDEEM: + handleBobWaitingForAtRedeem(repository, tradeBotData); + break; + + case ALICE_DONE: + case BOB_DONE: break; default: @@ -203,6 +250,48 @@ public class TradeBot { repository.saveChanges(); } + private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < crossChainTradeData.expectedBitcoin) { + if (balance != null && balance > 0) + LOGGER.debug(() -> String.format("P2SH-a balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + + return; + } + + // Attempt to send MESSAGE to Bob's Qortal trade address + byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + + PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + + messageTransaction.computeNonce(); + messageTransaction.sign(sender); + + // reset repository state to prevent deadlock + repository.discardChanges(); + ValidationResult result = messageTransaction.importAsUnconfirmed(); + + if (result != ValidationResult.OK) { + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", messageTransaction.getRecipient(), result.name())); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { // Fetch AT so we can determine trade start timestamp ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); @@ -211,7 +300,7 @@ public class TradeBot { return; } - String address = Crypto.toAddress(tradeBotData.getTradeNativePublicKey()); + String address = tradeBotData.getTradeNativeAddress(); List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(address, null, null, null); final byte[] originalLastTransactionSignature = tradeBotData.getLastTransactionSignature(); @@ -231,8 +320,6 @@ public class TradeBot { if (messageTransactionData.isText()) continue; - // Could enforce encryption here - // We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash byte[] messageData = messageTransactionData.getData(); BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); @@ -286,7 +373,9 @@ public class TradeBot { } } - private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException { + // XXX REFUND CHECK + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); @@ -294,18 +383,149 @@ public class TradeBot { } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + // We're waiting for AT to be in TRADE mode + if (crossChainTradeData.mode != CrossChainTradeData.Mode.TRADE) + return; + + // We're expecting AT to be locked to our native trade address + if (!crossChainTradeData.qortalRecipient.equals(tradeBotData.getTradeNativeAddress())) { + // AT locked to different address! We shouldn't continue but wait and refund. + LOGGER.warn(() -> String.format("Trade AT '%s' locked to '%s', not us ('%s')", + tradeBotData.getAtAddress(), + crossChainTradeData.qortalRecipient, + tradeBotData.getTradeNativeAddress())); + + // There's no P2SH-b at this point, so jump straight to refunding P2SH-a + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + return; + } + + // Alice needs to fund P2SH-b here + + // Find our MESSAGE to AT from previous state + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(crossChainTradeData.qortalCreatorTradeAddress, null, null, null); + if (messageTransactionsData == null) { + LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT '%s' from repository", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + // Find our message + Long recipientMessageTimestamp = null; + for (MessageTransactionData messageTransactionData : messageTransactionsData) + if (Arrays.equals(messageTransactionData.getSenderPublicKey(), tradeBotData.getTradeNativePublicKey())) { + recipientMessageTimestamp = messageTransactionData.getTimestamp(); + break; + } + + if (recipientMessageTimestamp == null) { + LOGGER.warn(() -> String.format("Unable to find our message to trade creator '%s'?", crossChainTradeData.qortalCreatorTradeAddress)); + return; + } + + int lockTimeA = tradeBotData.getLockTimeA(); + int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA); + + // Our calculated lockTimeB should match AT's calculated lockTimeB + if (lockTimeB != crossChainTradeData.lockTimeB) { + LOGGER.debug(() -> String.format("Trade AT lockTimeB '%d' doesn't match our lockTimeB '%d'", crossChainTradeData.lockTimeB, lockTimeB)); + // We'll eventually refund + return; + } + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, FEE_AMOUNT); + if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { + // We couldn't fund P2SH-b at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-b funding transaction?")); + return; + } + + // P2SH-b funded, now we wait for Bob to redeem it + tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + + private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + // XXX REFUND CHECK + + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // It's possible AT hasn't processed our previous MESSAGE yet and so lockTimeB won't be set + if (crossChainTradeData.lockTimeB == null) + // AT yet to process MESSAGE + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < crossChainTradeData.expectedBitcoin) + if (balance == null || balance < FEE_AMOUNT) { + if (balance != null && balance > 0) + LOGGER.debug(() -> String.format("P2SH-b balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT))); + + return; + } + + // Redeem P2SH-b using secret-b + Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-a + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret()); + + if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { + // We couldn't redeem P2SH-b at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-b redeeming transaction?")); + return; + } + + // P2SH-b redeemed, now we wait for Alice to use secret to redeem AT + tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + + private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + // XXX REFUND CHECK + + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); + if (p2shTransactions == null) { + LOGGER.debug(() -> String.format("Unable to fetch transactions relating to '%s'", p2shAddress)); + return; + } + + byte[] secretB = BTCP2SH.findP2shSecret(p2shAddress, p2shTransactions); + if (secretB == null) + // Secret not revealed at this time return; - // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + // Send MESSAGE to AT using both secrets + byte[] secretA = tradeBotData.getSecret(); + byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); - MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, crossChainTradeData.qortalCreatorTradeAddress, messageData, false, false); + MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false); messageTransaction.computeNonce(); messageTransaction.sign(sender); @@ -319,7 +539,50 @@ public class TradeBot { return; } - tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); + tradeBotData.setState(TradeBotData.State.ALICE_DONE); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + } + + private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException { + // XXX REFUND CHECK + + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // AT should be 'finished' once Alice has redeemed QORT funds + if (!atData.getIsFinished()) + // Not finished yet + return; + + byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-a from redeem message to AT '%s'?", tradeBotData.getAtAddress())); + return; + } + + // Use secretA to redeem P2SH-a + + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA); + + if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { + // We couldn't redeem P2SH-a at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-a redeeming transaction?")); + return; + } + + tradeBotData.setState(TradeBotData.State.BOB_DONE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 6bf00073..6b350349 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -1,26 +1,42 @@ 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 { @@ -60,6 +76,9 @@ public class BTC { private final NetworkParameters params; private final ElectrumX electrumX; + // Let ECKey.equals() do the hard work + private final Set spentKeys = new HashSet<>(); + // Constructors and instance private BTC() { @@ -121,9 +140,10 @@ public class BTC { List 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); } @@ -132,17 +152,17 @@ public class BTC { } public List getUnspentOutputs(String base58Address) { - List> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); + List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address)); if (unspentOutputs == null) return null; List unspentTransactionOutputs = new ArrayList<>(); - for (Pair unspentOutput : unspentOutputs) { - List transactionOutputs = getOutputs(unspentOutput.getA()); + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = getOutputs(unspentOutput.hash); if (transactionOutputs == null) return null; - unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.getB())); + unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); } return unspentTransactionOutputs; @@ -157,6 +177,7 @@ public class BTC { return transaction.getOutputs(); } + /** Returns list of raw transactions spending passed address. */ public List getAddressTransactions(String base58Address) { return this.electrumX.getAddressTransactions(addressToScript(base58Address)); } @@ -165,6 +186,147 @@ public class BTC { return this.electrumX.broadcastTransaction(transaction.bitcoinSerialize()); } + /** + * Returns bitcoinj transaction sending amount to recipient. + * + * @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)); + + DeterministicKeyChain activeKeyChain = wallet.getActiveKeyChain(); + activeKeyChain.setLookaheadSize(3); + + 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); + + do { + activeKeyChain.maybeLookAhead(); + + try { + wallet.completeTx(sendRequest); + break; + } catch (InsufficientMoneyException e) { + return null; + } catch (WalletAwareUTXOProvider.AllKeysSpentException e) { + // loop again and use maybeLookAhead() to generate more keys to check + } + } while (true); + + return sendRequest.tx; + } + + // UTXOProvider support + + static class WalletAwareUTXOProvider implements UTXOProvider { + private final Wallet wallet; + private final BTC btc; + + // We extend RuntimeException for unchecked-ness so it will bubble up to caller. + // We can't use UTXOProviderException as it will be wrapped in RuntimeException anyway. + @SuppressWarnings("serial") + public static class AllKeysSpentException extends RuntimeException { + public AllKeysSpentException() { + super(); + } + } + + public WalletAwareUTXOProvider(BTC btc, Wallet wallet) { + this.btc = btc; + this.wallet = wallet; + } + + public List getOpenTransactionOutputs(List keys) throws UTXOProviderException { + List allUnspentOutputs = new ArrayList<>(); + final boolean coinbase = false; + + boolean areAllKeysSpent = true; + for (ECKey key : keys) { + if (btc.spentKeys.contains(key)) { + wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + continue; + } + + Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List 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 all passed keys are spent then we need to signal caller that they might want to + * generate more keys to check. + */ + + if (unspentOutputs.isEmpty()) { + // Ask for transaction history - if it's empty then key has never been used + List 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); + } + + continue; + } + + // If we reach here, then there's definitely at least one unspent key + areAllKeysSpent = false; + + for (UnspentOutput unspentOutput : unspentOutputs) { + List 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 (areAllKeysSpent) + // Notify caller that they need to check more keys + throw new AllKeysSpentException(); + + 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) { diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index ad185d87..3910bfa4 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -4,6 +4,7 @@ import static org.ciyam.at.OpCode.calcOffset; import java.nio.ByteBuffer; import java.util.Arrays; +import java.util.List; import org.ciyam.at.API; import org.ciyam.at.CompilationException; @@ -19,6 +20,7 @@ import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; +import org.qortal.data.transaction.MessageTransactionData; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.utils.Base58; @@ -747,4 +749,49 @@ public class BTCACCT { return (int) ((lockTimeA + (recipientMessageTimestamp / 1000L)) / 2L); } + public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { + String atAddress = crossChainTradeData.qortalAtAddress; + String redeemerAddress = crossChainTradeData.qortalRecipient; + + List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(atAddress, null, null, null); + if (messageTransactionsData == null) + return null; + + // Find redeem message + for (MessageTransactionData messageTransactionData : messageTransactionsData) { + // Check message payload type/encryption + if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) + continue; + + // Check message payload size + byte[] messageData = messageTransactionData.getData(); + if (messageData.length != 32 + 32) + // Wrong payload length + continue; + + // Check sender + if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress)) + // Wrong sender; + continue; + + // Extract both secretA & secretB + byte[] secretA = new byte[32]; + System.arraycopy(messageData, 0, secretA, 0, secretA.length); + byte[] secretB = new byte[32]; + System.arraycopy(messageData, 32, secretB, 0, secretB.length); + + byte[] hashOfSecretA = Crypto.hash160(secretA); + if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA)) + continue; + + byte[] hashOfSecretB = Crypto.hash160(secretB); + if (!Arrays.equals(hashOfSecretB, crossChainTradeData.hashOfSecretB)) + continue; + + return secretA; + } + + return null; + } + } diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 41c3d99d..0c5213f5 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -25,7 +25,6 @@ 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; @@ -166,7 +165,21 @@ public class ElectrumX { return (Long) balanceJson.get("confirmed"); } - public List> getUnspentOutputs(byte[] script) { + 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; + } + } + + public List getUnspentOutputs(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); @@ -174,14 +187,16 @@ public class ElectrumX { if (unspentJson == null) return null; - List> unspentOutputs = new ArrayList<>(); + List unspentOutputs = new ArrayList<>(); for (Object rawUnspent : unspentJson) { JSONObject unspent = (JSONObject) rawUnspent; byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); int outputIndex = ((Long) unspent.get("tx_pos")).intValue(); + int height = ((Long) unspent.get("height")).intValue(); + long value = (Long) unspent.get("value"); - unspentOutputs.add(new Pair<>(txHash, outputIndex)); + unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value)); } return unspentOutputs; @@ -195,6 +210,7 @@ public class ElectrumX { return HashCode.fromString(rawTransactionHex).asBytes(); } + /** Returns list of raw transactions. */ public List getAddressTransactions(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 4441212c..8a77d80f 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -15,14 +15,11 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class TradeBotData { - // Never expose this - @XmlTransient - @Schema(hidden = true) private byte[] tradePrivateKey; public enum State { - BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(20), BOB_SENDING_MESSAGE_TO_AT(30), BOB_WAITING_FOR_P2SH_B(40), BOB_WAITING_FOR_AT_REDEEM(50), - ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(90), ALICE_WATCH_P2SH_B(100); + 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), + ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_REFUNDING_B(95), ALICE_REFUNDING_A(100), ALICE_DONE(105); public final int value; private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); @@ -41,6 +38,7 @@ public class TradeBotData { private byte[] tradeNativePublicKey; private byte[] tradeNativePublicKeyHash; + String tradeNativeAddress; private byte[] secret; private byte[] hashOfSecret; @@ -50,24 +48,36 @@ public class TradeBotData { private long bitcoinAmount; + // Never expose this + @XmlTransient + @Schema(hidden = true) + private String xprv58; + private byte[] lastTransactionSignature; private Integer lockTimeA; + protected TradeBotData() { + /* JAXB */ + } + public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, - byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, byte[] secret, byte[] hashOfSecret, + byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, + byte[] secret, byte[] hashOfSecret, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, byte[] lastTransactionSignature, Integer lockTimeA) { + long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; this.atAddress = atAddress; 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; } @@ -100,6 +110,10 @@ public class TradeBotData { return this.tradeNativePublicKeyHash; } + public String getTradeNativeAddress() { + return this.tradeNativeAddress; + } + public byte[] getSecret() { return this.secret; } @@ -120,6 +134,10 @@ public class TradeBotData { return this.bitcoinAmount; } + public String getXprv58() { + return this.xprv58; + } + public byte[] getLastTransactionSignature() { return this.lastTransactionSignature; } diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java index e1b409a0..cee1dc69 100644 --- a/src/main/java/org/qortal/repository/CrossChainRepository.java +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -6,8 +6,13 @@ import org.qortal.data.crosschain.TradeBotData; public interface CrossChainRepository { + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException; + public List 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; + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 392f42b1..3c30444e 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -17,13 +17,58 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { this.repository = repository; } + @Override + public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { + String sql = "SELECT trade_state, at_address, " + + "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 " + + "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 atAddress = resultSet.getString(2); + byte[] tradeNativePublicKey = resultSet.getBytes(3); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(4); + String tradeNativeAddress = resultSet.getString(5); + byte[] secret = resultSet.getBytes(6); + byte[] hashOfSecret = resultSet.getBytes(7); + byte[] tradeForeignPublicKey = resultSet.getBytes(8); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); + long bitcoinAmount = resultSet.getLong(10); + String xprv58 = resultSet.getString(11); + byte[] lastTransactionSignature = resultSet.getBytes(12); + Integer lockTimeA = resultSet.getInt(13); + if (lockTimeA == 0 && resultSet.wasNull()) + lockTimeA = null; + + return new TradeBotData(tradePrivateKey, tradeState, + atAddress, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, + tradeForeignPublicKey, tradeForeignPublicKeyHash, + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA); + } catch (SQLException e) { + throw new DataException("Unable to fetch trade-bot trading state from repository", e); + } + } + @Override public List getAllTradeBotData() throws DataException { String sql = "SELECT trade_private_key, trade_state, at_address, " + "trade_native_public_key, trade_native_public_key_hash, " - + "secret, hash_of_secret, " + + "trade_native_address, secret, hash_of_secret, " + "trade_foreign_public_key, trade_foreign_public_key_hash, " - + "bitcoin_amount, last_transaction_signature, locktime_a " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -42,21 +87,24 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { String atAddress = resultSet.getString(3); byte[] tradeNativePublicKey = resultSet.getBytes(4); byte[] tradeNativePublicKeyHash = resultSet.getBytes(5); - byte[] secret = resultSet.getBytes(6); - byte[] hashOfSecret = resultSet.getBytes(7); - byte[] tradeForeignPublicKey = resultSet.getBytes(8); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); - long bitcoinAmount = resultSet.getLong(10); - byte[] lastTransactionSignature = resultSet.getBytes(11); - Integer lockTimeA = resultSet.getInt(12); + String tradeNativeAddress = resultSet.getString(6); + byte[] secret = resultSet.getBytes(7); + byte[] hashOfSecret = resultSet.getBytes(8); + byte[] tradeForeignPublicKey = resultSet.getBytes(9); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(10); + long bitcoinAmount = resultSet.getLong(11); + String xprv58 = resultSet.getString(12); + byte[] lastTransactionSignature = resultSet.getBytes(13); + Integer lockTimeA = resultSet.getInt(14); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, atAddress, - tradeNativePublicKey, tradeNativePublicKeyHash, secret, hashOfSecret, + tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, + secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, lastTransactionSignature, lockTimeA); + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -73,14 +121,16 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) .bind("trade_state", tradeBotData.getState().value) .bind("at_address", tradeBotData.getAtAddress()) - .bind("locktime_a", tradeBotData.getLockTimeA()) .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("last_transaction_signature", tradeBotData.getLastTransactionSignature()); + .bind("xprv58", tradeBotData.getXprv58()) + .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) + .bind("locktime_a", tradeBotData.getLockTimeA()); try { saveHelper.execute(this.repository); @@ -89,4 +139,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { } } -} + @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); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index df08efcb..3ea10454 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -623,9 +623,9 @@ public class HSQLDBDatabaseUpdates { stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + "at_address QortalAddress, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " - + "secret VARBINARY(32) NOT NULL, hash_of_secret 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, last_transaction_signature Signature, locktime_a BIGINT, " + + "bitcoin_amount BIGINT NOT NULL, xprv58 VARCHAR(200), last_transaction_signature Signature, locktime_a BIGINT, " + "PRIMARY KEY (trade_private_key))"); break; diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index b9f7869a..1b6123a7 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -62,4 +62,15 @@ public class BtcTests extends Common { assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); } + @Test + public void testBuildSpend() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; + long amount = 1000L; + + btc.buildSpend(xprv58, recipient, amount); + } + } diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java index 3a958c79..992af2ee 100644 --- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java +++ b/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java @@ -12,8 +12,8 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.Test; import org.qortal.crosschain.ElectrumX; +import org.qortal.crosschain.ElectrumX.UnspentOutput; import org.qortal.utils.BitTwiddling; -import org.qortal.utils.Pair; import com.google.common.hash.HashCode; @@ -100,13 +100,13 @@ public class ElectrumXTests { Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); - List> unspentOutputs = electrumX.getUnspentOutputs(script); + List unspentOutputs = electrumX.getUnspentOutputs(script); assertNotNull(unspentOutputs); assertFalse(unspentOutputs.isEmpty()); - for (Pair unspentOutput : unspentOutputs) - System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.getA()).toString(), unspentOutput.getB())); + for (UnspentOutput unspentOutput : unspentOutputs) + System.out.println(String.format("TestNet address %s has unspent output at tx %s, output index %d", address, HashCode.fromBytes(unspentOutput.hash), unspentOutput.index)); } @Test From f9b726a75dfd05dfea90d135a6dbce7906606bc7 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 13 Jul 2020 11:14:45 +0100 Subject: [PATCH 17/51] WIP: TradeBot - added refunding code --- .../api/resource/CrossChainResource.java | 12 +- .../java/org/qortal/controller/TradeBot.java | 291 ++++++++++++++---- src/main/java/org/qortal/crosschain/BTC.java | 1 + .../qortal/data/crosschain/TradeBotData.java | 4 +- 4 files changed, 250 insertions(+), 58 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index d1cbcd8f..6db79ceb 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1029,8 +1029,16 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); - if (tradeBotData.getState() != TradeBotData.State.ALICE_DONE && tradeBotData.getState() != TradeBotData.State.BOB_DONE) - return "false"; + switch (tradeBotData.getState()) { + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + + default: + return "false"; + } repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); repository.saveChanges(); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 61dbb39c..0402fe0a 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -20,6 +20,7 @@ import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; +import org.qortal.data.account.AccountBalanceData; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; @@ -104,6 +105,8 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + LOGGER.info(() -> String.format("Built AT %s. Waiting for deployment", atAddress)); + // Return to user for signing and broadcast as we don't have their Qortal private key try { return DeployAtTransactionTransformer.toBytes(deployAtTransactionData); @@ -124,7 +127,7 @@ public class TradeBot { byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); - // We need to generate lockTimeA: halfway of refundTimeout from now + // We need to generate lockTime-A: halfway of refundTimeout from now int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, @@ -134,30 +137,32 @@ public class TradeBot { tradeForeignPublicKey, tradeForeignPublicKeyHash, crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA); - // Check we have enough funds via xprv58 to fund both P2SH to cover expectedBitcoin + // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); - long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-a */ + FEE_AMOUNT /* P2SH-b */; + long totalFundsRequired = crossChainTradeData.expectedBitcoin + FEE_AMOUNT /* P2SH-A */ + FEE_AMOUNT /* P2SH-B */; Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); if (fundingCheckTransaction == null) return false; - // P2SH_a to be funded + // P2SH-A to be funded byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - // Fund P2SH-a + // Fund P2SH-A Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT); if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { - // We couldn't fund P2SH-a at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-a funding transaction?")); + // We couldn't fund P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A funding transaction?")); return false; } repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + return true; } @@ -230,6 +235,18 @@ public class TradeBot { case BOB_DONE: break; + case ALICE_REFUNDING_B: + handleAliceRefundingP2shB(repository, tradeBotData); + break; + + case ALICE_REFUNDING_A: + handleAliceRefundingP2shA(repository, tradeBotData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; + default: LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name())); } @@ -248,12 +265,14 @@ public class TradeBot { tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); } private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); @@ -261,14 +280,28 @@ public class TradeBot { byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - Long balance = BTC.getInstance().getBalance(p2shAddress); - if (balance == null || balance < crossChainTradeData.expectedBitcoin) { - if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-a balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + // If AT has finished then maybe Bob cancelled his trade offer + if (atData.getIsFinished()) { + // No point sending MESSAGE - might as well wait for refund + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); return; } + Long balance = BTC.getInstance().getBalance(p2shAddress); + if (balance == null || balance < crossChainTradeData.expectedBitcoin) { + if (balance != null && balance > 0) + LOGGER.debug(() -> String.format("P2SH-A balance %s lower than expected %s", BTC.format(balance), BTC.format(crossChainTradeData.expectedBitcoin))); + + return; + } + + // P2SH-A funding confirmed + // Attempt to send MESSAGE to Bob's Qortal trade address byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); @@ -283,20 +316,34 @@ public class TradeBot { ValidationResult result = messageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", messageTransaction.getRecipient(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", + p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress())); } private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { // Fetch AT so we can determine trade start timestamp ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + + // If AT has finished then Bob likely cancelled his trade offer + if (atData.getIsFinished()) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + return; } @@ -320,7 +367,7 @@ public class TradeBot { if (messageTransactionData.isText()) continue; - // We're expecting: HASH160(secret) + Alice's Bitcoin pubkeyhash + // We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A byte[] messageData = messageTransactionData.getData(); BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); if (offerMessageData == null) @@ -356,13 +403,19 @@ public class TradeBot { ValidationResult result = outgoingMessageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", outgoingMessageTransaction.getRecipient(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", outgoingMessageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); + String p2shBAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + LOGGER.info(() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shBAddress)); + return; } @@ -374,15 +427,30 @@ public class TradeBot { } private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A + if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA()) { + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + if (atData.getIsFinished()) + LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); + else + LOGGER.info(() -> String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddress)); + + return; + } + // We're waiting for AT to be in TRADE mode if (crossChainTradeData.mode != CrossChainTradeData.Mode.TRADE) return; @@ -390,12 +458,17 @@ public class TradeBot { // We're expecting AT to be locked to our native trade address if (!crossChainTradeData.qortalRecipient.equals(tradeBotData.getTradeNativeAddress())) { // AT locked to different address! We shouldn't continue but wait and refund. - LOGGER.warn(() -> String.format("Trade AT '%s' locked to '%s', not us ('%s')", + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + LOGGER.warn(() -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalRecipient, - tradeBotData.getTradeNativeAddress())); + tradeBotData.getTradeNativeAddress(), + p2shAddress)); - // There's no P2SH-b at this point, so jump straight to refunding P2SH-a + // There's no P2SH-B at this point, so jump straight to refunding P2SH-A tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -403,12 +476,12 @@ public class TradeBot { return; } - // Alice needs to fund P2SH-b here + // Alice needs to fund P2SH-B here // Find our MESSAGE to AT from previous state List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(crossChainTradeData.qortalCreatorTradeAddress, null, null, null); if (messageTransactionsData == null) { - LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT '%s' from repository", crossChainTradeData.qortalCreatorTradeAddress)); + LOGGER.warn(() -> String.format("Unable to fetch messages to trade AT %s from repository", crossChainTradeData.qortalCreatorTradeAddress)); return; } @@ -421,16 +494,16 @@ public class TradeBot { } if (recipientMessageTimestamp == null) { - LOGGER.warn(() -> String.format("Unable to find our message to trade creator '%s'?", crossChainTradeData.qortalCreatorTradeAddress)); + LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress)); return; } int lockTimeA = tradeBotData.getLockTimeA(); int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA); - // Our calculated lockTimeB should match AT's calculated lockTimeB + // Our calculated lockTime-B should match AT's calculated lockTime-B if (lockTimeB != crossChainTradeData.lockTimeB) { - LOGGER.debug(() -> String.format("Trade AT lockTimeB '%d' doesn't match our lockTimeB '%d'", crossChainTradeData.lockTimeB, lockTimeB)); + LOGGER.debug(() -> String.format("Trade AT lockTime-B '%d' doesn't match our lockTime-B '%d'", crossChainTradeData.lockTimeB, lockTimeB)); // We'll eventually refund return; } @@ -440,27 +513,39 @@ public class TradeBot { Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, FEE_AMOUNT); if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { - // We couldn't fund P2SH-b at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-b funding transaction?")); + // We couldn't fund P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B funding transaction?")); return; } - // P2SH-b funded, now we wait for Bob to redeem it + // P2SH-B funded, now we wait for Bob to redeem it tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", + tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress)); } private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + // If we've passed AT refund timestamp then AT will have finished after auto-refunding + if (atData.getIsFinished()) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + + return; + } + // It's possible AT hasn't processed our previous MESSAGE yet and so lockTimeB won't be set if (crossChainTradeData.lockTimeB == null) // AT yet to process MESSAGE @@ -472,36 +557,36 @@ public class TradeBot { Long balance = BTC.getInstance().getBalance(p2shAddress); if (balance == null || balance < FEE_AMOUNT) { if (balance != null && balance > 0) - LOGGER.debug(() -> String.format("P2SH-b balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT))); + LOGGER.debug(() -> String.format("P2SH-B balance %s lower than expected %s", BTC.format(balance), BTC.format(FEE_AMOUNT))); return; } - // Redeem P2SH-b using secret-b - Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-a + // Redeem P2SH-B using secret-B + Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret()); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { - // We couldn't redeem P2SH-b at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-b redeeming transaction?")); + // We couldn't redeem P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B redeeming transaction?")); return; } - // P2SH-b redeemed, now we wait for Alice to use secret to redeem AT + // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress())); } private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); @@ -509,9 +594,20 @@ public class TradeBot { byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + // Refund P2SH-B if we've passed lockTime-B + if (NTP.getTime() >= crossChainTradeData.lockTimeB) { + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("LockTime-B reached, refunding P2SH-B %s - aborting trade", p2shAddress)); + + return; + } + List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); if (p2shTransactions == null) { - LOGGER.debug(() -> String.format("Unable to fetch transactions relating to '%s'", p2shAddress)); + LOGGER.debug(() -> String.format("Unable to fetch transactions relating to %s", p2shAddress)); return; } @@ -530,26 +626,29 @@ public class TradeBot { messageTransaction.computeNonce(); messageTransaction.sign(sender); - // reset repository state to prevent deadlock + // Reset repository state to prevent deadlock repository.discardChanges(); ValidationResult result = messageTransaction.importAsUnconfirmed(); if (result != ValidationResult.OK) { - LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT '%s': %s", messageTransaction.getRecipient(), result.name())); + LOGGER.warn(() -> String.format("Unable to send MESSAGE to AT %s: %s", messageTransaction.getRecipient(), result.name())); return; } tradeBotData.setState(TradeBotData.State.ALICE_DONE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + String receiveAddress = tradeBotData.getTradeNativeAddress(); + + LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", + p2shAddress, tradeBotData.getAtAddress(), receiveAddress)); } private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException { - // XXX REFUND CHECK - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT '%s' from repository", tradeBotData.getAtAddress())); + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); return; } CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); @@ -559,13 +658,25 @@ public class TradeBot { // Not finished yet return; - byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData); - if (secretA == null) { - LOGGER.debug(() -> String.format("Unable to find secret-a from redeem message to AT '%s'?", tradeBotData.getAtAddress())); + // If AT's balance is zero, then it's auto-refunded so we're done + AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT); + if (atBalanceData == null || atBalanceData.getBalance() == 0L) { + tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + return; } - // Use secretA to redeem P2SH-a + byte[] secretA = BTCACCT.findSecretA(repository, crossChainTradeData); + if (secretA == null) { + LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); + return; + } + + // Use secret-A to redeem P2SH-A byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); @@ -577,14 +688,86 @@ public class TradeBot { Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { - // We couldn't redeem P2SH-a at this time - LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-a redeeming transaction?")); + // We couldn't redeem P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A redeeming transaction?")); return; } tradeBotData.setState(TradeBotData.State.BOB_DONE); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + + String receiveAddress = BTC.getInstance().pkhToAddress(tradeBotData.getTradeForeignPublicKeyHash()); + + LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receiveAddress)); + } + + private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // We can't refund P2SH-B until lockTime-B has passed + if (NTP.getTime() <= crossChainTradeData.lockTimeB) + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin refundAmount = Coin.ZERO; + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { + // We couldn't refund P2SH-B at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B refund transaction?")); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress)); + } + + private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { + ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); + if (atData == null) { + LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + return; + } + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + + // We can't refund P2SH-A until lockTime-A has passed + if (NTP.getTime() <= tradeBotData.getLockTimeA()) + return; + + byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); + String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { + // We couldn't refund P2SH-A at this time + LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A refund transaction?")); + return; + } + + tradeBotData.setState(TradeBotData.State.ALICE_REFUNDED); + + repository.getCrossChainRepository().save(tradeBotData); + repository.saveChanges(); + + LOGGER.info(() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddress)); } } diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 6b350349..66603d69 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -117,6 +117,7 @@ public class BTC { return format(Coin.valueOf(amount)); } + /** Returns P2PKH Bitcoin address using passed public key hash. */ public String pkhToAddress(byte[] publicKeyHash) { return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString(); } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 8a77d80f..2e935ee5 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -18,8 +18,8 @@ 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), - ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_REFUNDING_B(95), ALICE_REFUNDING_A(100), ALICE_DONE(105); + 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 map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); From b294f5e333d99a5376fca4dd404aaee532637e8e Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 13 Jul 2020 16:57:13 +0100 Subject: [PATCH 18/51] WIP: More defensive ElectrumX calls. Bring non-trade-bot API calls up to date --- .../api/model/CrossChainCancelRequest.java | 6 +- .../api/model/CrossChainSecretRequest.java | 7 +- .../api/model/CrossChainTradeRequest.java | 6 +- .../api/resource/CrossChainResource.java | 93 ++++++++++++------- .../java/org/qortal/controller/TradeBot.java | 19 +++- .../java/org/qortal/crosschain/BTCP2SH.java | 4 +- .../java/org/qortal/crosschain/ElectrumX.java | 57 ++++++++---- 7 files changed, 123 insertions(+), 69 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java index e1f57a7e..730421a4 100644 --- a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -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") - public byte[] creatorPublicKey; + @Schema(description = "AT's trade public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] tradePublicKey; - @Schema(description = "Qortal AT address") + @Schema(description = "Qortal trade AT address") public String atAddress; public CrossChainCancelRequest() { diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index f0b5d0d1..52b9f125 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -14,8 +14,11 @@ public class CrossChainSecretRequest { @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "secret-A + secret-B (64 bytes)", example = "2gt2nSVBFknLfdU5buKtScLuTibkt9C3x6PZVqnA3AJ6BdEf3A9RbSj5Hn5QkvavdTTfmttNEaYEVw34TZdz135Q") - 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; public CrossChainSecretRequest() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java index 32737dd5..3a632bf3 100644 --- a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java @@ -9,13 +9,13 @@ import io.swagger.v3.oas.annotations.media.Schema; public class CrossChainTradeRequest { @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") - public byte[] creatorPublicKey; + 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 MESSAGE transaction") + public byte[] messageTransactionSignature; public CrossChainTradeRequest() { } diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 6db79ceb..a68eb0e5 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -68,6 +68,7 @@ import org.qortal.repository.RepositoryManager; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction; +import org.qortal.transaction.Transaction.TransactionType; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.Transformer; @@ -76,8 +77,6 @@ import org.qortal.transform.transaction.MessageTransactionTransformer; import org.qortal.utils.Base58; import org.qortal.utils.NTP; -import com.google.common.primitives.Bytes; - @Path("/crosschain") @Tag(name = "Cross-Chain") public class CrossChainResource { @@ -224,7 +223,7 @@ public class CrossChainResource { summary = "Builds raw, unsigned MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode", description = "Specify address of cross-chain AT that needs to be messaged, and address of Qortal recipient.
" + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" - + "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.", + + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( required = true, content = @Content( @@ -248,28 +247,52 @@ public class CrossChainResource { public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { Security.checkApiCallAllowed(request); - byte[] creatorPublicKey = tradeRequest.creatorPublicKey; + byte[] tradePublicKey = tradeRequest.tradePublicKey; - if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); if (tradeRequest.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - if (tradeRequest.recipient == null || !Crypto.isValidAddress(tradeRequest.recipient)) + if (tradeRequest.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, tradeRequest.atAddress); + ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Does supplied public key match trade public key? + if (tradePublicKey != null && !Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature); + if (transactionData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN); + + if (transactionData.getType() != TransactionType.MESSAGE) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + + MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; + byte[] messageData = messageTransactionData.getData(); + BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); + if (offerMessageData == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); + // Good to make MESSAGE - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(tradeRequest.recipient), 32, 0); - byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, tradeRequest.atAddress, recipientAddressBytes); + byte[] aliceForeignPublicKeyHash = offerMessageData.recipientBitcoinPKH; + byte[] hashOfSecretA = offerMessageData.hashOfSecretA; + int lockTimeA = (int) offerMessageData.lockTimeA; + + String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); + int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + + byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -315,11 +338,14 @@ public class CrossChainResource { if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - if (secretRequest.secret == null || secretRequest.secret.length != BTCACCT.SECRET_LENGTH * 2) + if (secretRequest.secretA == null || secretRequest.secretA.length != BTCACCT.SECRET_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + + if (secretRequest.secretB == null || secretRequest.secretB.length != BTCACCT.SECRET_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, secretRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER) @@ -334,7 +360,8 @@ public class CrossChainResource { // Good to make MESSAGE - byte[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, secretRequest.secret); + byte[] messageData = BTCACCT.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB); + byte[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, messageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -348,7 +375,7 @@ public class CrossChainResource { summary = "Builds raw, unsigned MESSAGE transaction that cancels cross-chain trade offer", description = "Specify address of cross-chain AT that needs to be cancelled.
" + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" - + "You need to sign output with same account as the AT creator otherwise the MESSAGE transaction will be invalid.", + + "You need to sign output with trade's private key otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( required = true, content = @Content( @@ -372,28 +399,31 @@ public class CrossChainResource { public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { Security.checkApiCallAllowed(request); - byte[] creatorPublicKey = cancelRequest.creatorPublicKey; + byte[] tradePublicKey = cancelRequest.tradePublicKey; - if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, creatorPublicKey, cancelRequest.atAddress); + ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + // Does supplied public key match trade public key? + if (tradePublicKey != null && !Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + // Good to make MESSAGE - PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); - String creatorAddress = creatorAccount.getAddress(); - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(creatorAddress), 32, 0); + String atCreatorAddress = crossChainTradeData.qortalCreator; + byte[] messageData = BTCACCT.buildRefundMessage(atCreatorAddress); - byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, recipientAddressBytes); + byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, cancelRequest.atAddress, messageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -468,7 +498,7 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == Mode.OFFER) @@ -551,7 +581,7 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, templateRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == Mode.OFFER) @@ -684,7 +714,7 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, refundRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, refundRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == Mode.OFFER) @@ -820,7 +850,7 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, redeemRequest.atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, redeemRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode == Mode.OFFER) @@ -920,7 +950,7 @@ public class CrossChainResource { public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { Security.checkApiCallAllowed(request); - if (tradeBotCreateRequest.tradeTimeout < 600) + if (tradeBotCreateRequest.tradeTimeout < 60) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); try (final Repository repository = RepositoryManager.getRepository()) { @@ -978,7 +1008,7 @@ public class CrossChainResource { // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, null, atAddress); // null to skip creator check + ATData atData = fetchAtDataWithChecking(repository, atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); if (crossChainTradeData.mode != Mode.OFFER) @@ -1049,15 +1079,11 @@ public class CrossChainResource { } } - private ATData fetchAtDataWithChecking(Repository repository, byte[] creatorPublicKey, String atAddress) throws DataException { + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN); - // Does supplied public key match that of AT? - if (creatorPublicKey != null && !Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - // Must be correct AT - check functionality using code hash if (!Arrays.equals(atData.getCodeHash(), BTCACCT.CODE_BYTES_HASH)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); @@ -1082,15 +1108,14 @@ public class CrossChainResource { int nonce = 0; long amount = 0L; Long assetId = null; // no assetId as amount is zero - Long fee = null; + Long fee = 0L; BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, senderPublicKey, fee, null); TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, atAddress, amount, assetId, messageData, false, false); MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - fee = messageTransaction.calcRecommendedFee(); - messageTransactionData.setFee(fee); + messageTransaction.computeNonce(); ValidationResult result = messageTransaction.isValidUnconfirmed(); if (result != ValidationResult.OK) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 0402fe0a..4e32316c 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -435,7 +435,7 @@ public class TradeBot { CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A - if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA()) { + if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -595,7 +595,7 @@ public class TradeBot { String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); // Refund P2SH-B if we've passed lockTime-B - if (NTP.getTime() >= crossChainTradeData.lockTimeB) { + if (NTP.getTime() >= crossChainTradeData.lockTimeB * 1000L) { tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -711,7 +711,7 @@ public class TradeBot { CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); // We can't refund P2SH-B until lockTime-B has passed - if (NTP.getTime() <= crossChainTradeData.lockTimeB) + if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L) return; byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); @@ -721,7 +721,7 @@ public class TradeBot { ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); + Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, crossChainTradeData.lockTimeB); if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { // We couldn't refund P2SH-B at this time LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B refund transaction?")); @@ -745,7 +745,12 @@ public class TradeBot { CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); // We can't refund P2SH-A until lockTime-A has passed - if (NTP.getTime() <= tradeBotData.getLockTimeA()) + if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L) + return; + + // We can't refund P2SH-A until we've passed median block time + Integer medianBlockTime = BTC.getInstance().getMedianBlockTime(); + if (medianBlockTime == null || NTP.getTime() <= medianBlockTime * 1000L) return; byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); @@ -754,6 +759,10 @@ public class TradeBot { Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + if (fundingOutputs == null) { + LOGGER.debug(() -> String.format("Couldn't fetch unspent outputs for %s", p2shAddress)); + return; + } Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, tradeBotData.getLockTimeA()); if (!BTC.getInstance().broadcastTransaction(p2shRefundTransaction)) { diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java index 90e77710..0e8a2b0b 100644 --- a/src/main/java/org/qortal/crosschain/BTCP2SH.java +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -93,9 +93,9 @@ public class BTCP2SH { // 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, so no lockTime and no RBF + 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 - 1, so lockTime can be used but not RBF + input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF transaction.addInput(input); } diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 0c5213f5..d3541527 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -128,16 +128,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 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"); @@ -158,8 +168,13 @@ public class ElectrumX { 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"); @@ -183,12 +198,12 @@ public class ElectrumX { 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 unspentOutputs = new ArrayList<>(); - for (Object rawUnspent : unspentJson) { + for (Object rawUnspent : (JSONArray) unspentJson) { JSONObject unspent = (JSONObject) rawUnspent; byte[] txHash = HashCode.fromString((String) unspent.get("tx_hash")).asBytes(); @@ -203,11 +218,11 @@ public class ElectrumX { } 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. */ @@ -215,13 +230,13 @@ public class ElectrumX { 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 rawTransactions = new ArrayList<>(); - for (Object rawTransactionInfo : transactionsJson) { + for (Object rawTransactionInfo : (JSONArray) transactionsJson) { JSONObject transactionInfo = (JSONObject) rawTransactionInfo; // We only want confirmed transactions @@ -254,11 +269,11 @@ public class ElectrumX { private Set serverPeersSubscribe() { Set 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; @@ -393,10 +408,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"); } From dea2f34c523bb05659b047e06e946d274b70fc85 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 17 Jul 2020 11:28:22 +0100 Subject: [PATCH 19/51] Trade-bot: more comments, more documentation, more ElectrumX servers. Bitcoin main-net ElectrumX server list added to ElectrumX class, albeit commented out at this point until it is decided that trade-bot is ready for production use. (Simply remove the leading //s) More comments and documentation has been added to TradeBot class to further describe the actions taken. It is important to note that: Bitcoin wallet access is required by trade-bot and so: A Bitcoin WALLET PRIVATE KEY is stored in the database by trade-bot and hence, if you use trade-bot: DO NOT DISTRIBUTE YOUR DB FILES TO ANYONE ELSE! Furthermore it should be obvious that this functionality is provided on a 'best effort", not guaranteed, basis, therefore: YOUR FUNDS ARE AT RISK! If you are unsure about any aspect, or cannot afford to lose your funds, or it's possible that unexpected outcomes occur, then DO NOT USE. To use trade-bot on Bitcoin TESTNET then this to your settings JSON file: "bitcoinNet": "TEST3", See Settings.java line 100, and BTC class for more info. --- .../java/org/qortal/controller/TradeBot.java | 170 ++++++++++++++++++ .../java/org/qortal/crosschain/BTCACCT.java | 2 + .../java/org/qortal/crosschain/ElectrumX.java | 29 ++- 3 files changed, 199 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 4e32316c..91f23345 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -59,6 +59,38 @@ public class TradeBot { return instance; } + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for BTC. + *

+ * Generates: + *

    + *
  • new 'trade' private key
  • + *
  • secret-B
  • + *
+ * Derives: + *
    + *
  • 'native' (as in Qortal) public key, public key hash, address (starting with Q)
  • + *
  • 'foreign' (as in Bitcoin) public key, public key hash
  • + *
  • HASH160 of secret-B
  • + *
+ * A Qortal AT is then constructed including the following as constants in the 'data segment': + *
    + *
  • 'native'/Qortal 'trade' address - used as a MESSAGE contact
  • + *
  • 'foreign'/Bitcoin public key hash - used by Alice's P2SH scripts to allow redeem
  • + *
  • HASH160 of secret-B - used by AT and P2SH to validate a potential secret-B
  • + *
  • QORT amount on offer by Bob
  • + *
  • BTC amount expected in return by Bob (from Alice)
  • + *
  • trading timeout, in case things go wrong and everyone needs to refund
  • + *
+ * Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network. + *

+ * Trade-bot will wait for Bob's AT to be deployed before taking next step. + *

+ * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretB = generateSecret(); @@ -115,6 +147,44 @@ public class TradeBot { } } + /** + * Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching BTC to an existing offer. + *

+ * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a Bitcoin wallet via xprv58. + *

+ * The crossChainTradeData contains the current trade offer state + * as extracted from the AT's data segment. + *

+ * Access to a funded wallet is via a Bitcoin BIP32 hierarchical deterministic key, + * passed via xprv58. + * This key will be stored in your node's database + * to allow trade-bot to create/fund the necessary P2SH transactions! + * However, due to the nature of BIP32 keys, it is possible to give the trade-bot + * only a subset of wallet access (see BIP32 for more details). + *

+ * As an example, the xprv58 can be extract from a legacy, password-less + * Electrum wallet by going to the console tab and entering:
+ * wallet.keystore.xprv
+ * which should result in a base58 string starting with either 'xprv' (for Bitcoin main-net) + * or 'tprv' for (Bitcoin test-net). + *

+ * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. + *

+ * If sufficient funds are available, this method will actually fund the P2SH-A + * with the Bitcoin amount expected by 'Bob'. + *

+ * If the Bitcoin transaction is successfully broadcast to the network then the trade-bot entry + * is saved to the repository and the cross-chain trading process commences. + *

+ * Trade-bot will wait for P2SH-A to confirm before taking next step. + *

+ * @param repository + * @param crossChainTradeData chosen trade OFFER that Alice wants to match + * @param xprv58 funded wallet xprv in base58 + * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise + * @throws DataException + */ public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretA = generateSecret(); @@ -258,6 +328,11 @@ public class TradeBot { } } + /** + * Trade-bot is waiting for Bob's AT to deploy. + *

+ * If AT is deployed, then trade-bot's next step is to wait for MESSAGE from Alice. + */ private void handleBobWaitingForAtConfirm(Repository repository, TradeBotData tradeBotData) throws DataException { if (!repository.getATRepository().exists(tradeBotData.getAtAddress())) return; @@ -269,6 +344,22 @@ public class TradeBot { LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); } + /** + * Trade-bot is waiting for Alice's P2SH-A to confirm. + *

+ * If P2SH-A is confirmed, then trade-bot's next step is to MESSAGE Bob's trade address with Alice's trade info. + *

+ * It is possible between broadcast and confirmation of P2SH-A funding transaction, that Bob has cancelled his trade offer. + * If this is detected then trade-bot's next step is to wait until P2SH-A can refund back to Alice. + *

+ * In normal operation, trade-bot send a zero-fee, PoW MESSAGE on Alice's behalf containing: + *

    + *
  • Alice's 'foreign'/Bitcoin public key hash - so Bob's trade-bot can derive P2SH-A address and check balance
  • + *
  • HASH160 of Alice's secret-A - also used to derive P2SH-A address
  • + *
  • lockTime of P2SH-A - also used to derive P2SH-A address, but also for other use later in the trading process
  • + *
+ * If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only. + */ private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -328,6 +419,24 @@ public class TradeBot { p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress())); } + /** + * Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info. + *

+ * It's possible Bob has cancelling his trade offer, receiving an automatic QORT refund, + * in which case trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot. + *

+ * Details from Alice are used to derive P2SH-A address and this is checked for funding balance. + *

+ * Assuming P2SH-A has at least expected Bitcoin balance, + * Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details. + *

+ * On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice. + *

+ * Trade-bot's next step is to wait for P2SH-B, which will allow Bob to reveal his secret-B, + * needed by Alice to progress her side of the trade. + */ private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException { // Fetch AT so we can determine trade start timestamp ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); @@ -426,6 +535,20 @@ public class TradeBot { } } + /** + * Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only. + *

+ * It's possible that Bob has cancelled his trade offer in the mean time, or that somehow + * this process has taken so long that we've reached P2SH-A's locktime, or that someone else + * has managed to trade with Bob. In any of these cases, trade-bot switches to begin the refunding process. + *

+ * Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct. + *

+ * If all is well, trade-bot then uses Bitcoin wallet to (token) fund P2SH-B. + *

+ * If P2SH-B funding transaction is successfully broadcast to the Bitcoin network, trade-bot's next + * step is to watch for Bob revealing secret-B by redeeming P2SH-B. + */ private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -527,6 +650,16 @@ public class TradeBot { tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress)); } + /** + * Trade-bot is waiting for P2SH-B to funded. + *

+ * It's possible than Bob's AT has reached it's trading timeout and automatically refunded QORT back to Bob. + * In which case, trade-bot is done with this specific trade and finalizes on refunded state. + *

+ * Assuming P2SH-B is funded, trade-bot 'redeems' this P2SH using secret-B, thus revealing it to Alice. + *

+ * Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT. + */ private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -583,6 +716,22 @@ public class TradeBot { LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress())); } + /** + * Trade-bot is waiting for Bob to redeem P2SH-B thus revealing secret-B to Alice. + *

+ * It's possible that this process has taken so long that we've reached P2SH-B's locktime. + * In which case, trade-bot switches to begin the refund process. + *

+ * If trade-bot can extract a valid secret-B from the spend of P2SH-B, then it creates a + * zero-fee, PoW MESSAGE to send to Bob's AT, including both secret-B and also Alice's secret-A. + *

+ * Both secrets are needed to release the QORT funds from Bob's AT to Alice's 'native'/Qortal + * trade address. + *

+ * In revealing a valid secret-A, Bob can then redeem the BTC funds from P2SH-A. + *

+ * If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done. + */ private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -645,6 +794,19 @@ public class TradeBot { p2shAddress, tradeBotData.getAtAddress(), receiveAddress)); } + /** + * Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the BTC funds from P2SH-A. + *

+ * It's possible that Bob's AT has reached its trading timeout and automatically refunded QORT back to Bob. In which case, + * trade-bot is done with this specific trade and finalizes in refunded state. + *

+ * Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the BTC funds from P2SH-A + * to Bob's 'foreign'/Bitcoin trade legacy-format address, as derived from trade private key. + *

+ * (This could potentially be 'improved' to send BTC to any address of Bob's choosing by changing the transaction output). + *

+ * If trade-bot successfully broadcasts the transaction, then this specific trade is done. + */ private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -702,6 +864,13 @@ public class TradeBot { LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receiveAddress)); } + /** + * Trade-bot is attempting to refund P2SH-B. + *

+ * We could potentially skip this step as P2SH-B is only funded with a token amount to cover the mining fee should Bob redeem P2SH-B. + *

+ * Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A. + */ private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { @@ -736,6 +905,7 @@ public class TradeBot { LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress)); } + /** Trade-bot is attempting to refund P2SH-A. */ private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException { ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); if (atData == null) { diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 3910bfa4..cb87ca0f 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -77,11 +77,13 @@ import com.google.common.primitives.Bytes; *

  • Alice scans P2SH-b redeem tx to extract secret-b *
      *
    • Alice MESSAGEs Qortal AT from her trade address, sending secret-a & secret-b
    • + *
    • AT's QORT funds end up at Qortal address derived from Alice's trade private key
    • *
    *
  • *
  • Bob checks AT, extracts secret-a *
      *
    • Bob redeems P2SH-a using his Bitcoin trade key and secret-a
    • + *
    • P2SH-a funds end up in at Bitcoin address derived from Bob's trade private key
    • *
    *
  • * diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index d3541527..2331a305 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -29,6 +29,7 @@ import org.qortal.crypto.TrustlessSSLSocketFactory; 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); @@ -92,7 +93,22 @@ 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": @@ -118,6 +134,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)); @@ -164,6 +181,7 @@ 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); @@ -180,6 +198,7 @@ public class ElectrumX { return (Long) balanceJson.get("confirmed"); } + /** Unspent output info as returned by ElectrumX network. */ public static class UnspentOutput { public final byte[] hash; public final int index; @@ -194,6 +213,7 @@ public class ElectrumX { } } + /** Returns list of unspent outputs pertaining to passed payment script, or null if there was an error. */ public List getUnspentOutputs(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); @@ -217,6 +237,7 @@ public class ElectrumX { return unspentOutputs; } + /** Returns raw transaction for passed transaction hash, or null if not found. */ public byte[] getRawTransaction(byte[] txHash) { Object rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); if (!(rawTransactionHex instanceof String)) @@ -225,7 +246,7 @@ public class ElectrumX { return HashCode.fromString((String) rawTransactionHex).asBytes(); } - /** Returns list of raw transactions. */ + /** Returns list of raw transactions, relating to passed payment script, if null if there's an error. */ public List getAddressTransactions(byte[] script) { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); @@ -254,6 +275,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) @@ -266,6 +288,7 @@ public class ElectrumX { // Class-private utility methods + /** Query current server for its list of peer servers, and return those we can parse. */ private Set serverPeersSubscribe() { Set newServers = new HashSet<>(); @@ -318,6 +341,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); @@ -336,6 +360,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; From 94c83d6a93febfbbd5780d5103686d527c5ded7d Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 17 Jul 2020 11:46:21 +0100 Subject: [PATCH 20/51] Commit 1 of 2: removing old CIYAM AT v1.3.4 --- lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar | Bin 146003 -> 0 bytes lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom | 9 --------- 2 files changed, 9 deletions(-) delete mode 100644 lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar delete mode 100644 lib/org/ciyam/AT/1.3.4/AT-1.3.4.pom diff --git a/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar b/lib/org/ciyam/AT/1.3.4/AT-1.3.4.jar deleted file mode 100644 index 611836faf3ae0de20dd5f4c33cf19e2bb73c269c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 146003 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%0AuK~+{Mv| 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 kNiNS?4nb%H;M&3mOA{K< - - 4.0.0 - org.ciyam - AT - 1.3.4 - POM was created from install:install-file - From ca8eabc42503f0e37a7820742c5a211e261eb0ff Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 17 Jul 2020 11:46:39 +0100 Subject: [PATCH 21/51] Commit 2 of 2: adding new CIYAM AT v.1.3.5 --- lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar | Bin 0 -> 149868 bytes lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom | 9 +++++++++ lib/org/ciyam/AT/maven-metadata-local.xml | 5 +++-- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar create mode 100644 lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom diff --git a/lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.jar new file mode 100644 index 0000000000000000000000000000000000000000..45045ad3dd63d431423eb3f679d05c124f3b98cd GIT binary patch literal 149868 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

    *gMZAa{G?(h; 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 literal 0 HcmV?d00001 diff --git a/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom new file mode 100644 index 00000000..b24b6706 --- /dev/null +++ b/lib/org/ciyam/AT/1.3.5/AT-1.3.5.pom @@ -0,0 +1,9 @@ + + + 4.0.0 + org.ciyam + AT + 1.3.5 + POM was created from install:install-file + diff --git a/lib/org/ciyam/AT/maven-metadata-local.xml b/lib/org/ciyam/AT/maven-metadata-local.xml index 680b4f78..82cd311a 100644 --- a/lib/org/ciyam/AT/maven-metadata-local.xml +++ b/lib/org/ciyam/AT/maven-metadata-local.xml @@ -3,10 +3,11 @@ org.ciyam AT - 1.3.4 + 1.3.5 1.3.4 + 1.3.5 - 20200609101009 + 20200717104214 From 21d7a4eed16454db1d0f920bae839cdfb27e3d44 Mon Sep 17 00:00:00 2001 From: catbref Date: Fri, 17 Jul 2020 11:46:39 +0100 Subject: [PATCH 22/51] Improved AT PUT_TX_AFTER_TIMESTAMP_INTO_A function Previous version fetched all the blocks from previous 'timestamp' to current height, checking each transaction. (very slow) New implementation leverages repository to do the heavy lifting. Could potentially benefit from some DB indexes in the future? Added unit test to cover. --- src/main/java/org/qortal/at/QortalATAPI.java | 70 ++--- .../org/qortal/repository/ATRepository.java | 24 ++ .../repository/hsqldb/HSQLDBATRepository.java | 36 +++ .../test/at/GetNextTransactionTests.java | 268 ++++++++++++++++++ 4 files changed, 346 insertions(+), 52 deletions(-) create mode 100644 src/test/java/org/qortal/test/at/GetNextTransactionTests.java diff --git a/src/main/java/org/qortal/at/QortalATAPI.java b/src/main/java/org/qortal/at/QortalATAPI.java index 0975bacb..582b44e2 100644 --- a/src/main/java/org/qortal/at/QortalATAPI.java +++ b/src/main/java/org/qortal/at/QortalATAPI.java @@ -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,11 +29,10 @@ 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; @@ -150,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 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 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, BitTwiddling.longFromBEBytes(signature, 8)); - this.setA3(state, BitTwiddling.longFromBEBytes(signature, 16)); - this.setA4(state, BitTwiddling.longFromBEBytes(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 diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index affbaf18..887672d8 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -88,4 +88,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. + *

    + * @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; + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index f6de4fb4..808cc44d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -341,4 +341,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); + } + + } + } diff --git a/src/test/java/org/qortal/test/at/GetNextTransactionTests.java b/src/test/java/org/qortal/test/at/GetNextTransactionTests.java new file mode 100644 index 00000000..eafc22fb --- /dev/null +++ b/src/test/java/org/qortal/test/at/GetNextTransactionTests.java @@ -0,0 +1,268 @@ +package org.qortal.test.at; + +import static org.junit.Assert.*; + +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.List; + +import org.ciyam.at.CompilationException; +import org.ciyam.at.FunctionCode; +import org.ciyam.at.MachineState; +import org.ciyam.at.OpCode; +import org.ciyam.at.Timestamp; +import org.junit.Before; +import org.junit.Test; +import org.qortal.account.PrivateKeyAccount; +import org.qortal.asset.Asset; +import org.qortal.at.QortalAtLoggerFactory; +import org.qortal.block.Block; +import org.qortal.data.at.ATStateData; +import org.qortal.data.block.BlockData; +import org.qortal.data.transaction.BaseTransactionData; +import org.qortal.data.transaction.DeployAtTransactionData; +import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.data.transaction.TransactionData; +import org.qortal.group.Group; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.test.common.BlockUtils; +import org.qortal.test.common.Common; +import org.qortal.test.common.TransactionUtils; +import org.qortal.transaction.DeployAtTransaction; +import org.qortal.transaction.MessageTransaction; +import org.qortal.transaction.Transaction; +import org.qortal.utils.BitTwiddling; + +public class GetNextTransactionTests extends Common { + + @Before + public void before() throws DataException { + Common.useDefaultSettings(); + } + + @Test + public void testGetNextTransaction() throws DataException { + byte[] data = new byte[] { 0x44 }; + + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "alice"); + + byte[] creationBytes = buildGetNextTransactionAT(); + + long fundingAmount = 1_00000000L; + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, creationBytes, fundingAmount); + String atAddress = deployAtTransaction.getATAccount().getAddress(); + + byte[] rawNextTimestamp = new byte[32]; + Transaction transaction; + + // Confirm initial value is zero + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + // Send message to someone other than AT + sendMessage(repository, deployer, data, deployer.getAddress()); + BlockUtils.mintBlock(repository); + + // Confirm AT does not find message + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + assertArrayEquals(new byte[32], rawNextTimestamp); + + // Send message to AT + transaction = sendMessage(repository, deployer, data, atAddress); + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + BlockUtils.mintBlock(repository); + assertTimestamp(repository, atAddress, transaction); + + // Mint a few blocks, then send non-AT message, followed by AT message + for (int i = 0; i < 5; ++i) + BlockUtils.mintBlock(repository); + sendMessage(repository, deployer, data, deployer.getAddress()); + transaction = sendMessage(repository, deployer, data, atAddress); + BlockUtils.mintBlock(repository); + + // Confirm AT finds message + BlockUtils.mintBlock(repository); + assertTimestamp(repository, atAddress, transaction); + } + } + + private byte[] buildGetNextTransactionAT() { + // Labels for data segment addresses + int addrCounter = 0; + + // Beginning of data segment for easy extraction + final int addrNextTx = addrCounter; + addrCounter += 4; + + final int addrNextTxIndex = addrCounter++; + + final int addrLastTxTimestamp = addrCounter++; + + // Data segment + ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); + + // skip addrNextTx + dataByteBuffer.position(dataByteBuffer.position() + 4 * MachineState.VALUE_SIZE); + + // Store pointer to addrNextTx at addrNextTxIndex + dataByteBuffer.putLong(addrNextTx); + + ByteBuffer codeByteBuffer = ByteBuffer.allocate(512); + + // Two-pass version + for (int pass = 0; pass < 2; ++pass) { + codeByteBuffer.clear(); + + try { + /* Initialization */ + + // Use AT creation 'timestamp' as starting point for finding transactions sent to AT + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + + // Set restart position to after this opcode + codeByteBuffer.put(OpCode.SET_PCS.compile()); + + /* Loop, waiting for message to AT */ + + // Find next transaction to this AT since the last one (if any) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + // Copy A to data segment, starting at addrNextTx (as pointed to by addrNextTxIndex) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_A_IND, addrNextTxIndex)); + // Stop if timestamp part of A is zero + codeByteBuffer.put(OpCode.STZ_DAT.compile(addrNextTx)); + + // Update our 'last found transaction's timestamp' using 'timestamp' from transaction + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); + // Stop and wait for next block + codeByteBuffer.put(OpCode.STP_IMD.compile()); + + } catch (CompilationException e) { + throw new IllegalStateException("Unable to compile AT?", e); + } + } + + codeByteBuffer.flip(); + + byte[] codeBytes = new byte[codeByteBuffer.limit()]; + codeByteBuffer.get(codeBytes); + + final short ciyamAtVersion = 2; + final short numCallStackPages = 0; + final short numUserStackPages = 0; + final long minActivationAmount = 0L; + + return MachineState.toCreationBytes(ciyamAtVersion, codeBytes, dataByteBuffer.array(), numCallStackPages, numUserStackPages, minActivationAmount); + } + + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, byte[] creationBytes, long fundingAmount) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = deployer.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", deployer.getAddress())); + System.exit(2); + } + + Long fee = null; + String name = "Test AT"; + String description = "Test AT"; + String atType = "Test"; + String tags = "TEST"; + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, deployer.getPublicKey(), fee, null); + TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT); + + DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData); + + fee = deployAtTransaction.calcRecommendedFee(); + deployAtTransactionData.setFee(fee); + + TransactionUtils.signAndMint(repository, deployAtTransactionData, deployer); + + return deployAtTransaction; + } + + private void extractNextTxTimestamp(Repository repository, String atAddress, byte[] rawNextTimestamp) throws DataException { + // Check AT result + ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); + byte[] stateData = atStateData.getStateData(); + + QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); + + System.arraycopy(dataBytes, 0, rawNextTimestamp, 0, rawNextTimestamp.length); + } + + private MessageTransaction sendMessage(Repository repository, PrivateKeyAccount sender, byte[] data, String recipient) throws DataException { + long txTimestamp = System.currentTimeMillis(); + byte[] lastReference = sender.getLastReference(); + + if (lastReference == null) { + System.err.println(String.format("Qortal account %s has no last reference", sender.getAddress())); + System.exit(2); + } + + Long fee = null; + int version = 4; + int nonce = 0; + long amount = 0; + Long assetId = null; // because amount is zero + + BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, sender.getPublicKey(), fee, null); + TransactionData messageTransactionData = new MessageTransactionData(baseTransactionData, version, nonce, recipient, amount, assetId, data, false, false); + + MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); + + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + + TransactionUtils.signAndImportValid(repository, messageTransactionData, sender); + + return messageTransaction; + } + + private void assertTimestamp(Repository repository, String atAddress, Transaction transaction) throws DataException { + int height = transaction.getHeight(); + byte[] transactionSignature = transaction.getTransactionData().getSignature(); + + BlockData blockData = repository.getBlockRepository().fromHeight(height); + assertNotNull(blockData); + + Block block = new Block(repository, blockData); + + List blockTransactions = block.getTransactions(); + int sequence; + for (sequence = blockTransactions.size() - 1; sequence >= 0; --sequence) + if (Arrays.equals(blockTransactions.get(sequence).getTransactionData().getSignature(), transactionSignature)) + break; + + assertNotSame(-1, sequence); + + byte[] rawNextTimestamp = new byte[32]; + extractNextTxTimestamp(repository, atAddress, rawNextTimestamp); + + Timestamp expectedTimestamp = new Timestamp(height, sequence); + Timestamp actualTimestamp = new Timestamp(BitTwiddling.longFromBEBytes(rawNextTimestamp, 0)); + + assertEquals(String.format("Expected height %d, seq %d but was height %d, seq %d", + height, sequence, + actualTimestamp.blockHeight, actualTimestamp.transactionSequence + ), + expectedTimestamp.longValue(), + actualTimestamp.longValue()); + + byte[] expectedPartialSignature = new byte[24]; + System.arraycopy(transactionSignature, 8, expectedPartialSignature, 0, expectedPartialSignature.length); + + byte[] actualPartialSignature = new byte[24]; + System.arraycopy(rawNextTimestamp, 8, actualPartialSignature, 0, actualPartialSignature.length); + + assertArrayEquals(expectedPartialSignature, actualPartialSignature); + } + +} From 098e2623d64dd3298ae9f11b1f560ad36766a7bf Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 28 Jul 2020 17:21:54 +0100 Subject: [PATCH 23/51] WIP: cross-chain AT now stores bitcoin receiving PKH --- .../model/CrossChainBitcoinRedeemRequest.java | 3 ++ .../api/model/CrossChainBuildRequest.java | 3 ++ .../api/model/TradeBotCreateRequest.java | 3 ++ .../api/resource/CrossChainResource.java | 35 +++++++++++++++++-- .../java/org/qortal/controller/TradeBot.java | 24 +++++++++++-- .../java/org/qortal/crosschain/BTCACCT.java | 16 +++++++-- .../java/org/qortal/crosschain/BTCP2SH.java | 14 +++++--- .../data/crosschain/CrossChainTradeData.java | 19 ++++++++-- .../java/org/qortal/test/btcacct/AtTests.java | 8 +++-- .../org/qortal/test/btcacct/DeployAT.java | 19 +++++++--- .../java/org/qortal/test/btcacct/Redeem.java | 2 +- 11 files changed, 124 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java index 5e95e36c..68129a3c 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java @@ -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[] receivePublicKeyHash; + public CrossChainBitcoinRedeemRequest() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java index e8d38703..dfc5dfd8 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -33,6 +33,9 @@ public class CrossChainBuildRequest { @Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080") public Integer tradeTimeout; + @Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx") + public String receiveAddress; + public CrossChainBuildRequest() { } diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java index 1898a989..386715bd 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -27,6 +27,9 @@ public class TradeBotCreateRequest { @Schema(description = "Suggested trade timeout (minutes)", example = "10080") public int tradeTimeout; + @Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx") + public String receiveAddress; + public TradeBotCreateRequest() { } diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index a68eb0e5..51c33990 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -26,11 +26,13 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.LegacyAddress; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.Account; import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; @@ -179,11 +181,23 @@ public class CrossChainResource { if (tradeRequest.bitcoinAmount <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + Address bitcoinReceiveAddress; + try { + bitcoinReceiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeRequest.receiveAddress); + } catch (AddressFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + byte[] bitcoinReceivePublicKeyHash = bitcoinReceiveAddress.getHash(); + + // We only support P2PKH addresses at this time + if (bitcoinReceiveAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + try (final Repository repository = RepositoryManager.getRepository()) { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB, - tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout); + tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout, bitcoinReceivePublicKeyHash); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); @@ -848,6 +862,12 @@ public class CrossChainResource { if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.SECRET_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + if (redeemRequest.receivePublicKeyHash == null) + redeemRequest.receivePublicKeyHash = redeemKey.getPubKeyHash(); + + if (redeemRequest.receivePublicKeyHash.length != 20) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, redeemRequest.atAddress); @@ -888,7 +908,7 @@ public class CrossChainResource { Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret); + org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivePublicKeyHash); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); if (!wasBroadcast) @@ -950,6 +970,17 @@ public class CrossChainResource { public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { Security.checkApiCallAllowed(request); + Address receiveAddress; + try { + receiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receiveAddress); + } catch (AddressFormatException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + } + + // We only support P2PKH addresses at this time + if (receiveAddress.getOutputScriptType() != ScriptType.P2PKH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + if (tradeBotCreateRequest.tradeTimeout < 60) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 91f23345..8d9577c3 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -8,10 +8,13 @@ import java.util.concurrent.atomic.AtomicBoolean; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; import org.qortal.api.model.TradeBotCreateRequest; @@ -103,6 +106,18 @@ public class TradeBot { byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + // Convert Bitcoin receive address into public key hash (we only support P2PKH at this time) + Address bitcoinReceiveAddress; + try { + bitcoinReceiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receiveAddress); + } catch (AddressFormatException e) { + throw new DataException("Unsupported Bitcoin receive address: " + tradeBotCreateRequest.receiveAddress); + } + if (bitcoinReceiveAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Bitcoin receive address: " + tradeBotCreateRequest.receiveAddress); + + byte[] bitcoinReceivePublicKeyHash = bitcoinReceiveAddress.getHash(); + PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); // Deploy AT @@ -116,7 +131,8 @@ public class TradeBot { String description = "QORT/BTC cross-chain trade"; String aTType = "ACCT"; String tags = "ACCT QORT BTC"; - byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, + tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout, bitcoinReceivePublicKeyHash); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -699,8 +715,9 @@ public class TradeBot { Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + byte[] receivePublicKeyHash = crossChainTradeData.creatorReceiveBitcoinPKH; - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret()); + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivePublicKeyHash); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { // We couldn't redeem P2SH-B at this time @@ -846,8 +863,9 @@ public class TradeBot { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); + byte[] receivePublicKeyHash = crossChainTradeData.creatorReceiveBitcoinPKH; - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA); + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivePublicKeyHash); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { // We couldn't redeem P2SH-A at this time diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index cb87ca0f..da7ed7a1 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -92,7 +92,7 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("14ee2cb9899f582037901c384bab9ccdd41e48d8c98bf7df5cf79f4e8c236286").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("58542b1d204d7034280fb85e8053c056353fcc9c3870c062a19b2fc17f764092").asBytes(); // SHA256 of AT code bytes public static class OfferMessageData { public byte[] recipientBitcoinPKH; @@ -117,7 +117,7 @@ public class BTCACCT { * @param tradeTimeout suggested timeout for entire trade * @return */ - public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) { + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout, byte[] bitcoinReceivePublicKeyHash) { // Labels for data segment addresses int addrCounter = 0; @@ -157,6 +157,9 @@ public class BTCACCT { final int addrMessageDataPointer = addrCounter++; final int addrMessageDataLength = addrCounter++; + final int addrBitcoinReceivePublicKeyHash = addrCounter; + addrCounter += 4; + final int addrEndOfConstants = addrCounter; // Variables @@ -280,6 +283,10 @@ public class BTCACCT { assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; dataByteBuffer.putLong(32L); + // Bitcoin receive public key hash + assert dataByteBuffer.position() == addrBitcoinReceivePublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinReceivePublicKeyHash incorrect"; + dataByteBuffer.put(Bytes.ensureCapacity(bitcoinReceivePublicKeyHash, 32, 0)); + assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; // Code labels @@ -619,6 +626,11 @@ public class BTCACCT { // Skip message data length dataByteBuffer.position(dataByteBuffer.position() + 8); + // Creator's Bitcoin/foreign receiving public key hash + tradeData.creatorReceiveBitcoinPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorReceiveBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorReceiveBitcoinPKH.length); // skip to 32 bytes + /* End of constants / begin variables */ // Skip AT creator's address diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java index 0e8a2b0b..319fc5ac 100644 --- a/src/main/java/org/qortal/crosschain/BTCP2SH.java +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -76,16 +76,18 @@ public class BTCP2SH { * @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 fundingOutputs, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder) { + public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List fundingOutputs, byte[] redeemScriptBytes, + Long lockTime, Function 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(spendKey.getPubKeyHash())); + transaction.addOutput(amount, ScriptBuilder.createP2PKHOutputScript(outputPublicKeyHash)); for (int inputIndex = 0; inputIndex < fundingOutputs.size(); ++inputIndex) { TransactionOutput fundingOutput = fundingOutputs.get(inputIndex); @@ -149,7 +151,8 @@ public class BTCP2SH { return scriptBuilder.build(); }; - return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder); + // Send funds back to funding address + return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, refundKey.getPubKeyHash()); } /** @@ -160,9 +163,10 @@ public class BTCP2SH { * @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 receivePublicKeyHash PKH used for output * @return Signed Bitcoin transaction for redeeming P2SH */ - public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret) { + public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivePublicKeyHash) { Function redeemSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... ScriptBuilder scriptBuilder = new ScriptBuilder(); @@ -183,7 +187,7 @@ public class BTCP2SH { return scriptBuilder.build(); }; - return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder); + return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivePublicKeyHash); } /** Returns 'secret', if any, given list of raw bitcoin transactions. */ diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 99a7f5e5..1ad50218 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -26,9 +26,12 @@ public class CrossChainTradeData { @Schema(description = "AT creator's Qortal trade address") public String qortalCreatorTradeAddress; - @Schema(description = "AT creator's Bitcoin public-key-hash (PKH)") + @Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)") public byte[] creatorBitcoinPKH; + @Schema(description = "AT creator's Bitcoin receiving public-key-hash (PKH)") + public byte[] creatorReceiveBitcoinPKH; + @Schema(description = "Timestamp when AT was created (milliseconds since epoch)") public long creationTimestamp; @@ -84,7 +87,7 @@ public class CrossChainTradeData { // We can represent BitcoinPKH as an address @XmlElement(name = "creatorBitcoinAddress") - @Schema(description = "AT creator's Bitcoin PKH in address form") + @Schema(description = "AT creator's trading Bitcoin PKH in address form") public String getCreatorBitcoinAddress() { if (this.creatorBitcoinPKH == null) return null; @@ -94,7 +97,7 @@ public class CrossChainTradeData { // We can represent BitcoinPKH as an address @XmlElement(name = "recipientBitcoinAddress") - @Schema(description = "Trade partner's Bitcoin PKH in address form") + @Schema(description = "Trade partner's trading Bitcoin PKH in address form") public String getRecipientBitcoinAddress() { if (this.recipientBitcoinPKH == null) return null; @@ -102,4 +105,14 @@ public class CrossChainTradeData { return BTC.getInstance().pkhToAddress(this.recipientBitcoinPKH); } + // We can represent BitcoinPKH as an address + @XmlElement(name = "creatorBitcoinReceivingAddress") + @Schema(description = "AT creator's Bitcoin receiving address") + public String getCreatorBitcoinReceivingAddress() { + if (this.creatorReceiveBitcoinPKH == null) + return null; + + return BTC.getInstance().pkhToAddress(this.creatorReceiveBitcoinPKH); + } + } diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 3f0fe919..c5150daa 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -19,6 +19,7 @@ import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.block.Block; +import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -53,6 +54,7 @@ public class AtTests extends Common { public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; + public static final byte[] bitcoinReceivePublicKeyHash = HashCode.fromString("00112233445566778899aabbccddeeff").asBytes(); @Before public void beforeTest() throws DataException { @@ -63,7 +65,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout, bitcoinReceivePublicKeyHash); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -526,7 +528,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout, bitcoinReceivePublicKeyHash); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -616,6 +618,7 @@ public class AtTests extends Common { + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" + + "\treceiving bitcoin address: %s,\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, tradeData.qortalCreator, @@ -625,6 +628,7 @@ public class AtTests extends Common { HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), + BTC.getInstance().pkhToAddress(tradeData.creatorReceiveBitcoinPKH), currentBlockHeight)); // Are we in 'offer' or 'trade' stage? diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 56e75150..4f33353d 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -2,10 +2,13 @@ package org.qortal.test.btcacct; import java.security.Security; +import org.bitcoinj.core.Address; +import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.controller.Controller; +import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; @@ -34,7 +37,7 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT 50000) usage("Trade timeout (minutes) must be between 60 and 50000"); + + Address receiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), args[argIndex++]); + if (receiveAddress.getOutputScriptType() != ScriptType.P2PKH) + usage("Bitcoin receive address must be P2PKH form"); + + bitcoinReceivePublicKeyHash = receiveAddress.getHash(); } catch (IllegalArgumentException e) { usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); } @@ -114,7 +125,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout, bitcoinReceivePublicKeyHash); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java index 40968450..761d4796 100644 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ b/src/test/java/org/qortal/test/btcacct/Redeem.java @@ -182,7 +182,7 @@ public class Redeem { Coin redeemAmount = Coin.valueOf(p2shBalance).subtract(bitcoinFee); System.out.println(String.format("Spending %s of output, with %s as mining fee", BTC.format(redeemAmount), BTC.format(bitcoinFee))); - Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret); + Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secret, redeemAddress.getHash()); byte[] redeemBytes = redeemTransaction.bitcoinSerialize(); From e2dc91c1eaf658110971c414785a6889e40e8e8c Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 29 Jul 2020 10:38:32 +0100 Subject: [PATCH 24/51] Fix API call DELETE /crosschain/tradeoffer regarding PoW MESSAGE reference --- .../org/qortal/api/resource/CrossChainResource.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 51c33990..4a3f45b3 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -13,6 +13,7 @@ import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Random; import java.util.function.Function; import java.util.function.ToIntFunction; @@ -1127,13 +1128,12 @@ public class CrossChainResource { } private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { - PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, senderPublicKey); - + // senderPublicKey is actually ephemeral trade public key, so there is no corresponding account and hence no reference long txTimestamp = NTP.getTime(); - byte[] lastReference = creatorAccount.getLastReference(); - if (lastReference == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE); + Random random = new Random(); + byte[] lastReference = new byte[Transformer.SIGNATURE_LENGTH]; + random.nextBytes(lastReference); int version = 4; int nonce = 0; From d85b746021ff0f2707d60c2e89ac2d66420c2483 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 29 Jul 2020 18:11:47 +0100 Subject: [PATCH 25/51] WIP: trade-bot: add xprv validation method to BTC class and use that for API call /crosschain/tradebot/respond instead of vague byte-length check --- .../java/org/qortal/api/resource/CrossChainResource.java | 9 +-------- src/main/java/org/qortal/crosschain/BTC.java | 9 +++++++++ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 4a3f45b3..8748b9dc 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1028,15 +1028,8 @@ public class CrossChainResource { if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - final byte[] xprv; - try { - xprv = Base58.decode(tradeBotRespondRequest.xprv58); - - if (xprv.length != 4 + 1 + 4 + 4 + 32 + 33 + 4) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } catch (NumberFormatException e) { + if (!BTC.getInstance().isValidXprv(tradeBotRespondRequest.xprv58)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 66603d69..6d17fb06 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -117,6 +117,15 @@ public class BTC { 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(); From 83955acd22a2fa386c3cad574a5c1b27c83543bf Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 29 Jul 2020 18:13:27 +0100 Subject: [PATCH 26/51] WIP: trade-bot: allow trade-bot entries to be deleted if in BOB_WAITING_FOR_AT_CONFIRM state. Also, return false (instead of throwing internal error) if trade-bot entry does not exist --- src/main/java/org/qortal/api/resource/CrossChainResource.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 8748b9dc..73e0e050 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1083,8 +1083,11 @@ public class CrossChainResource { try (final Repository repository = RepositoryManager.getRepository()) { TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); + if (tradeBotData == null) + return "false"; switch (tradeBotData.getState()) { + case BOB_WAITING_FOR_AT_CONFIRM: case ALICE_DONE: case BOB_DONE: case ALICE_REFUNDED: From d2cae7c8b58afa2450e84fb859219fd7171f9251 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 29 Jul 2020 18:14:47 +0100 Subject: [PATCH 27/51] WIP: trade-bot: use correct Bob Bitcoin receive address in log entry --- src/main/java/org/qortal/controller/TradeBot.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 8d9577c3..d054b27e 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -877,7 +877,7 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); - String receiveAddress = BTC.getInstance().pkhToAddress(tradeBotData.getTradeForeignPublicKeyHash()); + String receiveAddress = BTC.getInstance().pkhToAddress(receivePublicKeyHash); LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receiveAddress)); } From 7fd7104f466f138967e79733dcce3f41953337f8 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 29 Jul 2020 20:25:28 +0100 Subject: [PATCH 28/51] WIP: trade-bot: add flag to be set by AT if redeem happens so trade-bot detects redeem instead of refund --- src/main/java/org/qortal/controller/TradeBot.java | 11 +++++++++-- src/main/java/org/qortal/crosschain/BTCACCT.java | 10 +++++++++- .../qortal/data/crosschain/CrossChainTradeData.java | 3 +++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index d054b27e..7ad551e5 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -39,6 +39,7 @@ import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; +import org.qortal.utils.Amounts; import org.qortal.utils.NTP; public class TradeBot { @@ -837,9 +838,15 @@ public class TradeBot { // Not finished yet return; - // If AT's balance is zero, then it's auto-refunded so we're done + // If AT's balance should be zero AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT); - if (atBalanceData == null || atBalanceData.getBalance() == 0L) { + if (atBalanceData != null && atBalanceData.getBalance() > 0L) { + LOGGER.debug(() -> String.format("AT %s should have zero balance, not %s", tradeBotData.getAtAddress(), Amounts.prettyAmount(atBalanceData.getBalance()))); + return; + } + + // We check variable in AT that is set when trade successfully completes + if (!crossChainTradeData.hasRedeemed) { tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index da7ed7a1..ab64ae09 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -92,7 +92,7 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("58542b1d204d7034280fb85e8053c056353fcc9c3870c062a19b2fc17f764092").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f62e1447e8361703c261c8e8e1973713d29b713716582f491431cb7f6ee99e80").asBytes(); // SHA256 of AT code bytes public static class OfferMessageData { public byte[] recipientBitcoinPKH; @@ -201,6 +201,8 @@ public class BTCACCT { final int addrMode = addrCounter++; + final int addrRedeemFlag = addrCounter++; + // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); @@ -500,6 +502,9 @@ public class BTCACCT { codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer)); // Pay AT's balance to recipient codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); + // Set redeem flag + codeByteBuffer.put(OpCode.INC_DAT.compile(addrRedeemFlag)); + // Fall-through to refunding any remaining balance back to AT creator /* Refund balance back to AT creator */ @@ -686,6 +691,8 @@ public class BTCACCT { long mode = dataByteBuffer.getLong(); + long redeemFlag = dataByteBuffer.getLong(); + if (mode != 0) { tradeData.mode = CrossChainTradeData.Mode.TRADE; tradeData.refundTimeout = refundTimeout; @@ -695,6 +702,7 @@ public class BTCACCT { tradeData.recipientBitcoinPKH = recipientBitcoinPKH; tradeData.lockTimeA = lockTimeA; tradeData.lockTimeB = lockTimeB; + tradeData.hasRedeemed = redeemFlag != 0; } else { tradeData.mode = CrossChainTradeData.Mode.OFFER; } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 1ad50218..68a6124e 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -79,6 +79,9 @@ public class CrossChainTradeData { @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)") public byte[] recipientBitcoinPKH; + @Schema(description = "Whether AT has paid out to trade partner") + public Boolean hasRedeemed; + // Constructors // Necessary for JAXB From 16581766c689dc6838e7c80e3180c2ff362cc715 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 29 Jul 2020 20:48:06 +0100 Subject: [PATCH 29/51] WIP: trade-bot: detect and remove mempool entries from ElectrumX "listunspent" results --- src/main/java/org/qortal/crosschain/ElectrumX.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 2331a305..b52994ac 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -226,9 +226,13 @@ public class ElectrumX { 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(); - int height = ((Long) unspent.get("height")).intValue(); long value = (Long) unspent.get("value"); unspentOutputs.add(new UnspentOutput(txHash, outputIndex, height, value)); From 6be67d0d9228c2b82f24ac19a1306ee4b5d94c6e Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 30 Jul 2020 08:12:45 +0100 Subject: [PATCH 30/51] WIP: trade-bot: make sure the "trade" private key is valid for both Curve25519 and secp256k1 --- src/main/java/org/qortal/controller/TradeBot.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 7ad551e5..5db223ac 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -254,9 +254,9 @@ public class TradeBot { } private static byte[] generateTradePrivateKey() { - byte[] seed = new byte[32]; - RANDOM.nextBytes(seed); - return seed; + // The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. + // Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. + return new ECKey().getPrivKeyBytes(); } private static byte[] deriveTradeNativePublicKey(byte[] privateKey) { From 876bfb525b1b8ceaf11320d2c343f85ef6d37fbd Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 3 Aug 2020 09:36:46 +0100 Subject: [PATCH 31/51] WIP: trade-bot: more receive address support, some terminology clarification Bitcoin receive address no longer stored in AT but dealt with by trade-bot. This allows 'Bob' to have his BTC sent anywhere he likes when redeeming P2SH-A thus saving a step, typically incurred by UI. DB shape change due to this. Similarly, AT code has been updated to expect a Qortal receiving address when Alice sends MESSAGE to redeem AT. This means both trade-bot entries (Alice/Bob) can be safely wiped once trade completes. Some terms were confusing like "trade recipient" which actually referred to Alice and so have been unified as "trade partner" as to not be confused with (say) "recipient address" The MESSAGEs sent from Alice to Bob, from Bob to AT and from Alice to AT have been given more useful names: 'offer', 'trade' and 'redeem'. There is also a cancel MESSAGE sent from Bob to AT to cancel AT before trading occurs. Some API calls have been renamed in light of above. AT's 'mode' has been expanded from simply OFFER/TRADE to: OFFERING, TRADING, REFUNDED, REDEEMED, CANCELLED Tests updated, but MORE TESTING REQUIRED BEFORE RELEASE --- .../api/model/CrossChainBuildRequest.java | 3 - .../api/model/CrossChainCancelRequest.java | 2 +- .../api/model/CrossChainSecretRequest.java | 7 +- .../api/model/CrossChainTradeRequest.java | 4 +- .../api/model/TradeBotRespondRequest.java | 3 + .../api/resource/CrossChainResource.java | 88 ++-- .../java/org/qortal/controller/TradeBot.java | 35 +- .../java/org/qortal/crosschain/BTCACCT.java | 406 ++++++++++-------- .../data/crosschain/CrossChainTradeData.java | 29 +- .../qortal/data/crosschain/TradeBotData.java | 12 +- .../hsqldb/HSQLDBCrossChainRepository.java | 13 +- .../hsqldb/HSQLDBDatabaseUpdates.java | 2 +- .../java/org/qortal/test/btcacct/AtTests.java | 213 ++++----- .../org/qortal/test/btcacct/DeployAT.java | 19 +- 14 files changed, 436 insertions(+), 400 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java index dfc5dfd8..e8d38703 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBuildRequest.java @@ -33,9 +33,6 @@ public class CrossChainBuildRequest { @Schema(description = "Trade time window (minutes) from trade agreement to automatic refund", example = "10080") public Integer tradeTimeout; - @Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx") - public String receiveAddress; - public CrossChainBuildRequest() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java index 730421a4..f87471d0 100644 --- a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -8,7 +8,7 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainCancelRequest { - @Schema(description = "AT's trade public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") + @Schema(description = "AT creator's 'trade' public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] tradePublicKey; @Schema(description = "Qortal trade AT address") diff --git a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java index 52b9f125..7ad825d4 100644 --- a/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainSecretRequest.java @@ -8,8 +8,8 @@ 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; @@ -20,6 +20,9 @@ public class CrossChainSecretRequest { @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() { } diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java index 3a632bf3..1afd7290 100644 --- a/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainTradeRequest.java @@ -8,13 +8,13 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeRequest { - @Schema(description = "AT creator's public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") + @Schema(description = "AT creator's 'trade' public key", example = "C6wuddsBV3HzRrXUtezE7P5MoRXp5m3mEDokRDGZB6ry") public byte[] tradePublicKey; @Schema(description = "Qortal AT address") public String atAddress; - @Schema(description = "Signature of trading partner's MESSAGE transaction") + @Schema(description = "Signature of trading partner's 'offer' MESSAGE transaction") public byte[] messageTransactionSignature; public CrossChainTradeRequest() { diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java index 2c319fd9..4e947a9b 100644 --- a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java @@ -14,6 +14,9 @@ public class TradeBotRespondRequest { @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() { } diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 73e0e050..dd77ed3b 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -59,7 +59,6 @@ import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; -import org.qortal.data.crosschain.CrossChainTradeData.Mode; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; @@ -182,23 +181,11 @@ public class CrossChainResource { if (tradeRequest.bitcoinAmount <= 0) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - Address bitcoinReceiveAddress; - try { - bitcoinReceiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeRequest.receiveAddress); - } catch (AddressFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - } - byte[] bitcoinReceivePublicKeyHash = bitcoinReceiveAddress.getHash(); - - // We only support P2PKH addresses at this time - if (bitcoinReceiveAddress.getOutputScriptType() != ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - try (final Repository repository = RepositoryManager.getRepository()) { PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey); byte[] creationBytes = BTCACCT.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB, - tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout, bitcoinReceivePublicKeyHash); + tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout); long txTimestamp = NTP.getTime(); byte[] lastReference = creatorAccount.getLastReference(); @@ -233,11 +220,11 @@ public class CrossChainResource { } @POST - @Path("/tradeoffer/recipient") + @Path("/tradeoffer/trademessage") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode", - description = "Specify address of cross-chain AT that needs to be messaged, and address of Qortal recipient.
    " - + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
    " + summary = "Builds raw, unsigned 'trade' MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode", + description = "Specify address of cross-chain AT that needs to be messaged, and signature of 'offer' MESSAGE from trade partner.
    " + + "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
    " + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( required = true, @@ -259,7 +246,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String sendTradeRecipient(CrossChainTradeRequest tradeRequest) { + public String buildTradeMessage(CrossChainTradeRequest tradeRequest) { Security.checkApiCallAllowed(request); byte[] tradePublicKey = tradeRequest.tradePublicKey; @@ -277,11 +264,11 @@ public class CrossChainResource { ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) + if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); // Does supplied public key match trade public key? - if (tradePublicKey != null && !Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature); @@ -299,7 +286,7 @@ public class CrossChainResource { // Good to make MESSAGE - byte[] aliceForeignPublicKeyHash = offerMessageData.recipientBitcoinPKH; + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; byte[] hashOfSecretA = offerMessageData.hashOfSecretA; int lockTimeA = (int) offerMessageData.lockTimeA; @@ -316,12 +303,12 @@ public class CrossChainResource { } @POST - @Path("/tradeoffer/secret") + @Path("/tradeoffer/redeemmessage") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that sends secrets to AT, releasing funds to recipient", - description = "Specify address of cross-chain AT that needs to be messaged, and both 32-byte secrets.
    " - + "AT needs to be in 'trade' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
    " - + "You need to sign output with account the AT considers the 'recipient' otherwise the MESSAGE transaction will be invalid.", + summary = "Builds raw, unsigned 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner", + description = "Specify address of cross-chain AT that needs to be messaged, both 32-byte secrets and an address for receiving QORT from AT.
    " + + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
    " + + "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( required = true, content = @Content( @@ -342,12 +329,12 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String sendSecret(CrossChainSecretRequest secretRequest) { + public String buildRedeemMessage(CrossChainSecretRequest secretRequest) { Security.checkApiCallAllowed(request); - byte[] recipientPublicKey = secretRequest.recipientPublicKey; + byte[] partnerPublicKey = secretRequest.partnerPublicKey; - if (recipientPublicKey == null || recipientPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + if (partnerPublicKey == null || partnerPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress)) @@ -359,24 +346,26 @@ public class CrossChainResource { if (secretRequest.secretB == null || secretRequest.secretB.length != BTCACCT.SECRET_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); + if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == CrossChainTradeData.Mode.OFFER) + if (crossChainTradeData.mode != BTCACCT.Mode.TRADING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - PublicKeyAccount recipientAccount = new PublicKeyAccount(repository, recipientPublicKey); - String recipientAddress = recipientAccount.getAddress(); + String partnerAddress = Crypto.toAddress(partnerPublicKey); - // MESSAGE must come from address that AT considers trade partner / 'recipient' - if (!crossChainTradeData.qortalRecipient.equals(recipientAddress)) + // MESSAGE must come from address that AT considers trade partner + if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); // Good to make MESSAGE - byte[] messageData = BTCACCT.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB); - byte[] messageTransactionBytes = buildAtMessage(repository, recipientPublicKey, secretRequest.atAddress, messageData); + byte[] messageData = BTCACCT.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress); + byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -387,7 +376,7 @@ public class CrossChainResource { @DELETE @Path("/tradeoffer") @Operation( - summary = "Builds raw, unsigned MESSAGE transaction that cancels cross-chain trade offer", + summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer", description = "Specify address of cross-chain AT that needs to be cancelled.
    " + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
    " + "You need to sign output with trade's private key otherwise the MESSAGE transaction will be invalid.", @@ -411,7 +400,7 @@ public class CrossChainResource { } ) @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - public String cancelTradeOffer(CrossChainCancelRequest cancelRequest) { + public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { Security.checkApiCallAllowed(request); byte[] tradePublicKey = cancelRequest.tradePublicKey; @@ -426,17 +415,17 @@ public class CrossChainResource { ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == CrossChainTradeData.Mode.TRADE) + if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); // Does supplied public key match trade public key? - if (tradePublicKey != null && !Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); // Good to make MESSAGE String atCreatorAddress = crossChainTradeData.qortalCreator; - byte[] messageData = BTCACCT.buildRefundMessage(atCreatorAddress); + byte[] messageData = BTCACCT.buildCancelMessage(atCreatorAddress); byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, cancelRequest.atAddress, messageData); @@ -516,7 +505,7 @@ public class CrossChainResource { ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData)); @@ -599,7 +588,7 @@ public class CrossChainResource { ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); @@ -732,7 +721,7 @@ public class CrossChainResource { ATData atData = fetchAtDataWithChecking(repository, refundRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); @@ -874,7 +863,7 @@ public class CrossChainResource { ATData atData = fetchAtDataWithChecking(repository, redeemRequest.atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode == Mode.OFFER) + if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); @@ -1031,15 +1020,18 @@ public class CrossChainResource { if (!BTC.getInstance().isValidXprv(tradeBotRespondRequest.xprv58)) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + // Extract data from cross-chain trading AT try (final Repository repository = RepositoryManager.getRepository()) { ATData atData = fetchAtDataWithChecking(repository, atAddress); CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - if (crossChainTradeData.mode != Mode.OFFER) + if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - boolean result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58); + boolean result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58, tradeBotRespondRequest.receivingAddress); return result ? "true" : "false"; } catch (DataException e) { diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 5db223ac..8c085256 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -40,6 +40,7 @@ import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transform.TransformationException; import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.utils.Amounts; +import org.qortal.utils.Base58; import org.qortal.utils.NTP; public class TradeBot { @@ -133,7 +134,7 @@ public class TradeBot { String aTType = "ACCT"; String tags = "ACCT QORT BTC"; byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, - tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout, bitcoinReceivePublicKeyHash); + tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -150,7 +151,7 @@ public class TradeBot { tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null, null, null); + tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivePublicKeyHash); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -188,7 +189,7 @@ public class TradeBot { *

    * It is envisaged that the value in xprv58 will actually come from a Qortal-UI-managed wallet. *

    - * If sufficient funds are available, this method will actually fund the P2SH-A + * If sufficient funds are available, this method will actually fund the P2SH-A * with the Bitcoin amount expected by 'Bob'. *

    * If the Bitcoin transaction is successfully broadcast to the network then the trade-bot entry @@ -202,7 +203,7 @@ public class TradeBot { * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise * @throws DataException */ - public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58) throws DataException { + public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretA = generateSecret(); byte[] hashOfSecretA = Crypto.hash160(secretA); @@ -213,6 +214,7 @@ public class TradeBot { byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); + byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH // We need to generate lockTime-A: halfway of refundTimeout from now int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); @@ -222,7 +224,7 @@ public class TradeBot { tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretA, hashOfSecretA, tradeForeignPublicKey, tradeForeignPublicKeyHash, - crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA); + crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash); // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); @@ -499,7 +501,7 @@ public class TradeBot { if (offerMessageData == null) continue; - byte[] aliceForeignPublicKeyHash = offerMessageData.recipientBitcoinPKH; + byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; byte[] hashOfSecretA = offerMessageData.hashOfSecretA; int lockTimeA = (int) offerMessageData.lockTimeA; // Determine P2SH-A address and confirm funded @@ -592,11 +594,11 @@ public class TradeBot { } // We're waiting for AT to be in TRADE mode - if (crossChainTradeData.mode != CrossChainTradeData.Mode.TRADE) + if (crossChainTradeData.mode != BTCACCT.Mode.TRADING) return; // We're expecting AT to be locked to our native trade address - if (!crossChainTradeData.qortalRecipient.equals(tradeBotData.getTradeNativeAddress())) { + if (!crossChainTradeData.qortalPartnerAddress.equals(tradeBotData.getTradeNativeAddress())) { // AT locked to different address! We shouldn't continue but wait and refund. byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); @@ -604,7 +606,7 @@ public class TradeBot { LOGGER.warn(() -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", tradeBotData.getAtAddress(), - crossChainTradeData.qortalRecipient, + crossChainTradeData.qortalPartnerAddress, tradeBotData.getTradeNativeAddress(), p2shAddress)); @@ -701,7 +703,7 @@ public class TradeBot { // AT yet to process MESSAGE return; - byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); Long balance = BTC.getInstance().getBalance(p2shAddress); @@ -716,7 +718,7 @@ public class TradeBot { Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - byte[] receivePublicKeyHash = crossChainTradeData.creatorReceiveBitcoinPKH; + byte[] receivePublicKeyHash = tradeBotData.getReceivingPublicKeyHash(); Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivePublicKeyHash); @@ -783,9 +785,10 @@ public class TradeBot { // Secret not revealed at this time return; - // Send MESSAGE to AT using both secrets + // Send 'redeem' MESSAGE to AT using both secrets byte[] secretA = tradeBotData.getSecret(); - byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB); + String qortalReceiveAddress = Base58.encode(tradeBotData.getReceivingPublicKeyHash()); // Actually contains whole address, not just PKH + byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceiveAddress); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false); @@ -846,7 +849,7 @@ public class TradeBot { } // We check variable in AT that is set when trade successfully completes - if (!crossChainTradeData.hasRedeemed) { + if (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) { tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -864,13 +867,13 @@ public class TradeBot { // Use secret-A to redeem P2SH-A - byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.recipientBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); + byte[] redeemScriptBytes = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - byte[] receivePublicKeyHash = crossChainTradeData.creatorReceiveBitcoinPKH; + byte[] receivePublicKeyHash = tradeBotData.getReceivingPublicKeyHash(); Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivePublicKeyHash); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index ab64ae09..8e3312c6 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -1,10 +1,13 @@ package org.qortal.crosschain; +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; import static org.ciyam.at.OpCode.calcOffset; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; +import java.util.Map; import org.ciyam.at.API; import org.ciyam.at.CompilationException; @@ -37,7 +40,7 @@ import com.google.common.primitives.Bytes; *

  • Bob generates Bitcoin & Qortal 'trade' keys, and secret-b *
      *
    • private key required to sign P2SH redeem tx
    • - *
    • private key can be used to create 'secret' (e.g. double-SHA256)
    • + *
    • private key could be used to create 'secret' (e.g. double-SHA256)
    • *
    • encrypted private key could be stored in Qortal AT for access by Bob from any node
    • *
    *
  • @@ -48,42 +51,53 @@ import com.google.common.primitives.Bytes; *
  • Alice finds Qortal AT and wants to trade *
      *
    • Alice generates Bitcoin & Qortal 'trade' keys
    • - *
    • Alice funds Bitcoin P2SH-a
    • - *
    • Alice MESSAGEs Bob from her Qortal trade address, sending secret-hash-a and Bitcoin PKH
    • + *
    • Alice funds Bitcoin P2SH-A
    • + *
    • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
        + *
      • hash-of-secret-A
      • + *
      • her 'trade' Bitcoin PKH
      • + *
      + *
    • *
    *
  • - *
  • Bob receives MESSAGE + *
  • Bob receives "offer" MESSAGE *
      - *
    • Checks Alice's P2SH-a
    • - *
    • Sends MESSAGE to Qortal AT from his trade address, containing: + *
    • Checks Alice's P2SH-A
    • + *
    • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: *
        *
      • Alice's trade Qortal address
      • *
      • Alice's trade Bitcoin PKH
      • - *
      • secret-hash-a
      • + *
      • hash-of-secret-A
      • *
      *
    • *
    *
  • *
  • Alice checks Qortal AT to confirm it's locked to her *
      - *
    • Alice creates/funds Bitcoin P2SH-b
    • + *
    • Alice creates/funds Bitcoin P2SH-B
    • *
    *
  • - *
  • Bob checks P2SH-b is funded + *
  • Bob checks P2SH-B is funded *
      - *
    • Bob redeems P2SH-b using his Bitcoin trade key and secret-b
    • + *
    • Bob redeems P2SH-B using his Bitcoin trade key and secret-B
    • *
    *
  • - *
  • Alice scans P2SH-b redeem tx to extract secret-b + *
  • Alice scans P2SH-B redeem transaction to extract secret-B *
      - *
    • Alice MESSAGEs Qortal AT from her trade address, sending secret-a & secret-b
    • - *
    • AT's QORT funds end up at Qortal address derived from Alice's trade private key
    • + *
    • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: + *
        + *
      • secret-A
      • + *
      • secret-B
      • + *
      • Qortal receive address of her chosing
      • + *
      + *
    • + *
    • AT's QORT funds are sent to Qortal receive address
    • *
    *
  • - *
  • Bob checks AT, extracts secret-a + *
  • Bob checks AT, extracts secret-A *
      - *
    • Bob redeems P2SH-a using his Bitcoin trade key and secret-a
    • - *
    • P2SH-a funds end up in at Bitcoin address derived from Bob's trade private key
    • + *
    • Bob redeems P2SH-A using his Bitcoin trade key and secret-A
    • + *
    • P2SH-A BTC funds end up at Bitcoin address determined by redeem transaction output(s)
    • *
    *
  • * @@ -92,13 +106,36 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f62e1447e8361703c261c8e8e1973713d29b713716582f491431cb7f6ee99e80").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("fad14381b77ae1a2bfe7e16a1a8b571839c5f405fca0490ead08499ac170f65b").asBytes(); // SHA256 of AT code bytes public static class OfferMessageData { - public byte[] recipientBitcoinPKH; + public byte[] partnerBitcoinPKH; public byte[] hashOfSecretA; public long lockTimeA; } + public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerBitcoinPKH*/ + 20 /*hashOfSecretA*/ + 8 /*lockTimeA*/; + public static final int TRADE_MESSAGE_LENGTH = 32 /*partner's Qortal trade address (padded from 25 to 32)*/ + + 24 /*partner's Bitcoin PKH (padded from 20 to 24)*/ + + 8 /*lockTimeB*/ + + 24 /*hash of secret-A (padded from 20 to 24)*/ + + 8 /*lockTimeA*/; + public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receive address padded from 25 to 32*/; + public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; + + public enum Mode { + OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4); + + public final int value; + private static final Map map = stream(Mode.values()).collect(toMap(mode -> mode.value, mode -> mode)); + + Mode(int value) { + this.value = value; + } + + public static Mode valueOf(int value) { + return map.get(value); + } + } private BTCACCT() { } @@ -106,7 +143,7 @@ public class BTCACCT { /** * Returns Qortal AT creation bytes for cross-chain trading AT. *

    - * tradeTimeout (minutes) is the time window for the recipient to send the + * tradeTimeout (minutes) is the time window for the trade partner to send the * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. * * @param creatorTradeAddress AT creator's trade Qortal address, also used for refunds @@ -117,7 +154,7 @@ public class BTCACCT { * @param tradeTimeout suggested timeout for entire trade * @return */ - public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout, byte[] bitcoinReceivePublicKeyHash) { + public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) { // Labels for data segment addresses int addrCounter = 0; @@ -138,28 +175,26 @@ public class BTCACCT { final int addrBitcoinAmount = addrCounter++; final int addrTradeTimeout = addrCounter++; - final int addrMessageTxType = addrCounter++; - final int addrExpectedOfferMessageLength = addrCounter++; + final int addrMessageTxnType = addrCounter++; final int addrExpectedTradeMessageLength = addrCounter++; + final int addrExpectedRedeemMessageLength = addrCounter++; final int addrCreatorAddressPointer = addrCounter++; final int addrHashOfSecretBPointer = addrCounter++; - final int addrQortalRecipientPointer = addrCounter++; + final int addrQortalPartnerAddressPointer = addrCounter++; final int addrMessageSenderPointer = addrCounter++; - final int addrOfferMessageRecipientBitcoinPKHOffset = addrCounter++; - final int addrRecipientBitcoinPKHPointer = addrCounter++; - final int addrOfferMessageHashOfSecretAOffset = addrCounter++; + final int addrTradeMessagePartnerBitcoinPKHOffset = addrCounter++; + final int addrPartnerBitcoinPKHPointer = addrCounter++; + final int addrTradeMessageHashOfSecretAOffset = addrCounter++; final int addrHashOfSecretAPointer = addrCounter++; - final int addrTradeMessageSecretBOffset = addrCounter++; + final int addrRedeemMessageSecretBOffset = addrCounter++; + final int addrRedeemMessageReceiveAddressOffset = addrCounter++; final int addrMessageDataPointer = addrCounter++; final int addrMessageDataLength = addrCounter++; - final int addrBitcoinReceivePublicKeyHash = addrCounter; - addrCounter += 4; - final int addrEndOfConstants = addrCounter; // Variables @@ -169,18 +204,18 @@ public class BTCACCT { final int addrCreatorAddress3 = addrCounter++; final int addrCreatorAddress4 = addrCounter++; - final int addrQortalRecipient1 = addrCounter++; - final int addrQortalRecipient2 = addrCounter++; - final int addrQortalRecipient3 = addrCounter++; - final int addrQortalRecipient4 = addrCounter++; + final int addrQortalPartnerAddress1 = addrCounter++; + final int addrQortalPartnerAddress2 = addrCounter++; + final int addrQortalPartnerAddress3 = addrCounter++; + final int addrQortalPartnerAddress4 = addrCounter++; final int addrLockTimeA = addrCounter++; final int addrLockTimeB = addrCounter++; final int addrRefundTimeout = addrCounter++; final int addrRefundTimestamp = addrCounter++; - final int addrLastTxTimestamp = addrCounter++; + final int addrLastTxnTimestamp = addrCounter++; final int addrBlockTimestamp = addrCounter++; - final int addrTxType = addrCounter++; + final int addrTxnType = addrCounter++; final int addrResult = addrCounter++; final int addrMessageSender1 = addrCounter++; @@ -196,13 +231,11 @@ public class BTCACCT { final int addrHashOfSecretA = addrCounter; addrCounter += 4; - final int addrRecipientBitcoinPKH = addrCounter; + final int addrPartnerBitcoinPKH = addrCounter; addrCounter += 4; final int addrMode = addrCounter++; - final int addrRedeemFlag = addrCounter++; - // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); @@ -232,16 +265,16 @@ public class BTCACCT { dataByteBuffer.putLong(tradeTimeout); // We're only interested in MESSAGE transactions - assert dataByteBuffer.position() == addrMessageTxType * MachineState.VALUE_SIZE : "addrMessageTxType incorrect"; + assert dataByteBuffer.position() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect"; dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value); - // Expected length of OFFER MESSAGE data from AT creator - assert dataByteBuffer.position() == addrExpectedOfferMessageLength * MachineState.VALUE_SIZE : "addrExpectedOfferMessageLength incorrect"; - dataByteBuffer.putLong(32L + 32L + 32L); - - // Expected length of TRADE MESSAGE data from trade partner / "recipient" + // Expected length of 'trade' MESSAGE data from AT creator assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect"; - dataByteBuffer.putLong(32L + 32L); + dataByteBuffer.putLong(TRADE_MESSAGE_LENGTH); + + // Expected length of 'redeem' MESSAGE data from trade partner + assert dataByteBuffer.position() == addrExpectedRedeemMessageLength * MachineState.VALUE_SIZE : "addrExpectedRedeemMessageLength incorrect"; + dataByteBuffer.putLong(REDEEM_MESSAGE_LENGTH); // Index into data segment of AT creator's address, used by GET_B_IND assert dataByteBuffer.position() == addrCreatorAddressPointer * MachineState.VALUE_SIZE : "addrCreatorAddressPointer incorrect"; @@ -252,56 +285,56 @@ public class BTCACCT { dataByteBuffer.putLong(addrHashOfSecretB); // Index into data segment of recipient address, used by SET_B_IND - assert dataByteBuffer.position() == addrQortalRecipientPointer * MachineState.VALUE_SIZE : "addrQortalRecipientPointer incorrect"; - dataByteBuffer.putLong(addrQortalRecipient1); + assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; + dataByteBuffer.putLong(addrQortalPartnerAddress1); // Index into data segment of (temporary) transaction's sender's address, used by GET_B_IND assert dataByteBuffer.position() == addrMessageSenderPointer * MachineState.VALUE_SIZE : "addrMessageSenderPointer incorrect"; dataByteBuffer.putLong(addrMessageSender1); - // Offset into OFFER MESSAGE data payload for extracting recipient's Bitcoin PKH - assert dataByteBuffer.position() == addrOfferMessageRecipientBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrOfferMessageRecipientBitcoinPKHOffset incorrect"; + // Offset into 'trade' MESSAGE data payload for extracting partner's Bitcoin PKH + assert dataByteBuffer.position() == addrTradeMessagePartnerBitcoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerBitcoinPKHOffset incorrect"; dataByteBuffer.putLong(32L); - // Index into data segment of recipient's Bitcoin PKH, used by GET_B_IND - assert dataByteBuffer.position() == addrRecipientBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrRecipientBitcoinPKHPointer incorrect"; - dataByteBuffer.putLong(addrRecipientBitcoinPKH); + // Index into data segment of partner's Bitcoin PKH, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerBitcoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerBitcoinPKHPointer incorrect"; + dataByteBuffer.putLong(addrPartnerBitcoinPKH); - // Offset into OFFER MESSAGE data payload for extracting hash-of-secret-A - assert dataByteBuffer.position() == addrOfferMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrOfferMessageHashOfSecretAOffset incorrect"; + // Offset into 'trade' MESSAGE data payload for extracting hash-of-secret-A + assert dataByteBuffer.position() == addrTradeMessageHashOfSecretAOffset * MachineState.VALUE_SIZE : "addrTradeMessageHashOfSecretAOffset incorrect"; dataByteBuffer.putLong(64L); - // Index into data segment of hash of secret A, used by GET_B_IND + // Index into data segment to hash of secret A, used by GET_B_IND assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect"; dataByteBuffer.putLong(addrHashOfSecretA); - // Offset into TRADE MESSAGE data payload for extracting secret-B - assert dataByteBuffer.position() == addrTradeMessageSecretBOffset * MachineState.VALUE_SIZE : "addrTradeMessageSecretBOffset incorrect"; + // Offset into 'redeem' MESSAGE data payload for extracting secret-B + assert dataByteBuffer.position() == addrRedeemMessageSecretBOffset * MachineState.VALUE_SIZE : "addrRedeemMessageSecretBOffset incorrect"; dataByteBuffer.putLong(32L); + // Offset into 'redeem' MESSAGE data payload for extracting Qortal receive address + assert dataByteBuffer.position() == addrRedeemMessageReceiveAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceiveAddressOffset incorrect"; + dataByteBuffer.putLong(64L); + // Source location and length for hashing any passed secret assert dataByteBuffer.position() == addrMessageDataPointer * MachineState.VALUE_SIZE : "addrMessageDataPointer incorrect"; dataByteBuffer.putLong(addrMessageData); assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; dataByteBuffer.putLong(32L); - // Bitcoin receive public key hash - assert dataByteBuffer.position() == addrBitcoinReceivePublicKeyHash * MachineState.VALUE_SIZE : "addrBitcoinReceivePublicKeyHash incorrect"; - dataByteBuffer.put(Bytes.ensureCapacity(bitcoinReceivePublicKeyHash, 32, 0)); - assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; // Code labels Integer labelRefund = null; - Integer labelOfferTxLoop = null; - Integer labelCheckOfferTx = null; + Integer labelTradeTxnLoop = null; + Integer labelCheckTradeTxn = null; - Integer labelCheckNonRefundOfferTx = null; - Integer labelOfferTxExtract = null; - Integer labelTradeTxLoop = null; - Integer labelCheckTradeTx = null; - Integer labelCheckTradeSender = null; + Integer labelCheckNonRefundTradeTxn = null; + Integer labelTradeTxnExtract = null; + Integer labelRedeemTxnLoop = null; + Integer labelCheckRedeemTxn = null; + Integer labelCheckRedeemTxnSender = null; Integer labelCheckSecretB = null; Integer labelPayout = null; @@ -315,7 +348,7 @@ public class BTCACCT { /* Initialization */ // Use AT creation 'timestamp' as starting point for finding transactions sent to AT - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxTimestamp)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_CREATION_TIMESTAMP, addrLastTxnTimestamp)); // Load B register with AT creator's address so we can save it into addrCreatorAddress1-4 codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); @@ -327,26 +360,26 @@ public class BTCACCT { /* Loop, waiting for message from AT creator's trade address containing trade partner details, or AT owner's address to cancel offer */ /* Transaction processing loop */ - labelOfferTxLoop = codeByteBuffer.position(); + labelTradeTxnLoop = codeByteBuffer.position(); - // Find next transaction to this AT since the last one (if any) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); - // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. + // Find next transaction (if any) to this AT since the last one (referenced by addrLastTxnTimestamp) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); + // If no transaction found, A will be zero. If A is zero, set addrResult to 1, otherwise 0. codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckOfferTx))); + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTxn))); // Stop and wait for next block codeByteBuffer.put(OpCode.STP_IMD.compile()); /* Check transaction */ - labelCheckOfferTx = codeByteBuffer.position(); + labelCheckTradeTxn = codeByteBuffer.position(); // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); - // Extract transaction type (message/payment) from transaction and save type in addrTxType - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); /* Check transaction's sender. We're expecting AT creator's trade address. */ @@ -355,45 +388,50 @@ public class BTCACCT { // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelOfferTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelOfferTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelTradeTxnLoop))); /* Extract trade partner info from message */ // Extract message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrQortalRecipient1 (as pointed to by addrQortalRecipientPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalRecipientPointer)); - // Compare each of recipient address with creator's address (for offer-cancel scenario). If they don't match, assume recipient is trade partner. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalRecipient4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelCheckNonRefundOfferTx))); - // Recipient address is AT creator's address, so cancel offer and finish. - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); + // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); + // Compare each of partner address with creator's address (for offer-cancel scenario). If they don't match, assume address is trade partner. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); + // Partner address is AT creator's address, so cancel offer and finish. + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.CANCELLED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); /* Possible switch-to-trade-mode message */ - labelCheckNonRefundOfferTx = codeByteBuffer.position(); + labelCheckNonRefundTradeTxn = codeByteBuffer.position(); - // Not off-cancel scenario so check we received expected number of message bytes + // Not offer-cancel scenario so check we received expected number of message bytes codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); - codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedOfferMessageLength, calcOffset(codeByteBuffer, labelOfferTxExtract))); - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelOfferTxLoop == null ? 0 : labelOfferTxLoop)); + // If message length matches, branch to info extraction code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); + // Message length didn't match - go back to finding another 'trade' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); - labelOfferTxExtract = codeByteBuffer.position(); + /* Extracting info from 'trade' MESSAGE transaction */ + labelTradeTxnExtract = codeByteBuffer.position(); // Message is expected length, grab next 32 bytes - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOfferMessageRecipientBitcoinPKHOffset)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset)); - // Extract recipient's Bitcoin PKH (we only really use values from B1-B3) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrRecipientBitcoinPKHPointer)); + // Extract partner's Bitcoin PKH (we only really use values from B1-B3) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerBitcoinPKHPointer)); // Also extract lockTimeB codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeB)); // Grab next 32 bytes - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrOfferMessageHashOfSecretAOffset)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageHashOfSecretAOffset)); // Extract hash-of-secret-a (we only really use values from B1-B3) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrHashOfSecretAPointer)); @@ -404,66 +442,67 @@ public class BTCACCT { codeByteBuffer.put(OpCode.SET_DAT.compile(addrRefundTimeout, addrLockTimeA)); // refundTimeout = lockTimeA codeByteBuffer.put(OpCode.SUB_DAT.compile(addrRefundTimeout, addrLockTimeB)); // refundTimeout -= lockTimeB codeByteBuffer.put(OpCode.DIV_VAL.compile(addrRefundTimeout, 2L * 60L)); // refundTimeout /= 2 * 60 - // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this tx 'timestamp', then save into addrRefundTimestamp - codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxTimestamp, addrRefundTimeout)); + // Calculate trade timeout refund 'timestamp' by adding addrRefundTimeout minutes to this transaction's 'timestamp', then save into addrRefundTimestamp + codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout)); /* We are in 'trade mode' */ - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, 1)); + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.TRADING.value)); // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); - /* Loop, waiting for trade timeout or message from Qortal trade recipient containing secret-a and secret-b */ + /* Loop, waiting for trade timeout or 'redeem' MESSAGE from Qortal trade partner */ // Fetch current block 'timestamp' codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_BLOCK_TIMESTAMP, addrBlockTimestamp)); // If we're not past refund 'timestamp' then look for next transaction - codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BLT_DAT.compile(addrBlockTimestamp, addrRefundTimestamp, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); // We're past refund 'timestamp' so go refund everything back to AT creator codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRefund == null ? 0 : labelRefund)); /* Transaction processing loop */ - labelTradeTxLoop = codeByteBuffer.position(); + labelRedeemTxnLoop = codeByteBuffer.position(); // Find next transaction to this AT since the last one (if any) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxTimestamp)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PUT_TX_AFTER_TIMESTAMP_INTO_A, addrLastTxnTimestamp)); // If no transaction found, A will be zero. If A is zero, set addrComparator to 1, otherwise 0. codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.CHECK_A_IS_ZERO, addrResult)); // If addrResult is zero (i.e. A is non-zero, transaction was found) then go check transaction - codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckTradeTx))); + codeByteBuffer.put(OpCode.BZR_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckRedeemTxn))); // Stop and wait for next block codeByteBuffer.put(OpCode.STP_IMD.compile()); /* Check transaction */ - labelCheckTradeTx = codeByteBuffer.position(); + labelCheckRedeemTxn = codeByteBuffer.position(); // Update our 'last found transaction's timestamp' using 'timestamp' from transaction - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxTimestamp)); - // Extract transaction type (message/payment) from transaction and save type in addrTxType - codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxType)); + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TIMESTAMP_FROM_TX_IN_A, addrLastTxnTimestamp)); + // Extract transaction type (message/payment) from transaction and save type in addrTxnType + codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_TYPE_FROM_TX_IN_A, addrTxnType)); // If transaction type is not MESSAGE type then go look for another transaction - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxType, addrMessageTxType, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); /* Check message payload length */ codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); - codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelCheckTradeSender))); - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelOfferTxLoop == null ? 0 : labelOfferTxLoop)); + // If message length matches, branch to sender checking code + codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedRedeemMessageLength, calcOffset(codeByteBuffer, labelCheckRedeemTxnSender))); + // Message length didn't match - go back to finding another 'redeem' MESSAGE transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); /* Check transaction's sender */ - - labelCheckTradeSender = codeByteBuffer.position(); + labelCheckRedeemTxnSender = codeByteBuffer.position(); // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalRecipient1, calcOffset(codeByteBuffer, labelTradeTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalRecipient2, calcOffset(codeByteBuffer, labelTradeTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalRecipient3, calcOffset(codeByteBuffer, labelTradeTxLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalRecipient4, calcOffset(codeByteBuffer, labelTradeTxLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrQortalPartnerAddress1, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrQortalPartnerAddress2, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrQortalPartnerAddress3, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrQortalPartnerAddress4, calcOffset(codeByteBuffer, labelRedeemTxnLoop))); - /* Check 'secret-a' in transaction's message */ + /* Check 'secret-A' in transaction's message */ // Extract secret-A from first 32 bytes of message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); @@ -476,14 +515,14 @@ public class BTCACCT { codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); // If hashes don't match, addrResult will be zero so go find another transaction codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelCheckSecretB))); - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxLoop == null ? 0 : labelTradeTxLoop)); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); - /* Check 'secret-b' in transaction's message */ + /* Check 'secret-B' in transaction's message */ labelCheckSecretB = codeByteBuffer.position(); // Extract secret-B from next 32 bytes of message from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessageSecretBOffset)); + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageSecretBOffset)); // Save B register into data segment starting at addrMessageData (as pointed to by addrMessageDataPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageDataPointer)); // Load B register with expected hash result (as pointed to by addrHashOfSecretBPointer) @@ -493,28 +532,28 @@ public class BTCACCT { codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.CHECK_HASH160_WITH_B, addrResult, addrMessageDataPointer, addrMessageDataLength)); // If hashes don't match, addrResult will be zero so go find another transaction codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); - codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxLoop == null ? 0 : labelTradeTxLoop)); + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); - /* Success! Pay arranged amount to intended recipient */ + /* Success! Pay arranged amount to receive address */ labelPayout = codeByteBuffer.position(); - // Load B register with intended recipient address (as pointed to by addrQortalRecipientPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.SET_B_IND, addrQortalRecipientPointer)); + // Extract Qortal receive address from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceiveAddressOffset)); // Pay AT's balance to recipient codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); - // Set redeem flag - codeByteBuffer.put(OpCode.INC_DAT.compile(addrRedeemFlag)); + // Set redeemed mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REDEEMED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) + codeByteBuffer.put(OpCode.FIN_IMD.compile()); // Fall-through to refunding any remaining balance back to AT creator /* Refund balance back to AT creator */ labelRefund = codeByteBuffer.position(); - // Load B register with AT creator's address. - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_CREATOR_INTO_B)); - // Pay AT's balance back to AT's creator. - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PAY_ALL_TO_ADDRESS_IN_B)); - // We're finished forever + // Set refunded mode + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REFUNDED.value)); + // We're finished forever (finishing auto-refunds remaining balance to AT creator) codeByteBuffer.put(OpCode.FIN_IMD.compile()); } catch (CompilationException e) { throw new IllegalStateException("Unable to compile BTC-QORT ACCT?", e); @@ -587,15 +626,16 @@ public class BTCACCT { // Expected BTC amount tradeData.expectedBitcoin = dataByteBuffer.getLong(); + // Trade timeout tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); // Skip MESSAGE transaction type dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip expected OFFER message length + // Skip expected 'trade' message length dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip expected TRADE message length + // Skip expected 'redeem' message length dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to creator's address @@ -604,25 +644,28 @@ public class BTCACCT { // Skip pointer to hash-of-secret-B dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip pointer to Qortal recipient + // Skip pointer to partner's Qortal trade address dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to message sender dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip OFFER message data offset for recipient's bitcoin PKH + // Skip 'trade' message data offset for recipient's bitcoin PKH dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip pointer to recipient's bitcoin PKH + // Skip pointer to partner's bitcoin PKH dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip OFFER message data offset for hash-of-secret-A + // Skip 'trade' message data offset for hash-of-secret-A dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to hash-of-secret-A dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip TRADE message data offset for secret-B + // Skip 'redeem' message data offset for secret-B + dataByteBuffer.position(dataByteBuffer.position() + 8); + + // Skip 'redeem' message data offset for partner's Qortal receive address dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to message data @@ -631,17 +674,12 @@ public class BTCACCT { // Skip message data length dataByteBuffer.position(dataByteBuffer.position() + 8); - // Creator's Bitcoin/foreign receiving public key hash - tradeData.creatorReceiveBitcoinPKH = new byte[20]; - dataByteBuffer.get(tradeData.creatorReceiveBitcoinPKH); - dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorReceiveBitcoinPKH.length); // skip to 32 bytes - /* End of constants / begin variables */ // Skip AT creator's address dataByteBuffer.position(dataByteBuffer.position() + 8 * 4); - // Recipient's trade address (if present) + // Partner's trade address (if present) dataByteBuffer.get(addressBytes); String qortalRecipient = Base58.encode(addressBytes); dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); @@ -655,7 +693,7 @@ public class BTCACCT { // AT refund timeout (probably only useful for debugging) int refundTimeout = (int) dataByteBuffer.getLong(); - // Trade offer timeout (AT 'timestamp' converted to Qortal block height) + // Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height) long tradeRefundTimestamp = dataByteBuffer.getLong(); // Skip last transaction timestamp @@ -684,60 +722,58 @@ public class BTCACCT { dataByteBuffer.get(hashOfSecretA); dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes - // Potential recipient's Bitcoin PKH + // Potential partner's Bitcoin PKH byte[] recipientBitcoinPKH = new byte[20]; dataByteBuffer.get(recipientBitcoinPKH); dataByteBuffer.position(dataByteBuffer.position() + 32 - recipientBitcoinPKH.length); // skip to 32 bytes - long mode = dataByteBuffer.getLong(); + long modeValue = dataByteBuffer.getLong(); + Mode mode = Mode.valueOf((int) (modeValue & 0xffL)); - long redeemFlag = dataByteBuffer.getLong(); - - if (mode != 0) { - tradeData.mode = CrossChainTradeData.Mode.TRADE; + if (mode != null && mode != Mode.OFFERING) { + tradeData.mode = mode; tradeData.refundTimeout = refundTimeout; tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; - tradeData.qortalRecipient = qortalRecipient; + tradeData.qortalPartnerAddress = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; - tradeData.recipientBitcoinPKH = recipientBitcoinPKH; + tradeData.partnerBitcoinPKH = recipientBitcoinPKH; tradeData.lockTimeA = lockTimeA; tradeData.lockTimeB = lockTimeB; - tradeData.hasRedeemed = redeemFlag != 0; } else { - tradeData.mode = CrossChainTradeData.Mode.OFFER; + tradeData.mode = Mode.OFFERING; } return tradeData; } - /** Returns trade-info MESSAGE payload for trade partner/recipient to send to AT creator's trade address. */ + /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ public static byte[] buildOfferMessage(byte[] recipientBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); return Bytes.concat(recipientBitcoinPKH, hashOfSecretA, lockTimeABytes); } - /** Returns trade-info extracted from MESSAGE payload sent by trade partner/recipient, or null if not valid. */ + /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ public static OfferMessageData extractOfferMessageData(byte[] messageData) { - if (messageData == null || messageData.length != 20 + 20 + 8) + if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH) return null; OfferMessageData offerMessageData = new OfferMessageData(); - offerMessageData.recipientBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20); + offerMessageData.partnerBitcoinPKH = Arrays.copyOfRange(messageData, 0, 20); offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40); offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40); return offerMessageData; } - /** Returns trade-info MESSAGE payload for AT creator to send to AT. */ - public static byte[] buildTradeMessage(String recipientQortalAddress, byte[] recipientBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) { - byte[] data = new byte[32 + 32 + 32]; - byte[] recipientQortalAddressBytes = Base58.decode(recipientQortalAddress); + /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ + public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) { + byte[] data = new byte[TRADE_MESSAGE_LENGTH]; + byte[] recipientQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); byte[] lockTimeBBytes = BitTwiddling.toBEByteArray((long) lockTimeB); System.arraycopy(recipientQortalAddressBytes, 0, data, 0, recipientQortalAddressBytes.length); - System.arraycopy(recipientBitcoinPKH, 0, data, 32, recipientBitcoinPKH.length); + System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); System.arraycopy(lockTimeBBytes, 0, data, 56, lockTimeBBytes.length); System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length); @@ -745,9 +781,9 @@ public class BTCACCT { return data; } - /** Returns refund MESSAGE payload for AT creator to cancel trade AT. */ - public static byte[] buildRefundMessage(String creatorQortalAddress) { - byte[] data = new byte[32]; + /** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */ + public static byte[] buildCancelMessage(String creatorQortalAddress) { + byte[] data = new byte[CANCEL_MESSAGE_LENGTH]; byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress); System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length); @@ -755,31 +791,33 @@ public class BTCACCT { return data; } - /** Returns redeem MESSAGE payload for trade partner/recipient to send to AT. */ - public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB) { - byte[] data = new byte[32 + 32]; + /** Returns 'redeem' MESSAGE payload for trade partner/ to send to AT. */ + public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB, String qortalReceiveAddress) { + byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; + byte[] qortalReceiveAddressBytes = Base58.decode(qortalReceiveAddress); System.arraycopy(secretA, 0, data, 0, secretA.length); System.arraycopy(secretB, 0, data, 32, secretB.length); + System.arraycopy(qortalReceiveAddressBytes, 0, data, 64, qortalReceiveAddressBytes.length); return data; } - /** Returns P2SH-B lockTime (epoch seconds) based on trade partner/recipient's MESSAGE timestamp and P2SH-A locktime. */ - public static int calcLockTimeB(long recipientMessageTimestamp, int lockTimeA) { - // lockTimeB is halfway between recipientMessageTimesamp and lockTimeA - return (int) ((lockTimeA + (recipientMessageTimestamp / 1000L)) / 2L); + /** Returns P2SH-B lockTime (epoch seconds) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */ + public static int calcLockTimeB(long offerMessageTimestamp, int lockTimeA) { + // lockTimeB is halfway between offerMessageTimesamp and lockTimeA + return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L); } public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException { String atAddress = crossChainTradeData.qortalAtAddress; - String redeemerAddress = crossChainTradeData.qortalRecipient; + String redeemerAddress = crossChainTradeData.qortalPartnerAddress; List messageTransactionsData = repository.getTransactionRepository().getMessagesByRecipient(atAddress, null, null, null); if (messageTransactionsData == null) return null; - // Find redeem message + // Find 'redeem' message for (MessageTransactionData messageTransactionData : messageTransactionsData) { // Check message payload type/encryption if (messageTransactionData.isText() || messageTransactionData.isEncrypted()) @@ -787,7 +825,7 @@ public class BTCACCT { // Check message payload size byte[] messageData = messageTransactionData.getData(); - if (messageData.length != 32 + 32) + if (messageData.length != REDEEM_MESSAGE_LENGTH) // Wrong payload length continue; diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index 68a6124e..c5ffea39 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -6,6 +6,7 @@ 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; @@ -13,8 +14,6 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeData { - public enum Mode { OFFER, TRADE }; - // Properties @Schema(description = "AT's Qortal address") @@ -29,9 +28,6 @@ public class CrossChainTradeData { @Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)") public byte[] creatorBitcoinPKH; - @Schema(description = "AT creator's Bitcoin receiving public-key-hash (PKH)") - public byte[] creatorReceiveBitcoinPKH; - @Schema(description = "Timestamp when AT was created (milliseconds since epoch)") public long creationTimestamp; @@ -53,7 +49,7 @@ public class CrossChainTradeData { 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; @@ -68,7 +64,7 @@ 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-A nLockTime based on trade timeout") public Integer lockTimeA; @@ -77,10 +73,7 @@ public class CrossChainTradeData { public Integer lockTimeB; @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)") - public byte[] recipientBitcoinPKH; - - @Schema(description = "Whether AT has paid out to trade partner") - public Boolean hasRedeemed; + public byte[] partnerBitcoinPKH; // Constructors @@ -102,20 +95,10 @@ public class CrossChainTradeData { @XmlElement(name = "recipientBitcoinAddress") @Schema(description = "Trade partner's trading Bitcoin PKH in address form") public String getRecipientBitcoinAddress() { - if (this.recipientBitcoinPKH == null) + if (this.partnerBitcoinPKH == null) return null; - return BTC.getInstance().pkhToAddress(this.recipientBitcoinPKH); - } - - // We can represent BitcoinPKH as an address - @XmlElement(name = "creatorBitcoinReceivingAddress") - @Schema(description = "AT creator's Bitcoin receiving address") - public String getCreatorBitcoinReceivingAddress() { - if (this.creatorReceiveBitcoinPKH == null) - return null; - - return BTC.getInstance().pkhToAddress(this.creatorReceiveBitcoinPKH); + return BTC.getInstance().pkhToAddress(this.partnerBitcoinPKH); } } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 2e935ee5..87dfde21 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -48,7 +48,7 @@ public class TradeBotData { private long bitcoinAmount; - // Never expose this + // Never expose this via API @XmlTransient @Schema(hidden = true) private String xprv58; @@ -57,6 +57,9 @@ public class TradeBotData { private Integer lockTimeA; + // Could be Bitcoin or Qortal... + private byte[] receivingPublicKeyHash; + protected TradeBotData() { /* JAXB */ } @@ -65,7 +68,7 @@ public class TradeBotData { byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, byte[] secret, byte[] hashOfSecret, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA) { + long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingPublicKeyHash) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; this.atAddress = atAddress; @@ -80,6 +83,7 @@ public class TradeBotData { this.xprv58 = xprv58; this.lastTransactionSignature = lastTransactionSignature; this.lockTimeA = lockTimeA; + this.receivingPublicKeyHash = receivingPublicKeyHash; } public byte[] getTradePrivateKey() { @@ -154,4 +158,8 @@ public class TradeBotData { this.lockTimeA = lockTimeA; } + public byte[] getReceivingPublicKeyHash() { + return this.receivingPublicKeyHash; + } + } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 3c30444e..23fe2801 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -23,7 +23,7 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { + "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 " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_public_key_hash " + "FROM TradeBotStates " + "WHERE trade_private_key = ?"; @@ -50,13 +50,14 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { Integer lockTimeA = resultSet.getInt(13); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; + byte[] receivingPublicKeyHash = resultSet.getBytes(14); return new TradeBotData(tradePrivateKey, tradeState, atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA); + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingPublicKeyHash); } catch (SQLException e) { throw new DataException("Unable to fetch trade-bot trading state from repository", e); } @@ -68,7 +69,7 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { + "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 " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_public_key_hash " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -98,13 +99,14 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { Integer lockTimeA = resultSet.getInt(14); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; + byte[] receivingPublicKeyHash = resultSet.getBytes(15); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA); + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingPublicKeyHash); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -130,7 +132,8 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { .bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) .bind("xprv58", tradeBotData.getXprv58()) .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) - .bind("locktime_a", tradeBotData.getLockTimeA()); + .bind("locktime_a", tradeBotData.getLockTimeA()) + .bind("receiving_public_key_hash", tradeBotData.getReceivingPublicKeyHash()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 3ea10454..11e5a6a0 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -626,7 +626,7 @@ public class HSQLDBDatabaseUpdates { + "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, " - + "PRIMARY KEY (trade_private_key))"); + + "receiving_public_key_hash VARBINARY(32) NOT NULL, PRIMARY KEY (trade_private_key))"); break; default: diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index c5150daa..79a67c9b 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -19,7 +19,6 @@ import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.block.Block; -import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -65,7 +64,7 @@ public class AtTests extends Common { public void testCompile() { Account deployer = Common.getTestAccount(null, "chloe"); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout, bitcoinReceivePublicKeyHash); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -73,10 +72,10 @@ public class AtTests extends Common { public void testDeploy() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); @@ -90,10 +89,10 @@ public class AtTests extends Common { assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance); - expectedBalance = recipientsInitialBalance; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipient's post-deployment balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance); // Test orphaning BlockUtils.orphanLastBlock(repository); @@ -108,10 +107,10 @@ public class AtTests extends Common { assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); - expectedBalance = recipientsInitialBalance; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + expectedBalance = partnersInitialBalance; + actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipient's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance); } } @@ -120,10 +119,10 @@ public class AtTests extends Common { public void testOfferCancel() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); Account at = deployAtTransaction.getATAccount(); @@ -132,12 +131,12 @@ public class AtTests extends Common { long deployAtFee = deployAtTransaction.getTransactionData().getFee(); long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; - // Send creator's address to AT - byte[] recipientAddressBytes = Bytes.ensureCapacity(Base58.decode(deployer.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, recipientAddressBytes, atAddress); + // Send creator's address to AT, instead of typical partner's address + byte[] partnerAddressBytes = Bytes.ensureCapacity(Base58.decode(deployer.getAddress()), 32, 0); + MessageTransaction messageTransaction = sendMessage(repository, deployer, partnerAddressBytes, atAddress); long messageFee = messageTransaction.getTransactionData().getFee(); - // Refund should happen 1st block after receiving recipient address + // Refund should happen 1st block after receiving 'cancel' message BlockUtils.mintBlock(repository); long expectedMinimumBalance = deployersPostDeploymentBalance; @@ -154,6 +153,10 @@ public class AtTests extends Common { ATData atData = repository.getATRepository().fromATAddress(atAddress); assertTrue(atData.getIsFinished()); + // AT should be in CANCELLED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); + // Test orphaning BlockUtils.orphanLastBlock(repository); @@ -169,21 +172,21 @@ public class AtTests extends Common { public void testTradingInfoProcessing() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - long recipientMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // Send trade info to AT - byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); Block postDeploymentBlock = BlockUtils.mintBlock(repository); @@ -199,16 +202,16 @@ public class AtTests extends Common { CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); // AT should be in TRADE mode - assertEquals(CrossChainTradeData.Mode.TRADE, tradeData.mode); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); // Check hashOfSecretA was extracted correctly assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); - // Check trade partner/recipient Qortal address was extracted correctly - assertEquals(recipient.getAddress(), tradeData.qortalRecipient); + // Check trade partner Qortal address was extracted correctly + assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); - // Check trade partner/recipient's Bitcoin PKH was extracted correctly - assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.recipientBitcoinPKH)); + // Check trade partner's Bitcoin PKH was extracted correctly + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerBitcoinPKH)); // Test orphaning BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); @@ -226,30 +229,30 @@ public class AtTests extends Common { public void testIncorrectTradeSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - long recipientMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // Send trade info to AT BUT NOT FROM AT CREATOR - byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress); BlockUtils.mintBlock(repository); - long expectedBalance = recipientsInitialBalance; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipient's post-initial-payout balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance); describeAt(repository, atAddress); @@ -257,7 +260,7 @@ public class AtTests extends Common { CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); // AT should still be in OFFER mode - assertEquals(CrossChainTradeData.Mode.OFFER, tradeData.mode); + assertEquals(BTCACCT.Mode.OFFERING, tradeData.mode); } } @@ -266,21 +269,21 @@ public class AtTests extends Common { public void testAutomaticTradeRefund() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - long recipientMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // Send trade info to AT - byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); Block postDeploymentBlock = BlockUtils.mintBlock(repository); @@ -298,6 +301,10 @@ public class AtTests extends Common { ATData atData = repository.getATRepository().fromATAddress(atAddress); assertTrue(atData.getIsFinished()); + // AT should be in REFUNDED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.REFUNDED, tradeData.mode); + // Test orphaning BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); @@ -313,29 +320,29 @@ public class AtTests extends Common { public void testCorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - long recipientMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // Send trade info to AT - byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); // Send correct secrets to AT, from correct account - messageData = BTCACCT.buildRedeemMessage(secretA, secretB); - messageTransaction = sendMessage(repository, recipient, messageData, atAddress); + messageData = BTCACCT.buildRedeemMessage(secretA, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); // AT should send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -347,18 +354,22 @@ public class AtTests extends Common { ATData atData = repository.getATRepository().fromATAddress(atAddress); assertTrue(atData.getIsFinished()); - long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); + // AT should be in REDEEMED mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.REDEEMED, tradeData.mode); - assertEquals("Recipent's post-redeem balance incorrect", expectedBalance, actualBalance); + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's post-redeem balance incorrect", expectedBalance, actualBalance); // Orphan redeem BlockUtils.orphanLastBlock(repository); - expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee(); - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + actualBalance = partner.getConfirmedBalance(Asset.QORT); - assertEquals("Recipent's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); + assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance); // Check AT state ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -372,11 +383,11 @@ public class AtTests extends Common { public void testCorrectSecretsIncorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); @@ -384,19 +395,19 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - long recipientMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // Send trade info to AT - byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); // Send correct secrets to AT, but from wrong account - messageData = BTCACCT.buildRedeemMessage(secretA, secretB); + messageData = BTCACCT.buildRedeemMessage(secretA, secretB, partner.getAddress()); messageTransaction = sendMessage(repository, bystander, messageData, atAddress); // AT should NOT send funds in the next block @@ -409,10 +420,14 @@ public class AtTests extends Common { ATData atData = repository.getATRepository().fromATAddress(atAddress); assertFalse(atData.getIsFinished()); - long expectedBalance = recipientsInitialBalance; - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); - assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + long expectedBalance = partnersInitialBalance; + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } @@ -423,10 +438,10 @@ public class AtTests extends Common { public void testIncorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); @@ -434,12 +449,12 @@ public class AtTests extends Common { Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); - long recipientMessageTransactionTimestamp = System.currentTimeMillis(); - int lockTimeA = calcTestLockTimeA(recipientMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTransactionTimestamp, lockTimeA); + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // Send trade info to AT - byte[] messageData = BTCACCT.buildTradeMessage(recipient.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); // Give AT time to process message @@ -449,8 +464,8 @@ public class AtTests extends Common { byte[] wrongSecret = new byte[32]; Random random = new Random(); random.nextBytes(wrongSecret); - messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB); - messageTransaction = sendMessage(repository, recipient, messageData, atAddress); + messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); // AT should NOT send funds in the next block ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); @@ -462,14 +477,18 @@ public class AtTests extends Common { ATData atData = repository.getATRepository().fromATAddress(atAddress); assertFalse(atData.getIsFinished()); - long expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee(); - long actualBalance = recipient.getConfirmedBalance(Asset.QORT); + // AT should still be in TRADE mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); - assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); + long actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); // Send incorrect secrets to AT, from correct account - messageData = BTCACCT.buildRedeemMessage(secretA, wrongSecret); - messageTransaction = sendMessage(repository, recipient, messageData, atAddress); + messageData = BTCACCT.buildRedeemMessage(secretA, wrongSecret, partner.getAddress()); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); // AT should NOT send funds in the next block BlockUtils.mintBlock(repository); @@ -480,10 +499,14 @@ public class AtTests extends Common { atData = repository.getATRepository().fromATAddress(atAddress); assertFalse(atData.getIsFinished()); - expectedBalance = recipientsInitialBalance - messageTransaction.getTransactionData().getFee() * 2; - actualBalance = recipient.getConfirmedBalance(Asset.QORT); + // AT should still be in TRADE mode + tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); - assertEquals("Recipent's balance incorrect", expectedBalance, actualBalance); + expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2; + actualBalance = partner.getConfirmedBalance(Asset.QORT); + + assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } @@ -494,10 +517,10 @@ public class AtTests extends Common { public void testDescribeDeployed() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); - PrivateKeyAccount recipient = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); - long recipientsInitialBalance = recipient.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); @@ -528,7 +551,7 @@ public class AtTests extends Common { } private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout, bitcoinReceivePublicKeyHash); + byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -611,6 +634,7 @@ public class AtTests extends Common { int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); System.out.print(String.format("%s:\n" + + "\tmode: %s\n" + "\tcreator: %s,\n" + "\tcreation timestamp: %s,\n" + "\tcurrent balance: %s QORT,\n" @@ -618,9 +642,9 @@ public class AtTests extends Common { + "\tHASH160 of secret-B: %s,\n" + "\tredeem payout: %s QORT,\n" + "\texpected bitcoin: %s BTC,\n" - + "\treceiving bitcoin address: %s,\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, + tradeData.mode.name(), tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), @@ -628,26 +652,19 @@ public class AtTests extends Common { HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), Amounts.prettyAmount(tradeData.expectedBitcoin), - BTC.getInstance().pkhToAddress(tradeData.creatorReceiveBitcoinPKH), currentBlockHeight)); - // Are we in 'offer' or 'trade' stage? - if (tradeData.tradeRefundHeight == null) { - // Offer - System.out.println(String.format("\tstatus: 'offer mode'")); - } else { - // Trade - System.out.println(String.format("\tstatus: 'trade mode',\n" - + "\trefund height: block %d,\n" + if (tradeData.mode != BTCACCT.Mode.OFFERING && tradeData.mode != BTCACCT.Mode.CANCELLED) { + System.out.println(String.format("\trefund height: block %d,\n" + "\tHASH160 of secret-A: %s,\n" + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" + "\tBitcoin P2SH-B nLockTime: %d (%s),\n" - + "\ttrade recipient: %s", + + "\ttrade partner: %s", tradeData.tradeRefundHeight, HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40), tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L), tradeData.lockTimeB, epochMilliFormatter.apply(tradeData.lockTimeB * 1000L), - tradeData.qortalRecipient)); + tradeData.qortalPartnerAddress)); } } diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/btcacct/DeployAT.java index 4f33353d..ef5a0295 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/btcacct/DeployAT.java @@ -2,13 +2,10 @@ package org.qortal.test.btcacct; import java.security.Security; -import org.bitcoinj.core.Address; -import org.bitcoinj.script.Script.ScriptType; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.controller.Controller; -import org.qortal.crosschain.BTC; import org.qortal.crosschain.BTCACCT; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; @@ -37,7 +34,7 @@ public class DeployAT { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); System.err.println(String.format("example: DeployAT " + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" + "\t80.4020 \\\n" @@ -45,13 +42,12 @@ public class DeployAT { + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" + "\t123.456 \\\n" - + "\t10080 \\\n" - + "\tn2iQZCtKZ5SrFDJENGJkd4RpAuQp3SEoix")); + + "\t10080")); System.exit(1); } public static void main(String[] args) { - if (args.length != 8) + if (args.length != 7) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); @@ -64,7 +60,6 @@ public class DeployAT { byte[] secretHash = null; long fundingAmount = 0; int tradeTimeout = 0; - byte[] bitcoinReceivePublicKeyHash = null; int argIndex = 0; try { @@ -95,12 +90,6 @@ public class DeployAT { tradeTimeout = Integer.parseInt(args[argIndex++]); if (tradeTimeout < 60 || tradeTimeout > 50000) usage("Trade timeout (minutes) must be between 60 and 50000"); - - Address receiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), args[argIndex++]); - if (receiveAddress.getOutputScriptType() != ScriptType.P2PKH) - usage("Bitcoin receive address must be P2PKH form"); - - bitcoinReceivePublicKeyHash = receiveAddress.getHash(); } catch (IllegalArgumentException e) { usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage())); } @@ -125,7 +114,7 @@ public class DeployAT { System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout, bitcoinReceivePublicKeyHash); + byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); From e9c85c946e6b8db8ebf9823b623ff073177c4233 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 3 Aug 2020 10:49:47 +0100 Subject: [PATCH 32/51] WIP: trade-bot: two more unit tests to cover some edge cases --- .../java/org/qortal/test/btcacct/AtTests.java | 96 +++++++++++++++++-- 1 file changed, 90 insertions(+), 6 deletions(-) diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index 79a67c9b..b271075c 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -12,7 +12,6 @@ import java.util.List; import java.util.Random; import java.util.function.Function; -import org.bitcoinj.core.Base58; import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; @@ -55,6 +54,8 @@ public class AtTests extends Common { public static final long bitcoinAmount = 864200L; public static final byte[] bitcoinReceivePublicKeyHash = HashCode.fromString("00112233445566778899aabbccddeeff").asBytes(); + private static final Random RANDOM = new Random(); + @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); @@ -132,11 +133,11 @@ public class AtTests extends Common { long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; // Send creator's address to AT, instead of typical partner's address - byte[] partnerAddressBytes = Bytes.ensureCapacity(Base58.decode(deployer.getAddress()), 32, 0); - MessageTransaction messageTransaction = sendMessage(repository, deployer, partnerAddressBytes, atAddress); + byte[] messageData = BTCACCT.buildCancelMessage(deployer.getAddress()); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); long messageFee = messageTransaction.getTransactionData().getFee(); - // Refund should happen 1st block after receiving 'cancel' message + // AT should process 'cancel' message in next block BlockUtils.mintBlock(repository); long expectedMinimumBalance = deployersPostDeploymentBalance; @@ -167,6 +168,45 @@ public class AtTests extends Common { } } + @SuppressWarnings("unused") + @Test + public void testOfferCancelInvalidLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long deployAtFee = deployAtTransaction.getTransactionData().getFee(); + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; + + // Instead of sending creator's address to AT, send too-short/invalid message + byte[] messageData = new byte[7]; + RANDOM.nextBytes(messageData); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + long messageFee = messageTransaction.getTransactionData().getFee(); + + // AT should process 'cancel' message in next block + // As message is too short, it will be padded to 32bytes and (probably) contain incorrect sender to be valid cancel + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should still be in OFFERING mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.OFFERING, tradeData.mode); + } + } + @SuppressWarnings("unused") @Test public void testTradingInfoProcessing() throws DataException { @@ -462,8 +502,7 @@ public class AtTests extends Common { // Send incorrect secrets to AT, from correct account byte[] wrongSecret = new byte[32]; - Random random = new Random(); - random.nextBytes(wrongSecret); + RANDOM.nextBytes(wrongSecret); messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB, partner.getAddress()); messageTransaction = sendMessage(repository, partner, messageData, atAddress); @@ -512,6 +551,51 @@ public class AtTests extends Common { } } + @SuppressWarnings("unused") + @Test + public void testCorrectSecretsCorrectSenderInvalidMessageLength() throws DataException { + try (final Repository repository = RepositoryManager.getRepository()) { + PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + + long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); + long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); + + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + Account at = deployAtTransaction.getATAccount(); + String atAddress = at.getAddress(); + + long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); + int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); + int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + + // Send trade info to AT + byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + + // Give AT time to process message + BlockUtils.mintBlock(repository); + + // Send correct secrets to AT, from correct account, but missing receive address, hence incorrect length + messageData = Bytes.concat(secretA, secretB); + messageTransaction = sendMessage(repository, partner, messageData, atAddress); + + // AT should NOT send funds in the next block + ATStateData preRedeemAtStateData = repository.getATRepository().getLatestATState(atAddress); + BlockUtils.mintBlock(repository); + + describeAt(repository, atAddress); + + // Check AT is NOT finished + ATData atData = repository.getATRepository().fromATAddress(atAddress); + assertFalse(atData.getIsFinished()); + + // AT should be in TRADING mode + CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + } + } + @SuppressWarnings("unused") @Test public void testDescribeDeployed() throws DataException { From ea9b0d458888640b00258fbb6152ede5715de749 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 3 Aug 2020 14:54:45 +0100 Subject: [PATCH 33/51] WIP: trade-bot: initial API call for listing completed trades Also: renamed trade bot field/column "receiving_public_key_hash" to "receiving_account_info" as Alice's trade bot uses it to store Alice's Qortal address, not PKH. Added some extra simplistic repository calls to support above, like BlockRepository.getTimestampFromHeight, ATRepository.getCreatorPublicKey(atAddress) --- .../api/model/CrossChainTradeSummary.java | 43 +++++++++++ .../api/resource/CrossChainResource.java | 51 ++++++++++++- .../java/org/qortal/controller/TradeBot.java | 6 +- .../java/org/qortal/crosschain/BTCACCT.java | 38 ++++++++-- .../qortal/data/crosschain/TradeBotData.java | 10 +-- .../org/qortal/repository/ATRepository.java | 21 +++++ .../qortal/repository/BlockRepository.java | 9 +++ .../repository/hsqldb/HSQLDBATRepository.java | 76 +++++++++++++++++++ .../hsqldb/HSQLDBBlockRepository.java | 14 ++++ .../hsqldb/HSQLDBCrossChainRepository.java | 14 ++-- .../hsqldb/HSQLDBDatabaseUpdates.java | 14 +++- 11 files changed, 274 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/CrossChainTradeSummary.java diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java new file mode 100644 index 00000000..52ac7de3 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java @@ -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; + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index dd77ed3b..efe86f23 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -43,6 +43,7 @@ import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; import org.qortal.api.model.CrossChainSecretRequest; import org.qortal.api.model.CrossChainTradeRequest; +import org.qortal.api.model.CrossChainTradeSummary; import org.qortal.api.model.TradeBotCreateRequest; import org.qortal.api.model.TradeBotRespondRequest; import org.qortal.api.model.CrossChainBitcoinP2SHStatus; @@ -57,6 +58,7 @@ import org.qortal.crosschain.BTCACCT; import org.qortal.crosschain.BTCP2SH; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; +import org.qortal.data.at.ATStateData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; @@ -92,7 +94,6 @@ public class CrossChainResource { summary = "Find cross-chain trade offers", responses = { @ApiResponse( - description = "automated transactions", content = @Content( array = @ArraySchema( schema = @Schema( @@ -1099,6 +1100,54 @@ public class CrossChainResource { } } + @GET + @Path("/trades") + @Operation( + summary = "Find completed cross-chain trades", + description = "Returns summary info about successfully completed cross-chain trades", + responses = { + @ApiResponse( + content = @Content( + array = @ArraySchema( + schema = @Schema( + implementation = CrossChainTradeSummary.class + ) + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public List getCompletedTrades( + @Parameter( ref = "limit") @QueryParam("limit") Integer limit, + @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, + @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { + // Impose a limit on 'limit' + if (limit != null && limit > 100) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + byte[] codeHash = BTCACCT.CODE_BYTES_HASH; + + 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 crossChainTrades = new ArrayList<>(); + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState); + + // We also need block timestamp for use as trade timestamp + long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp); + crossChainTrades.add(crossChainTradeSummary); + } + + return crossChainTrades; + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + private ATData fetchAtDataWithChecking(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); if (atData == null) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 8c085256..8286e843 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -718,7 +718,7 @@ public class TradeBot { Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - byte[] receivePublicKeyHash = tradeBotData.getReceivingPublicKeyHash(); + byte[] receivePublicKeyHash = tradeBotData.getReceivingAccountInfo(); Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivePublicKeyHash); @@ -787,7 +787,7 @@ public class TradeBot { // Send 'redeem' MESSAGE to AT using both secrets byte[] secretA = tradeBotData.getSecret(); - String qortalReceiveAddress = Base58.encode(tradeBotData.getReceivingPublicKeyHash()); // Actually contains whole address, not just PKH + String qortalReceiveAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceiveAddress); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); @@ -873,7 +873,7 @@ public class TradeBot { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - byte[] receivePublicKeyHash = tradeBotData.getReceivingPublicKeyHash(); + byte[] receivePublicKeyHash = tradeBotData.getReceivingAccountInfo(); Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivePublicKeyHash); diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 8e3312c6..8f9948d9 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -108,6 +108,11 @@ public class BTCACCT { public static final int MIN_LOCKTIME = 1500000000; public static final byte[] CODE_BYTES_HASH = HashCode.fromString("fad14381b77ae1a2bfe7e16a1a8b571839c5f405fca0490ead08499ac170f65b").asBytes(); // SHA256 of AT code bytes + /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ + private static final int MODE_VALUE_OFFSET = 63; + /** Byte offset into AT state data where 'mode' variable (long) is stored. */ + public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); + public static class OfferMessageData { public byte[] partnerBitcoinPKH; public byte[] hashOfSecretA; @@ -235,6 +240,7 @@ public class BTCACCT { addrCounter += 4; final int addrMode = addrCounter++; + assert addrMode == MODE_VALUE_OFFSET : "MODE_VALUE_OFFSET does not match addrMode"; // Data segment ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE); @@ -584,18 +590,40 @@ public class BTCACCT { * @throws DataException */ public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { - String atAddress = atData.getATAddress(); + ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); + return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); + } - ATStateData atStateData = repository.getATRepository().getLatestATState(atAddress); - byte[] stateData = atStateData.getStateData(); + /** + * Returns CrossChainTradeData with useful info extracted from AT. + * + * @param repository + * @param atAddress + * @throws DataException + */ + public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException { + byte[] creatorPublicKey = repository.getATRepository().getCreatorPublicKey(atStateData.getATAddress()); + return populateTradeData(repository, creatorPublicKey, atStateData); + } + + /** + * Returns CrossChainTradeData with useful info extracted from AT. + * + * @param repository + * @param atAddress + * @throws DataException + */ + public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException { + String atAddress = atStateData.getATAddress(); QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); + byte[] stateData = atStateData.getStateData(); byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); CrossChainTradeData tradeData = new CrossChainTradeData(); tradeData.qortalAtAddress = atAddress; - tradeData.qortalCreator = Crypto.toAddress(atData.getCreatorPublicKey()); - tradeData.creationTimestamp = atData.getCreation(); + tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); + tradeData.creationTimestamp = atStateData.getCreation(); Account atAccount = new Account(repository, atAddress); tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 87dfde21..fdfa9926 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -58,7 +58,7 @@ public class TradeBotData { private Integer lockTimeA; // Could be Bitcoin or Qortal... - private byte[] receivingPublicKeyHash; + private byte[] receivingAccountInfo; protected TradeBotData() { /* JAXB */ @@ -68,7 +68,7 @@ public class TradeBotData { byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, byte[] secret, byte[] hashOfSecret, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingPublicKeyHash) { + long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { this.tradePrivateKey = tradePrivateKey; this.tradeState = tradeState; this.atAddress = atAddress; @@ -83,7 +83,7 @@ public class TradeBotData { this.xprv58 = xprv58; this.lastTransactionSignature = lastTransactionSignature; this.lockTimeA = lockTimeA; - this.receivingPublicKeyHash = receivingPublicKeyHash; + this.receivingAccountInfo = receivingAccountInfo; } public byte[] getTradePrivateKey() { @@ -158,8 +158,8 @@ public class TradeBotData { this.lockTimeA = lockTimeA; } - public byte[] getReceivingPublicKeyHash() { - return this.receivingPublicKeyHash; + public byte[] getReceivingAccountInfo() { + return this.receivingAccountInfo; } } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 887672d8..05d9fb21 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -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 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). + *

    + * If searching for specific data segment value, both dataByteOffset + * and expectedValue need to be non-null. + *

    + * Note that dataByteOffset starts from 0 and will typically be + * a multiple of MachineState.VALUE_SIZE, which is usually 8: + * width of a long. + *

    + * 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, + Integer limit, Integer offset, Boolean reverse) throws DataException; + /** * Returns all ATStateData for a given block height. *

    diff --git a/src/main/java/org/qortal/repository/BlockRepository.java b/src/main/java/org/qortal/repository/BlockRepository.java index 24372212..d7671f28 100644 --- a/src/main/java/org/qortal/repository/BlockRepository.java +++ b/src/main/java/org/qortal/repository/BlockRepository.java @@ -60,6 +60,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. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 808cc44d..3318d715 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -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 getAllExecutableATs() throws DataException { String sql = "SELECT AT_address, creator, created_when, version, asset_id, code_bytes, code_hash, " @@ -273,6 +287,68 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public List getMatchingFinalATStates(byte[] codeHash, + Integer dataByteOffset, Long expectedValue, + 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 = ? AND is_finished "); + + Object[] bindParams; + + 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 = new Object[] { codeHash, dataByteOffset + 1, expectedHexValue }; + } else { + bindParams = new Object[] { codeHash }; + } + + sql.append(" ORDER BY height "); + if (reverse != null && reverse) + sql.append("DESC"); + + HSQLDBRepository.limitOffsetSql(sql, limit, offset); + + List atStates = new ArrayList<>(); + + try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams)) { + 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 getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java index 0860e1b1..a6ef51c4 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBBlockRepository.java @@ -131,6 +131,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"; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 23fe2801..0eb1ef00 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -23,7 +23,7 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { + "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_public_key_hash " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info " + "FROM TradeBotStates " + "WHERE trade_private_key = ?"; @@ -50,14 +50,14 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { Integer lockTimeA = resultSet.getInt(13); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; - byte[] receivingPublicKeyHash = resultSet.getBytes(14); + byte[] receivingAccountInfo = resultSet.getBytes(14); return new TradeBotData(tradePrivateKey, tradeState, atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingPublicKeyHash); + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); } catch (SQLException e) { throw new DataException("Unable to fetch trade-bot trading state from repository", e); } @@ -69,7 +69,7 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { + "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_public_key_hash " + + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -99,14 +99,14 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { Integer lockTimeA = resultSet.getInt(14); if (lockTimeA == 0 && resultSet.wasNull()) lockTimeA = null; - byte[] receivingPublicKeyHash = resultSet.getBytes(15); + byte[] receivingAccountInfo = resultSet.getBytes(15); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, atAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingPublicKeyHash); + bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -133,7 +133,7 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { .bind("xprv58", tradeBotData.getXprv58()) .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) .bind("locktime_a", tradeBotData.getLockTimeA()) - .bind("receiving_public_key_hash", tradeBotData.getReceivingPublicKeyHash()); + .bind("receiving_account_info", tradeBotData.getReceivingAccountInfo()); try { saveHelper.execute(this.repository); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 11e5a6a0..2706bb5d 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -626,7 +626,19 @@ public class HSQLDBDatabaseUpdates { + "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_public_key_hash VARBINARY(32) NOT NULL, PRIMARY KEY (trade_private_key))"); + + "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; + + case 22: + // XXX for testing only - do not merge in 'master' + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN IF NOT EXISTS receiving_public_key_hash VARBINARY(32)"); + stmt.execute("ALTER TABLE TradeBotStates DROP COLUMN receiving_public_key_hash"); + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN IF NOT EXISTS receiving_account_info VARBINARY(32)"); break; default: From a3517568831d0e68276046b6ac03972647c36df3 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 3 Aug 2020 15:52:17 +0100 Subject: [PATCH 34/51] WIP: trade-bot: add missing JavaTypeAdapter to TradeBotData.bitcoinAmount Also: unify "receiveAddress" to "receivingAddress" Fix missed changes from "recipient" to "partner" in BTCACCT, etc. --- .../model/CrossChainBitcoinRedeemRequest.java | 2 +- .../api/model/TradeBotCreateRequest.java | 2 +- .../api/resource/CrossChainResource.java | 14 +++--- .../java/org/qortal/controller/TradeBot.java | 36 +++++++------- .../java/org/qortal/crosschain/BTCACCT.java | 48 +++++++++---------- .../java/org/qortal/crosschain/BTCP2SH.java | 6 +-- .../qortal/data/crosschain/TradeBotData.java | 2 + 7 files changed, 56 insertions(+), 54 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java index 68129a3c..074fd24d 100644 --- a/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinRedeemRequest.java @@ -26,7 +26,7 @@ public class CrossChainBitcoinRedeemRequest { public byte[] secret; @Schema(description = "Bitcoin HASH160(public key) for receiving funds, or omit to derive from private key", example = "u17kBVKkKSp12oUzaxFwNnq1JZf") - public byte[] receivePublicKeyHash; + public byte[] receivingAccountInfo; public CrossChainBitcoinRedeemRequest() { } diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java index 386715bd..adc319e3 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java @@ -28,7 +28,7 @@ public class TradeBotCreateRequest { public int tradeTimeout; @Schema(description = "Bitcoin address for receiving", example = "1NCTG9oLk41bU6pcehLNo9DVJup77EHAVx") - public String receiveAddress; + public String receivingAddress; public TradeBotCreateRequest() { } diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index efe86f23..fd7853c8 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -853,10 +853,10 @@ public class CrossChainResource { if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.SECRET_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - if (redeemRequest.receivePublicKeyHash == null) - redeemRequest.receivePublicKeyHash = redeemKey.getPubKeyHash(); + if (redeemRequest.receivingAccountInfo == null) + redeemRequest.receivingAccountInfo = redeemKey.getPubKeyHash(); - if (redeemRequest.receivePublicKeyHash.length != 20) + if (redeemRequest.receivingAccountInfo.length != 20) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); // Extract data from cross-chain trading AT @@ -899,7 +899,7 @@ public class CrossChainResource { Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); - org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivePublicKeyHash); + org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivingAccountInfo); boolean wasBroadcast = BTC.getInstance().broadcastTransaction(redeemTransaction); if (!wasBroadcast) @@ -961,15 +961,15 @@ public class CrossChainResource { public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { Security.checkApiCallAllowed(request); - Address receiveAddress; + Address receivingAddress; try { - receiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receiveAddress); + receivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); } catch (AddressFormatException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); } // We only support P2PKH addresses at this time - if (receiveAddress.getOutputScriptType() != ScriptType.P2PKH) + if (receivingAddress.getOutputScriptType() != ScriptType.P2PKH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); if (tradeBotCreateRequest.tradeTimeout < 60) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 8286e843..db9f0f8d 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -108,17 +108,17 @@ public class TradeBot { byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); - // Convert Bitcoin receive address into public key hash (we only support P2PKH at this time) - Address bitcoinReceiveAddress; + // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time) + Address bitcoinReceivingAddress; try { - bitcoinReceiveAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receiveAddress); + bitcoinReceivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); } catch (AddressFormatException e) { - throw new DataException("Unsupported Bitcoin receive address: " + tradeBotCreateRequest.receiveAddress); + throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); } - if (bitcoinReceiveAddress.getOutputScriptType() != ScriptType.P2PKH) - throw new DataException("Unsupported Bitcoin receive address: " + tradeBotCreateRequest.receiveAddress); + if (bitcoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH) + throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); - byte[] bitcoinReceivePublicKeyHash = bitcoinReceiveAddress.getHash(); + byte[] bitcoinReceivingAccountInfo = bitcoinReceivingAddress.getHash(); PublicKeyAccount creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); @@ -151,7 +151,7 @@ public class TradeBot { tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivePublicKeyHash); + tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -718,9 +718,9 @@ public class TradeBot { Coin redeemAmount = Coin.ZERO; // The real funds are in P2SH-A ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - byte[] receivePublicKeyHash = tradeBotData.getReceivingAccountInfo(); + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivePublicKeyHash); + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, tradeBotData.getSecret(), receivingAccountInfo); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { // We couldn't redeem P2SH-B at this time @@ -787,8 +787,8 @@ public class TradeBot { // Send 'redeem' MESSAGE to AT using both secrets byte[] secretA = tradeBotData.getSecret(); - String qortalReceiveAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH - byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceiveAddress); + String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH + byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceivingAddress); PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey()); MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, tradeBotData.getAtAddress(), messageData, false, false); @@ -809,10 +809,10 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); - String receiveAddress = tradeBotData.getTradeNativeAddress(); + String receivingAddress = tradeBotData.getTradeNativeAddress(); LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", - p2shAddress, tradeBotData.getAtAddress(), receiveAddress)); + p2shAddress, tradeBotData.getAtAddress(), receivingAddress)); } /** @@ -873,9 +873,9 @@ public class TradeBot { Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress); - byte[] receivePublicKeyHash = tradeBotData.getReceivingAccountInfo(); + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivePublicKeyHash); + Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, secretA, receivingAccountInfo); if (!BTC.getInstance().broadcastTransaction(p2shRedeemTransaction)) { // We couldn't redeem P2SH-A at this time @@ -887,9 +887,9 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); - String receiveAddress = BTC.getInstance().pkhToAddress(receivePublicKeyHash); + String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo); - LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receiveAddress)); + LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); } /** diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 8f9948d9..3eb1689d 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -88,10 +88,10 @@ import com.google.common.primitives.Bytes; *

      *
    • secret-A
    • *
    • secret-B
    • - *
    • Qortal receive address of her chosing
    • + *
    • Qortal receiving address of her chosing
    • *
    * - *
  • AT's QORT funds are sent to Qortal receive address
  • + *
  • AT's QORT funds are sent to Qortal receiving address
  • * * *
  • Bob checks AT, extracts secret-A @@ -124,7 +124,7 @@ public class BTCACCT { + 8 /*lockTimeB*/ + 24 /*hash of secret-A (padded from 20 to 24)*/ + 8 /*lockTimeA*/; - public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receive address padded from 25 to 32*/; + public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; public enum Mode { @@ -195,7 +195,7 @@ public class BTCACCT { final int addrHashOfSecretAPointer = addrCounter++; final int addrRedeemMessageSecretBOffset = addrCounter++; - final int addrRedeemMessageReceiveAddressOffset = addrCounter++; + final int addrRedeemMessageReceivingAddressOffset = addrCounter++; final int addrMessageDataPointer = addrCounter++; final int addrMessageDataLength = addrCounter++; @@ -290,7 +290,7 @@ public class BTCACCT { assert dataByteBuffer.position() == addrHashOfSecretBPointer * MachineState.VALUE_SIZE : "addrHashOfSecretBPointer incorrect"; dataByteBuffer.putLong(addrHashOfSecretB); - // Index into data segment of recipient address, used by SET_B_IND + // Index into data segment of partner's Qortal address, used by SET_B_IND assert dataByteBuffer.position() == addrQortalPartnerAddressPointer * MachineState.VALUE_SIZE : "addrQortalPartnerAddressPointer incorrect"; dataByteBuffer.putLong(addrQortalPartnerAddress1); @@ -318,8 +318,8 @@ public class BTCACCT { assert dataByteBuffer.position() == addrRedeemMessageSecretBOffset * MachineState.VALUE_SIZE : "addrRedeemMessageSecretBOffset incorrect"; dataByteBuffer.putLong(32L); - // Offset into 'redeem' MESSAGE data payload for extracting Qortal receive address - assert dataByteBuffer.position() == addrRedeemMessageReceiveAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceiveAddressOffset incorrect"; + // Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address + assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect"; dataByteBuffer.putLong(64L); // Source location and length for hashing any passed secret @@ -540,12 +540,12 @@ public class BTCACCT { codeByteBuffer.put(OpCode.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout))); codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop)); - /* Success! Pay arranged amount to receive address */ + /* Success! Pay arranged amount to receiving address */ labelPayout = codeByteBuffer.position(); - // Extract Qortal receive address from next 32 bytes of message from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceiveAddressOffset)); - // Pay AT's balance to recipient + // Extract Qortal receiving address from next 32 bytes of message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); + // Pay AT's balance to receiving address codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); // Set redeemed mode codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REDEEMED.value)); @@ -678,7 +678,7 @@ public class BTCACCT { // Skip pointer to message sender dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip 'trade' message data offset for recipient's bitcoin PKH + // Skip 'trade' message data offset for partner's bitcoin PKH dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to partner's bitcoin PKH @@ -693,7 +693,7 @@ public class BTCACCT { // Skip 'redeem' message data offset for secret-B dataByteBuffer.position(dataByteBuffer.position() + 8); - // Skip 'redeem' message data offset for partner's Qortal receive address + // Skip 'redeem' message data offset for partner's Qortal receiving address dataByteBuffer.position(dataByteBuffer.position() + 8); // Skip pointer to message data @@ -751,9 +751,9 @@ public class BTCACCT { dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes // Potential partner's Bitcoin PKH - byte[] recipientBitcoinPKH = new byte[20]; - dataByteBuffer.get(recipientBitcoinPKH); - dataByteBuffer.position(dataByteBuffer.position() + 32 - recipientBitcoinPKH.length); // skip to 32 bytes + byte[] partnerBitcoinPKH = new byte[20]; + dataByteBuffer.get(partnerBitcoinPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerBitcoinPKH.length); // skip to 32 bytes long modeValue = dataByteBuffer.getLong(); Mode mode = Mode.valueOf((int) (modeValue & 0xffL)); @@ -764,7 +764,7 @@ public class BTCACCT { tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; tradeData.qortalPartnerAddress = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; - tradeData.partnerBitcoinPKH = recipientBitcoinPKH; + tradeData.partnerBitcoinPKH = partnerBitcoinPKH; tradeData.lockTimeA = lockTimeA; tradeData.lockTimeB = lockTimeB; } else { @@ -775,9 +775,9 @@ public class BTCACCT { } /** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */ - public static byte[] buildOfferMessage(byte[] recipientBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { + public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) { byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); - return Bytes.concat(recipientBitcoinPKH, hashOfSecretA, lockTimeABytes); + return Bytes.concat(partnerBitcoinPKH, hashOfSecretA, lockTimeABytes); } /** Returns info extracted from 'offer' MESSAGE payload sent by trade partner to AT creator's trade address, or null if not valid. */ @@ -796,11 +796,11 @@ public class BTCACCT { /** Returns 'trade' MESSAGE payload for AT creator to send to AT. */ public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int lockTimeB) { byte[] data = new byte[TRADE_MESSAGE_LENGTH]; - byte[] recipientQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); + byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress); byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA); byte[] lockTimeBBytes = BitTwiddling.toBEByteArray((long) lockTimeB); - System.arraycopy(recipientQortalAddressBytes, 0, data, 0, recipientQortalAddressBytes.length); + System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length); System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length); System.arraycopy(lockTimeBBytes, 0, data, 56, lockTimeBBytes.length); System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length); @@ -820,13 +820,13 @@ public class BTCACCT { } /** Returns 'redeem' MESSAGE payload for trade partner/ to send to AT. */ - public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB, String qortalReceiveAddress) { + public static byte[] buildRedeemMessage(byte[] secretA, byte[] secretB, String qortalReceivingAddress) { byte[] data = new byte[REDEEM_MESSAGE_LENGTH]; - byte[] qortalReceiveAddressBytes = Base58.decode(qortalReceiveAddress); + byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress); System.arraycopy(secretA, 0, data, 0, secretA.length); System.arraycopy(secretB, 0, data, 32, secretB.length); - System.arraycopy(qortalReceiveAddressBytes, 0, data, 64, qortalReceiveAddressBytes.length); + System.arraycopy(qortalReceivingAddressBytes, 0, data, 64, qortalReceivingAddressBytes.length); return data; } diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BTCP2SH.java index 319fc5ac..8a0fa546 100644 --- a/src/main/java/org/qortal/crosschain/BTCP2SH.java +++ b/src/main/java/org/qortal/crosschain/BTCP2SH.java @@ -163,10 +163,10 @@ public class BTCP2SH { * @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 receivePublicKeyHash PKH used for output + * @param receivingAccountInfo Bitcoin PKH used for output * @return Signed Bitcoin transaction for redeeming P2SH */ - public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivePublicKeyHash) { + public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) { Function redeemSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... ScriptBuilder scriptBuilder = new ScriptBuilder(); @@ -187,7 +187,7 @@ public class BTCP2SH { return scriptBuilder.build(); }; - return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivePublicKeyHash); + return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo); } /** Returns 'secret', if any, given list of raw bitcoin transactions. */ diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index fdfa9926..6544999b 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -8,6 +8,7 @@ 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; @@ -46,6 +47,7 @@ public class TradeBotData { private byte[] tradeForeignPublicKey; private byte[] tradeForeignPublicKeyHash; + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private long bitcoinAmount; // Never expose this via API From f90bd6ee4564ada5d6b8590461700d9290f3c651 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 3 Aug 2020 17:57:22 +0100 Subject: [PATCH 35/51] WIP: trade-bot: added WS for streaming existing/new trades in OFFERING state --- src/main/java/org/qortal/api/ApiService.java | 2 + .../api/model/CrossChainOfferSummary.java | 72 +++++++++++++ .../api/resource/CrossChainResource.java | 9 +- .../api/websocket/TradeOffersWebSocket.java | 100 ++++++++++++++++++ .../data/crosschain/CrossChainTradeData.java | 24 +---- .../org/qortal/repository/ATRepository.java | 4 +- .../repository/hsqldb/HSQLDBATRepository.java | 26 +++-- 7 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 src/main/java/org/qortal/api/model/CrossChainOfferSummary.java create mode 100644 src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java 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; From a8743b1bd3ef9b44e88e10c18d455207e187eac2 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 3 Aug 2020 19:28:55 +0100 Subject: [PATCH 36/51] ElectrumX network main-net servers --- .../java/org/qortal/crosschain/ElectrumX.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index b52994ac..8b10e52a 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -95,20 +95,19 @@ public class ElectrumX { case "MAIN": 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), - )); + 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": From 25bf315e238291146c96f3c62b9ccec2a0b7297b Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 4 Aug 2020 11:21:57 +0100 Subject: [PATCH 37/51] WIP: trade-bot: tradeoffers websocket initial message with OFFERING/REDEEMED and fixed subsequent messages --- .../api/websocket/TradeOffersWebSocket.java | 156 +++++++++++++----- .../java/org/qortal/crosschain/BTCACCT.java | 12 +- 2 files changed, 122 insertions(+), 46 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 24101eef..e658a428 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -3,7 +3,9 @@ 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; @@ -18,10 +20,10 @@ 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") @@ -34,10 +36,72 @@ public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSock @OnWebSocketConnect public void onWebSocketConnect(Session session) { - BlockNotifier.Listener listener = blockData -> onNotify(session, blockData); - BlockNotifier.getInstance().register(session, listener); + Map> queryParams = session.getUpgradeRequest().getParameterMap(); - this.onNotify(session, null); + final boolean includeHistoric = queryParams.get("includeHistoric") != null; + final Map previousAtModes = new HashMap<>(); + List crossChainOfferSummaries; + + try (final Repository repository = RepositoryManager.getRepository()) { + List 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))); + + if (includeHistoric) { + // We also want REDEEMED 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; + expectedValue = (long) BTCACCT.Mode.REDEEMED.value; + ++minimumFinalHeight; // because height is just *before* timestamp + + List historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (historicAtStates == null) { + session.close(4002, "repository issue fetching REDEEMED trades"); + return; + } + + initialAtStates.addAll(historicAtStates); + + // Save initial AT modes + previousAtModes.putAll(historicAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.REDEEMED))); + } + } + + crossChainOfferSummaries = produceSummaries(repository, initialAtStates); + } 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 @@ -47,54 +111,68 @@ public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSock @OnWebSocketMessage public void onWebSocketMessage(Session session, String message) { + /* ignored */ } - 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 { + private void onNotify(Session session, BlockData blockData, final Map previousAtModes) { + synchronized (previousAtModes) { //NOSONAR squid:S2445 suppressed because previousAtModes is final and curried in lambda + try (final Repository repository = RepositoryManager.getRepository()) { // Find any new trade ATs since this block - minimumFinalHeight = blockData.getHeight(); + final Boolean isFinished = null; + final Integer dataByteOffset = null; + final Long expectedValue = null; + final Integer minimumFinalHeight = blockData.getHeight(); + + List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); + + if (atStates == null) + return; + + List crossChainOfferSummaries = produceSummaries(repository, atStates); + + // 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))); + } catch (DataException e) { + // No output this time } - - 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; } + } + private boolean sendOfferSummaries(Session session, List crossChainOfferSummaries) { try { - List crossChainOffers = crossChainTradeDataList.stream().map(crossChainTradeData -> new CrossChainOfferSummary(crossChainTradeData)).collect(Collectors.toList()); - StringWriter stringWriter = new StringWriter(); - - this.marshall(stringWriter, crossChainOffers); + this.marshall(stringWriter, crossChainOfferSummaries); String output = stringWriter.toString(); - session.getRemote().sendString(output); + session.getRemote().sendStringByFuture(output); } catch (IOException e) { // No output this time? + return false; } + + return true; + } + + private static List produceSummaries(Repository repository, List atStates) throws DataException { + List offerSummaries = new ArrayList<>(); + + for (ATStateData atState : atStates) + offerSummaries.add(new CrossChainOfferSummary(BTCACCT.populateTradeData(repository, atState))); + + return offerSummaries; } } diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 3eb1689d..13184d19 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -17,7 +17,6 @@ import org.ciyam.at.OpCode; import org.ciyam.at.Timestamp; import org.qortal.account.Account; import org.qortal.asset.Asset; -import org.qortal.at.QortalAtLoggerFactory; import org.qortal.at.QortalFunctionCode; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; @@ -614,13 +613,11 @@ public class BTCACCT { * @throws DataException */ public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, ATStateData atStateData) throws DataException { + byte[] addressBytes = new byte[25]; // for general use String atAddress = atStateData.getATAddress(); - QortalAtLoggerFactory loggerFactory = QortalAtLoggerFactory.getInstance(); - byte[] stateData = atStateData.getStateData(); - byte[] dataBytes = MachineState.extractDataBytes(loggerFactory, stateData); - CrossChainTradeData tradeData = new CrossChainTradeData(); + tradeData.qortalAtAddress = atAddress; tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); tradeData.creationTimestamp = atStateData.getCreation(); @@ -628,8 +625,9 @@ public class BTCACCT { Account atAccount = new Account(repository, atAddress); tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT); - ByteBuffer dataByteBuffer = ByteBuffer.wrap(dataBytes); - byte[] addressBytes = new byte[25]; + byte[] stateData = atStateData.getStateData(); + ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData); + dataByteBuffer.position(MachineState.HEADER_LENGTH); /* Constants */ From 91518464c22dff7efdd4cf917710d0bdb6e13df9 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 4 Aug 2020 12:26:09 +0100 Subject: [PATCH 38/51] WIP: trade-bot: fix empty bitcoin wallet edge case when finding UTXOs --- src/main/java/org/qortal/crosschain/BTC.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 6d17fb06..48bb51c6 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -293,6 +293,9 @@ public class BTC { // Fully spent key - case (a) btc.spentKeys.add(key); wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); + } else { + // Key never been used - case (b) + areAllKeysSpent = false; } continue; From cd07240ce7b3290ac3df4492756c3d6d85381148 Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 4 Aug 2020 16:37:44 +0100 Subject: [PATCH 39/51] Add BTC.getWalletBalance(xprv) and add API call to access that. Also improved BTC.WalletAwareUTXOProvider to derive more keys itself instead of throwing and relying on caller to do the work. Added benefit of cleaning up caller code and being more efficient. Needed because not all receiving/change addresses were being picked up. --- .../api/resource/CrossChainResource.java | 36 ++++ src/main/java/org/qortal/crosschain/BTC.java | 190 ++++++++++-------- .../org/qortal/test/btcacct/BtcTests.java | 13 ++ 3 files changed, 159 insertions(+), 80 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 880acfe3..42c0cbe5 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -911,6 +911,42 @@ public class CrossChainResource { } } + @POST + @Path("/btc/walletbalance") + @Operation( + summary = "Returns BTC balance for BIP32 wallet", + description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.TEXT_PLAIN, + schema = @Schema( + type = "string", + description = "BIP32 'm' private key in base58", + example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ" + ) + ) + ), + responses = { + @ApiResponse( + content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) + ) + } + ) + @ApiErrors({ApiError.INVALID_PRIVATE_KEY}) + public String getBitcoinWalletBalance(String xprv58) { + Security.checkApiCallAllowed(request); + + if (!BTC.getInstance().isValidXprv(xprv58)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); + + Long balance = BTC.getInstance().getWalletBalance(xprv58); + if (balance == null) + return "null"; + + return balance.toString(); + } + @GET @Path("/tradebot") @Operation( diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 48bb51c6..0f2920a7 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -206,10 +206,7 @@ public class BTC { */ 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)); - - DeterministicKeyChain activeKeyChain = wallet.getActiveKeyChain(); - activeKeyChain.setLookaheadSize(3); + 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)); @@ -218,111 +215,144 @@ public class BTC { // Much smaller fee for TestNet3 sendRequest.feePerKb = Coin.valueOf(2000L); - do { - activeKeyChain.maybeLookAhead(); + try { + wallet.completeTx(sendRequest); + return sendRequest.tx; + } catch (InsufficientMoneyException e) { + return null; + } + } - try { - wallet.completeTx(sendRequest); - break; - } catch (InsufficientMoneyException e) { - return null; - } catch (WalletAwareUTXOProvider.AllKeysSpentException e) { - // loop again and use maybeLookAhead() to generate more keys to check - } - } while (true); + /** + * 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)); - return sendRequest.tx; + Coin balance = wallet.getBalance(); + if (balance == null) + return null; + + return balance.value; } // UTXOProvider support static class WalletAwareUTXOProvider implements UTXOProvider { - private final Wallet wallet; + private static final int LOOKAHEAD_INCREMENT = 3; + private final BTC btc; + private final Wallet wallet; - // We extend RuntimeException for unchecked-ness so it will bubble up to caller. - // We can't use UTXOProviderException as it will be wrapped in RuntimeException anyway. - @SuppressWarnings("serial") - public static class AllKeysSpentException extends RuntimeException { - public AllKeysSpentException() { - super(); - } + 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) { + 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 getOpenTransactionOutputs(List keys) throws UTXOProviderException { List allUnspentOutputs = new ArrayList<>(); final boolean coinbase = false; - boolean areAllKeysSpent = true; - for (ECKey key : keys) { - if (btc.spentKeys.contains(key)) { - wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); - continue; - } + int ki = 0; + do { + boolean areAllKeysUnspent = true; + boolean areAllKeysSpent = true; - Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); - byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + for (; ki < keys.size(); ++ki) { + ECKey key = keys.get(ki); - List 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 all passed keys are spent then we need to signal caller that they might want to - * generate more keys to check. - */ - - if (unspentOutputs.isEmpty()) { - // Ask for transaction history - if it's empty then key has never been used - List 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); + if (btc.spentKeys.contains(key)) { wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); - } else { - // Key never been used - case (b) - areAllKeysSpent = false; + areAllKeysUnspent = false; + continue; } - continue; + Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); + byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); + + List 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()) { + // Ask for transaction history - if it's empty then key has never been used + List 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 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 we reach here, then there's definitely at least one unspent key - areAllKeysSpent = false; + 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(); - for (UnspentOutput unspentOutput : unspentOutputs) { - List 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); + // This returns all keys, including those already in 'keys' + List allLeafKeys = this.keyChain.getLeafKeys(); + // Add only new keys onto our list of keys to search + List 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 (areAllKeysSpent) - // Notify caller that they need to check more keys - throw new AllKeysSpentException(); + // If we have processed all keys, then we're done + } while (ki < keys.size()); return allUnspentOutputs; } diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index 1b6123a7..f5829be8 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -73,4 +73,17 @@ public class BtcTests extends Common { btc.buildSpend(xprv58, recipient, amount); } + @Test + public void testGetWalletBalance() { + BTC btc = BTC.getInstance(); + + String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + + Long balance = btc.getWalletBalance(xprv58); + + assertNotNull(balance); + + System.out.println(BTC.format(balance)); + } + } From faa2e9502bd195e5318f6506e7d84348bcf75bfb Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 4 Aug 2020 17:00:36 +0100 Subject: [PATCH 40/51] WIP: trade-bot: include creation/latest timestamp (as appropriate) in trade offer summaries via websocket --- .../api/model/CrossChainOfferSummary.java | 9 +++++++- .../api/websocket/TradeOffersWebSocket.java | 22 ++++++++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java index c32d00aa..97522e99 100644 --- a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -32,17 +32,20 @@ public class CrossChainOfferSummary { private BTCACCT.Mode mode; + private long timestamp; + protected CrossChainOfferSummary() { /* For JAXB */ } - public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData) { + 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; } public String getQortalAtAddress() { @@ -69,4 +72,8 @@ public class CrossChainOfferSummary { return this.mode; } + public long getTimestamp() { + return this.timestamp; + } + } diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index e658a428..8b49ab00 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -20,6 +20,7 @@ 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; @@ -89,7 +90,7 @@ public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSock } } - crossChainOfferSummaries = produceSummaries(repository, initialAtStates); + crossChainOfferSummaries = produceSummaries(repository, initialAtStates, null); } catch (DataException e) { session.close(4003, "generic repository issue"); return; @@ -130,7 +131,7 @@ public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSock if (atStates == null) return; - List crossChainOfferSummaries = produceSummaries(repository, atStates); + List crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp()); // Remove any entries unchanged from last time crossChainOfferSummaries.removeIf(offerSummary -> previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode()); @@ -166,11 +167,22 @@ public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSock return true; } - private static List produceSummaries(Repository repository, List atStates) throws DataException { + private static List produceSummaries(Repository repository, List atStates, Long timestamp) throws DataException { List offerSummaries = new ArrayList<>(); - for (ATStateData atState : atStates) - offerSummaries.add(new CrossChainOfferSummary(BTCACCT.populateTradeData(repository, atState))); + for (ATStateData atState : atStates) { + 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()); + } + + offerSummaries.add(new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp)); + } return offerSummaries; } From 6b834992166c70f1a4e029760678e79dfdd707ee Mon Sep 17 00:00:00 2001 From: catbref Date: Tue, 4 Aug 2020 20:35:22 +0100 Subject: [PATCH 41/51] WIP: trade-bot: add support for showing trade partner's Qortal receiving address in trade offer summaries --- .../api/model/CrossChainOfferSummary.java | 7 +++++ .../java/org/qortal/crosschain/BTCACCT.java | 29 +++++++++++++++++-- .../data/crosschain/CrossChainTradeData.java | 3 ++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java index 97522e99..4cabfc37 100644 --- a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -34,6 +34,8 @@ public class CrossChainOfferSummary { private long timestamp; + private String partnerQortalReceivingAddress; + protected CrossChainOfferSummary() { /* For JAXB */ } @@ -46,6 +48,7 @@ public class CrossChainOfferSummary { this.tradeTimeout = crossChainTradeData.tradeTimeout; this.mode = crossChainTradeData.mode; this.timestamp = timestamp; + this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress; } public String getQortalAtAddress() { @@ -76,4 +79,8 @@ public class CrossChainOfferSummary { return this.timestamp; } + public String getPartnerQortalReceivingAddress() { + return this.partnerQortalReceivingAddress; + } + } diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 13184d19..404c9fb0 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -105,10 +105,10 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("fad14381b77ae1a2bfe7e16a1a8b571839c5f405fca0490ead08499ac170f65b").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("a472f32a6799ff85ed99567701b42fa228c58a09d38485ab208ad78d0f2cc813").asBytes(); // SHA256 of AT code bytes /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ - private static final int MODE_VALUE_OFFSET = 63; + private static final int MODE_VALUE_OFFSET = 68; /** Byte offset into AT state data where 'mode' variable (long) is stored. */ public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); @@ -199,6 +199,8 @@ public class BTCACCT { final int addrMessageDataPointer = addrCounter++; final int addrMessageDataLength = addrCounter++; + final int addrPartnerReceivingAddressPointer = addrCounter++; + final int addrEndOfConstants = addrCounter; // Variables @@ -238,6 +240,9 @@ public class BTCACCT { final int addrPartnerBitcoinPKH = addrCounter; addrCounter += 4; + final int addrPartnerReceivingAddress = addrCounter; + addrCounter += 4; + final int addrMode = addrCounter++; assert addrMode == MODE_VALUE_OFFSET : "MODE_VALUE_OFFSET does not match addrMode"; @@ -327,6 +332,10 @@ public class BTCACCT { assert dataByteBuffer.position() == addrMessageDataLength * MachineState.VALUE_SIZE : "addrMessageDataLength incorrect"; dataByteBuffer.putLong(32L); + // Pointer into data segment of where to save partner's receiving Qortal address, used by GET_B_IND + assert dataByteBuffer.position() == addrPartnerReceivingAddressPointer * MachineState.VALUE_SIZE : "addrPartnerReceivingAddressPointer incorrect"; + dataByteBuffer.putLong(addrPartnerReceivingAddress); + assert dataByteBuffer.position() == addrEndOfConstants * MachineState.VALUE_SIZE : "dataByteBuffer position not at end of constants"; // Code labels @@ -544,6 +553,8 @@ public class BTCACCT { // Extract Qortal receiving address from next 32 bytes of message from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrRedeemMessageReceivingAddressOffset)); + // Save B register into data segment starting at addrPartnerReceivingAddress (as pointed to by addrPartnerReceivingAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerReceivingAddressPointer)); // Pay AT's balance to receiving address codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); // Set redeemed mode @@ -700,6 +711,9 @@ public class BTCACCT { // Skip message data length dataByteBuffer.position(dataByteBuffer.position() + 8); + // Skip pointer to partner's receiving address + dataByteBuffer.position(dataByteBuffer.position() + 8); + /* End of constants / begin variables */ // Skip AT creator's address @@ -753,9 +767,17 @@ public class BTCACCT { dataByteBuffer.get(partnerBitcoinPKH); dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerBitcoinPKH.length); // skip to 32 bytes + // Partner's receiving address (if present) + byte[] partnerReceivingAddress = new byte[25]; + dataByteBuffer.get(partnerReceivingAddress); + dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerReceivingAddress.length); // skip to 32 bytes + + // Trade AT's 'mode' long modeValue = dataByteBuffer.getLong(); Mode mode = Mode.valueOf((int) (modeValue & 0xffL)); + /* End of variables */ + if (mode != null && mode != Mode.OFFERING) { tradeData.mode = mode; tradeData.refundTimeout = refundTimeout; @@ -765,6 +787,9 @@ public class BTCACCT { tradeData.partnerBitcoinPKH = partnerBitcoinPKH; tradeData.lockTimeA = lockTimeA; tradeData.lockTimeB = lockTimeB; + + if (mode == Mode.REDEEMED) + tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); } else { tradeData.mode = Mode.OFFERING; } diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index bd012cdc..f445f58e 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -73,6 +73,9 @@ public class CrossChainTradeData { @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 // Necessary for JAXB From 615381ca5aac8218a18218f46c681d21e4fe81d7 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 10:03:08 +0100 Subject: [PATCH 42/51] Fix BTC spend txn building to be less aggressive about caching/checking spent keys --- src/main/java/org/qortal/crosschain/BTC.java | 13 ++++++------ .../org/qortal/test/btcacct/BtcTests.java | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java index 0f2920a7..5e5a3639 100644 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ b/src/main/java/org/qortal/crosschain/BTC.java @@ -277,12 +277,6 @@ public class BTC { for (; ki < keys.size(); ++ki) { ECKey key = keys.get(ki); - if (btc.spentKeys.contains(key)) { - wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key); - areAllKeysUnspent = false; - continue; - } - Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); @@ -299,6 +293,13 @@ public class BTC { */ 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 historicTransactionHashes = btc.electrumX.getAddressTransactions(script); if (historicTransactionHashes == null) diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/btcacct/BtcTests.java index f5829be8..a00e54f6 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/btcacct/BtcTests.java @@ -5,6 +5,7 @@ import static org.junit.Assert.*; import java.util.Arrays; import java.util.List; +import org.bitcoinj.core.Transaction; import org.bitcoinj.store.BlockStoreException; import org.junit.After; import org.junit.Before; @@ -67,10 +68,17 @@ public class BtcTests extends Common { BTC btc = BTC.getInstance(); String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; + String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; long amount = 1000L; - btc.buildSpend(xprv58, recipient, amount); + Transaction transaction = btc.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); + + // Check spent key caching doesn't affect outcome + + transaction = btc.buildSpend(xprv58, recipient, amount); + assertNotNull(transaction); } @Test @@ -84,6 +92,16 @@ public class BtcTests extends Common { assertNotNull(balance); System.out.println(BTC.format(balance)); + + // Check spent key caching doesn't affect outcome + + Long repeatBalance = btc.getWalletBalance(xprv58); + + assertNotNull(repeatBalance); + + System.out.println(BTC.format(repeatBalance)); + + assertEquals(balance, repeatBalance); } } From 36d0abe635dc51e5dfd44e53e2882683f97305b1 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 10:04:25 +0100 Subject: [PATCH 43/51] WIP: trade-bot: log warning when we can't fund P2SH-B for some reason --- src/main/java/org/qortal/controller/TradeBot.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index db9f0f8d..afe5c5f6 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -654,6 +654,11 @@ public class TradeBot { String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, FEE_AMOUNT); + if (p2shFundingTransaction == null) { + LOGGER.warn(() -> String.format("Unable to build P2SH-B funding transaction - lack of funds?")); + return; + } + if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { // We couldn't fund P2SH-B at this time LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-B funding transaction?")); From ec2c9d2a44f7e0991cd9e14bafecb7cba4419881 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 10:05:09 +0100 Subject: [PATCH 44/51] Improve /crosschain/tradebot/respond with varied API errors such as BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE, etc. instead of just "false" --- .../api/resource/CrossChainResource.java | 19 ++++++++++++++++--- .../java/org/qortal/controller/TradeBot.java | 15 +++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 42c0cbe5..0c61c83b 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1045,7 +1045,7 @@ public class CrossChainResource { ) } ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { Security.checkApiCallAllowed(request); @@ -1068,9 +1068,22 @@ public class CrossChainResource { if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - boolean result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58, tradeBotRespondRequest.receivingAddress); + TradeBot.ResponseResult result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58, tradeBotRespondRequest.receivingAddress); - return result ? "true" : "false"; + switch (result) { + case OK: + return "true"; + + case INSUFFICIENT_FUNDS: + case BTC_BALANCE_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); + + case BTC_NETWORK_ISSUE: + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); + + default: + return "false"; + } } catch (DataException e) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index afe5c5f6..0c22cb48 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -45,6 +45,8 @@ import org.qortal.utils.NTP; public class TradeBot { + public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE } + private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); private static final Random RANDOM = new SecureRandom(); private static final long FEE_AMOUNT = 1000L; @@ -203,7 +205,7 @@ public class TradeBot { * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise * @throws DataException */ - public static boolean startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { + public static ResponseResult startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { byte[] tradePrivateKey = generateTradePrivateKey(); byte[] secretA = generateSecret(); byte[] hashOfSecretA = Crypto.hash160(secretA); @@ -233,7 +235,7 @@ public class TradeBot { Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); if (fundingCheckTransaction == null) - return false; + return ResponseResult.INSUFFICIENT_FUNDS; // P2SH-A to be funded byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); @@ -241,10 +243,15 @@ public class TradeBot { // Fund P2SH-A Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, crossChainTradeData.expectedBitcoin + FEE_AMOUNT); + if (p2shFundingTransaction == null) { + LOGGER.warn(() -> String.format("Unable to build P2SH-A funding transaction - lack of funds?")); + return ResponseResult.BTC_BALANCE_ISSUE; + } + if (!BTC.getInstance().broadcastTransaction(p2shFundingTransaction)) { // We couldn't fund P2SH-A at this time LOGGER.debug(() -> String.format("Couldn't broadcast P2SH-A funding transaction?")); - return false; + return ResponseResult.BTC_NETWORK_ISSUE; } repository.getCrossChainRepository().save(tradeBotData); @@ -252,7 +259,7 @@ public class TradeBot { LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); - return true; + return ResponseResult.OK; } private static byte[] generateTradePrivateKey() { From ce5cf87094732845431afa7308489445f1c1d20c Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 13:20:19 +0100 Subject: [PATCH 45/51] Added unified, simple event bus to eventually replace controller "notifiers" --- src/main/java/org/qortal/event/Event.java | 5 +++ src/main/java/org/qortal/event/EventBus.java | 33 ++++++++++++++++++++ src/main/java/org/qortal/event/Listener.java | 6 ++++ 3 files changed, 44 insertions(+) create mode 100644 src/main/java/org/qortal/event/Event.java create mode 100644 src/main/java/org/qortal/event/EventBus.java create mode 100644 src/main/java/org/qortal/event/Listener.java diff --git a/src/main/java/org/qortal/event/Event.java b/src/main/java/org/qortal/event/Event.java new file mode 100644 index 00000000..0c97522c --- /dev/null +++ b/src/main/java/org/qortal/event/Event.java @@ -0,0 +1,5 @@ +package org.qortal.event; + +public interface Event { + +} diff --git a/src/main/java/org/qortal/event/EventBus.java b/src/main/java/org/qortal/event/EventBus.java new file mode 100644 index 00000000..e0014a20 --- /dev/null +++ b/src/main/java/org/qortal/event/EventBus.java @@ -0,0 +1,33 @@ +package org.qortal.event; + +import java.util.ArrayList; +import java.util.List; + +public enum EventBus { + INSTANCE; + + private static final List 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 clonedListeners; + + synchronized (LISTENERS) { + clonedListeners = new ArrayList<>(LISTENERS); + } + + for (Listener listener : clonedListeners) + listener.listen(event); + } +} diff --git a/src/main/java/org/qortal/event/Listener.java b/src/main/java/org/qortal/event/Listener.java new file mode 100644 index 00000000..cb1668bf --- /dev/null +++ b/src/main/java/org/qortal/event/Listener.java @@ -0,0 +1,6 @@ +package org.qortal.event; + +@FunctionalInterface +public interface Listener { + void listen(Event event); +} From d507383487c6cce93ffa35493bff214a56d7c77d Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 13:23:07 +0100 Subject: [PATCH 46/51] Rework ApiWebSocket so it can manage sessions and in readiness to conversion from "notifiers" to event-bus --- .../api/websocket/ActiveChatsWebSocket.java | 7 +-- .../api/websocket/AdminStatusWebSocket.java | 5 +- .../qortal/api/websocket/ApiWebSocket.java | 39 +++++++++--- .../qortal/api/websocket/BlocksWebSocket.java | 5 +- .../api/websocket/ChatMessagesWebSocket.java | 5 +- .../api/websocket/TradeOffersWebSocket.java | 63 ++++++++++--------- 6 files changed, 73 insertions(+), 51 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java index b85b7891..15fbc34d 100644 --- a/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ActiveChatsWebSocket.java @@ -10,7 +10,6 @@ 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.controller.ChatNotifier; import org.qortal.crypto.Crypto; @@ -22,7 +21,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) { @@ -31,7 +30,7 @@ public class ActiveChatsWebSocket extends WebSocketServlet implements ApiWebSock @OnWebSocketConnect public void onWebSocketConnect(Session session) { - Map pathParams = this.getPathParams(session, "/{address}"); + Map pathParams = getPathParams(session, "/{address}"); String address = pathParams.get("address"); if (address == null || !Crypto.isValidAddress(address)) { @@ -70,7 +69,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(); diff --git a/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java b/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java index 2a957921..12c31707 100644 --- a/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/AdminStatusWebSocket.java @@ -9,7 +9,6 @@ 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.NodeStatus; import org.qortal.controller.StatusNotifier; @@ -19,7 +18,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) { @@ -51,7 +50,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(); diff --git a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java index 9209c5b9..87ee16cd 100644 --- a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java @@ -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, List> SESSIONS_BY_CLASS = new HashMap<>(); + + protected static String getPathInfo(Session session) { ServletUpgradeRequest upgradeRequest = (ServletUpgradeRequest) session.getUpgradeRequest(); return upgradeRequest.getHttpServletRequest().getPathInfo(); } - default Map getPathParams(Session session, String pathSpec) { + protected static Map 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 getSessions() { + synchronized (SESSIONS_BY_CLASS) { + return new ArrayList<>(SESSIONS_BY_CLASS.get(this.getClass())); + } + } + } diff --git a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java index 398cdd33..29d07012 100644 --- a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java @@ -8,7 +8,6 @@ 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.ApiError; import org.qortal.controller.BlockNotifier; @@ -20,7 +19,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) { @@ -98,7 +97,7 @@ public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket { StringWriter stringWriter = new StringWriter(); try { - this.marshall(stringWriter, blockData); + marshall(stringWriter, blockData); session.getRemote().sendString(stringWriter.toString()); } catch (IOException e) { diff --git a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java index ef04b950..c9069498 100644 --- a/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ChatMessagesWebSocket.java @@ -12,7 +12,6 @@ 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.controller.ChatNotifier; import org.qortal.data.chat.ChatMessage; @@ -23,7 +22,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) { @@ -123,7 +122,7 @@ public class ChatMessagesWebSocket extends WebSocketServlet implements ApiWebSoc StringWriter stringWriter = new StringWriter(); try { - this.marshall(stringWriter, chatMessages); + marshall(stringWriter, chatMessages); session.getRemote().sendString(stringWriter.toString()); } catch (IOException e) { diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index 8b49ab00..a8967385 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -13,7 +13,6 @@ 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; @@ -28,7 +27,7 @@ import org.qortal.utils.NTP; @WebSocket @SuppressWarnings("serial") -public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSocket { +public class TradeOffersWebSocket extends ApiWebSocket { @Override public void configure(WebSocketServletFactory factory) { @@ -116,46 +115,48 @@ public class TradeOffersWebSocket extends WebSocketServlet implements ApiWebSock } private void onNotify(Session session, BlockData blockData, final Map previousAtModes) { + List 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 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 - 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(); + // Remove any entries unchanged from last time + crossChainOfferSummaries.removeIf(offerSummary -> previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode()); - List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, - isFinished, dataByteOffset, expectedValue, minimumFinalHeight, - null, null, null); + // Don't send anything if no results + if (crossChainOfferSummaries.isEmpty()) + return; - if (atStates == null) - return; + final boolean wasSent = sendOfferSummaries(session, crossChainOfferSummaries); - List crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp()); + if (!wasSent) + return; - // 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))); - } catch (DataException e) { - // No output this time - } + previousAtModes.putAll(crossChainOfferSummaries.stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, CrossChainOfferSummary::getMode))); } } private boolean sendOfferSummaries(Session session, List crossChainOfferSummaries) { try { StringWriter stringWriter = new StringWriter(); - this.marshall(stringWriter, crossChainOfferSummaries); + marshall(stringWriter, crossChainOfferSummaries); String output = stringWriter.toString(); session.getRemote().sendStringByFuture(output); From cac68ccc14ff0abeb8967a0d3b24f686c17f59af Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 13:23:24 +0100 Subject: [PATCH 47/51] Added trade-bot websocket --- src/main/java/org/qortal/api/ApiService.java | 2 + .../api/websocket/TradeBotWebSocket.java | 119 ++++++++++++++++++ .../java/org/qortal/controller/TradeBot.java | 42 +++++++ 3 files changed, 163 insertions(+) create mode 100644 src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 97b42960..9b230601 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.TradeBotWebSocket; import org.qortal.api.websocket.TradeOffersWebSocket; import org.qortal.settings.Settings; @@ -199,6 +200,7 @@ public class ApiService { 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(); diff --git a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java new file mode 100644 index 00000000..e97e54bc --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java @@ -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 PREVIOUS_STATES = new HashMap<>(); + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(TradeBotWebSocket.class); + + try (final Repository repository = RepositoryManager.getRepository()) { + List 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 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 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 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; + } + +} diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 0c22cb48..74bec0bc 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -30,6 +30,8 @@ import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; +import org.qortal.event.Event; +import org.qortal.event.EventBus; import org.qortal.group.Group; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -47,6 +49,18 @@ public class TradeBot { public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE } + public static class StateChangeEvent implements Event { + private final TradeBotData tradeBotData; + + public StateChangeEvent(TradeBotData tradeBotData) { + this.tradeBotData = tradeBotData; + } + + public TradeBotData getTradeBotData() { + return this.tradeBotData; + } + } + private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); private static final Random RANDOM = new SecureRandom(); private static final long FEE_AMOUNT = 1000L; @@ -158,6 +172,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("Built AT %s. Waiting for deployment", atAddress)); + notifyStateChange(tradeBotData); // Return to user for signing and broadcast as we don't have their Qortal private key try { @@ -258,6 +273,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + notifyStateChange(tradeBotData); return ResponseResult.OK; } @@ -368,6 +384,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); } /** @@ -405,6 +422,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddress)); + notifyStateChange(tradeBotData); return; } @@ -443,6 +461,7 @@ public class TradeBot { LOGGER.info(() -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", p2shAddress, crossChainTradeData.qortalCreatorTradeAddress, tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); } /** @@ -478,6 +497,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); return; } @@ -550,6 +570,7 @@ public class TradeBot { String p2shBAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); LOGGER.info(() -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shBAddress)); + notifyStateChange(tradeBotData); return; } @@ -558,6 +579,7 @@ public class TradeBot { if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + notifyStateChange(tradeBotData); } } @@ -597,6 +619,8 @@ public class TradeBot { else LOGGER.info(() -> String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddress)); + notifyStateChange(tradeBotData); + return; } @@ -622,6 +646,8 @@ public class TradeBot { repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); + notifyStateChange(tradeBotData); + return; } @@ -679,6 +705,8 @@ public class TradeBot { LOGGER.info(() -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddress)); + + notifyStateChange(tradeBotData); } /** @@ -706,6 +734,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); return; } @@ -746,6 +775,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddress, tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); } /** @@ -782,6 +812,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("LockTime-B reached, refunding P2SH-B %s - aborting trade", p2shAddress)); + notifyStateChange(tradeBotData); return; } @@ -825,6 +856,8 @@ public class TradeBot { LOGGER.info(() -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", p2shAddress, tradeBotData.getAtAddress(), receivingAddress)); + + notifyStateChange(tradeBotData); } /** @@ -867,6 +900,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); + notifyStateChange(tradeBotData); return; } @@ -902,6 +936,7 @@ public class TradeBot { String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo); LOGGER.info(() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); + notifyStateChange(tradeBotData); } /** @@ -943,6 +978,7 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddress)); + notifyStateChange(tradeBotData); } /** Trade-bot is attempting to refund P2SH-A. */ @@ -987,6 +1023,12 @@ public class TradeBot { repository.saveChanges(); LOGGER.info(() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddress)); + notifyStateChange(tradeBotData); + } + + private static void notifyStateChange(TradeBotData tradeBotData) { + StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); + EventBus.INSTANCE.notify(stateChangeEvent); } } From c89de7adfb50d72cca7773dce11c2e1fd88729a1 Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 16:00:40 +0100 Subject: [PATCH 48/51] Add creatorAddress, qortAmount and (last updated) timestamp to trade-bot entries --- .../api/resource/CrossChainResource.java | 3 + .../java/org/qortal/controller/TradeBot.java | 23 ++++-- .../qortal/data/crosschain/TradeBotData.java | 28 +++++++- .../hsqldb/HSQLDBCrossChainRepository.java | 71 +++++++++++-------- .../hsqldb/HSQLDBDatabaseUpdates.java | 40 ++++++++++- 5 files changed, 129 insertions(+), 36 deletions(-) diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 0c61c83b..40654179 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -1011,6 +1011,9 @@ public class CrossChainResource { if (tradeBotCreateRequest.tradeTimeout < 60) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + if (tradeBotCreateRequest.bitcoinAmount <= 0 || tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + try (final Repository repository = RepositoryManager.getRepository()) { // Do some simple checking first Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/TradeBot.java index 74bec0bc..496125b4 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/TradeBot.java @@ -163,7 +163,7 @@ public class TradeBot { String atAddress = deployAtTransactionData.getAtAddress(); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, - atAddress, + creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretB, hashOfSecretB, tradeForeignPublicKey, tradeForeignPublicKeyHash, @@ -237,7 +237,7 @@ public class TradeBot { int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, - crossChainTradeData.qortalAtAddress, + receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretA, hashOfSecretA, tradeForeignPublicKey, tradeForeignPublicKeyHash, @@ -380,6 +380,7 @@ public class TradeBot { return; tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_MESSAGE); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -418,6 +419,7 @@ public class TradeBot { if (atData.getIsFinished()) { // No point sending MESSAGE - might as well wait for refund tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -456,6 +458,7 @@ public class TradeBot { } tradeBotData.setState(TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -493,6 +496,7 @@ public class TradeBot { // If AT has finished then Bob likely cancelled his trade offer if (atData.getIsFinished()) { tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -563,6 +567,7 @@ public class TradeBot { } tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_P2SH_B); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -577,6 +582,7 @@ public class TradeBot { // Don't resave if we don't need to if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) { + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); notifyStateChange(tradeBotData); @@ -608,6 +614,7 @@ public class TradeBot { // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -643,6 +650,7 @@ public class TradeBot { // There's no P2SH-B at this point, so jump straight to refunding P2SH-A tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -700,6 +708,7 @@ public class TradeBot { // P2SH-B funded, now we wait for Bob to redeem it tradeBotData.setState(TradeBotData.State.ALICE_WATCH_P2SH_B); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -730,6 +739,7 @@ public class TradeBot { // If we've passed AT refund timestamp then AT will have finished after auto-refunding if (atData.getIsFinished()) { tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -771,6 +781,7 @@ public class TradeBot { // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT tradeBotData.setState(TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -808,6 +819,7 @@ public class TradeBot { // Refund P2SH-B if we've passed lockTime-B if (NTP.getTime() >= crossChainTradeData.lockTimeB * 1000L) { tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_B); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -849,6 +861,7 @@ public class TradeBot { } tradeBotData.setState(TradeBotData.State.ALICE_DONE); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -896,6 +909,7 @@ public class TradeBot { // We check variable in AT that is set when trade successfully completes if (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) { tradeBotData.setState(TradeBotData.State.BOB_REFUNDED); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -930,6 +944,7 @@ public class TradeBot { } tradeBotData.setState(TradeBotData.State.BOB_DONE); + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -973,7 +988,7 @@ public class TradeBot { } tradeBotData.setState(TradeBotData.State.ALICE_REFUNDING_A); - + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); @@ -1018,7 +1033,7 @@ public class TradeBot { } tradeBotData.setState(TradeBotData.State.ALICE_REFUNDED); - + tradeBotData.setTimestamp(NTP.getTime()); repository.getCrossChainRepository().save(tradeBotData); repository.saveChanges(); diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 6544999b..0f57845d 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -35,8 +35,14 @@ public class TradeBotData { } 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; @@ -66,14 +72,18 @@ public class TradeBotData { /* JAXB */ } - public TradeBotData(byte[] tradePrivateKey, State tradeState, String atAddress, + 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; @@ -100,6 +110,10 @@ public class TradeBotData { this.tradeState = state; } + public String getCreatorAddress() { + return this.creatorAddress; + } + public String getAtAddress() { return this.atAddress; } @@ -108,6 +122,18 @@ public class TradeBotData { 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; } diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 0eb1ef00..589ca0a4 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -19,7 +19,8 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { - String sql = "SELECT trade_state, at_address, " + 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, " @@ -36,24 +37,27 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { if (tradeState == null) throw new DataException("Illegal trade-bot trade-state fetched from repository"); - String atAddress = resultSet.getString(2); - byte[] tradeNativePublicKey = resultSet.getBytes(3); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(4); - String tradeNativeAddress = resultSet.getString(5); - byte[] secret = resultSet.getBytes(6); - byte[] hashOfSecret = resultSet.getBytes(7); - byte[] tradeForeignPublicKey = resultSet.getBytes(8); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(9); - long bitcoinAmount = resultSet.getLong(10); - String xprv58 = resultSet.getString(11); - byte[] lastTransactionSignature = resultSet.getBytes(12); - Integer lockTimeA = resultSet.getInt(13); + 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(14); + byte[] receivingAccountInfo = resultSet.getBytes(17); return new TradeBotData(tradePrivateKey, tradeState, - atAddress, + creatorAddress, atAddress, timestamp, qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, @@ -65,7 +69,8 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override public List getAllTradeBotData() throws DataException { - String sql = "SELECT trade_private_key, trade_state, at_address, " + 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, " @@ -85,24 +90,27 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { if (tradeState == null) throw new DataException("Illegal trade-bot trade-state fetched from repository"); - String atAddress = resultSet.getString(3); - byte[] tradeNativePublicKey = resultSet.getBytes(4); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(5); - String tradeNativeAddress = resultSet.getString(6); - byte[] secret = resultSet.getBytes(7); - byte[] hashOfSecret = resultSet.getBytes(8); - byte[] tradeForeignPublicKey = resultSet.getBytes(9); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(10); - long bitcoinAmount = resultSet.getLong(11); - String xprv58 = resultSet.getString(12); - byte[] lastTransactionSignature = resultSet.getBytes(13); - Integer lockTimeA = resultSet.getInt(14); + 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(15); + byte[] receivingAccountInfo = resultSet.getBytes(18); TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, - atAddress, + creatorAddress, atAddress, timestamp, qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, tradeForeignPublicKey, tradeForeignPublicKeyHash, @@ -122,7 +130,10 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { 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()) diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 2706bb5d..4c4d5274 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -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 { @@ -621,7 +626,7 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " - + "at_address QortalAddress, " + + "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, " @@ -641,6 +646,39 @@ public class HSQLDBDatabaseUpdates { stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN IF NOT EXISTS receiving_account_info VARBINARY(32)"); break; + case 23: + // XXX for testing/dev only - do not merge into 'master' + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN IF NOT EXISTS creator_address QortalAddress BEFORE at_address"); + // Update Bob bot entries + stmt.execute("UPDATE TradeBotStates AS StatesToUpdate " + + "SET (trade_private_key, creator_address) = (" + + "SELECT trade_private_key, accounts.account " + + "FROM TradeBotStates " + + "JOIN ATs USING (at_address) " + + "JOIN Accounts ON Accounts.public_key = ATs.creator " + + "WHERE tradebotstates.trade_private_key = StatesToUpdate.trade_private_key" + + ") WHERE trade_state < 90"); + + stmt.execute("SELECT trade_private_key, receiving_account_info FROM TradeBotStates WHERE trade_state >= 90"); + Map aliceAddresses = new HashMap<>(); + try (ResultSet resultSet = stmt.getResultSet()) { + while (resultSet.next()) { + byte[] tradePrivateKey = resultSet.getBytes(1); + byte[] receivingAccountInfo = resultSet.getBytes(2); + + aliceAddresses.put(HashCode.fromBytes(tradePrivateKey).toString(), Base58.encode(receivingAccountInfo)); + } + } + for (Map.Entry entry : aliceAddresses.entrySet()) + stmt.execute("UPDATE TradeBotStates SET creator_address = '" + entry.getValue() + "' WHERE trade_private_key = HEXTORAW('" + entry.getKey() + "')"); + stmt.execute("COMMIT"); + + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN IF NOT EXISTS updated_when BIGINT BEFORE trade_native_public_key"); + stmt.execute("UPDATE TradeBotStates SET updated_when = UNIX_TIMESTAMP() * 1000"); + + stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN IF NOT EXISTS qort_amount QortalAmount NOT NULL DEFAULT 12345678 BEFORE trade_native_public_key"); + break; + default: // nothing to do return false; From ce8992867d794b66191035bdaee43c4359a6d0fd Mon Sep 17 00:00:00 2001 From: catbref Date: Wed, 5 Aug 2020 20:58:25 +0100 Subject: [PATCH 49/51] Include last 24 hours of CANCELLED & REFUNDED trade offers in first message --- .../api/websocket/TradeOffersWebSocket.java | 61 +++++++++++++------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index a8967385..2520b1d8 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -63,14 +63,18 @@ public class TradeOffersWebSocket extends ApiWebSocket { // 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 trades over the last 24 hours + // 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; - expectedValue = (long) BTCACCT.Mode.REDEEMED.value; + dataByteOffset = null; + expectedValue = null; ++minimumFinalHeight; // because height is just *before* timestamp List historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, @@ -78,18 +82,32 @@ public class TradeOffersWebSocket extends ApiWebSocket { null, null, null); if (historicAtStates == null) { - session.close(4002, "repository issue fetching REDEEMED trades"); + session.close(4002, "repository issue fetching historic trades"); return; } - initialAtStates.addAll(historicAtStates); + for (ATStateData historicAtState : historicAtStates) { + CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null); - // Save initial AT modes - previousAtModes.putAll(historicAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.REDEEMED))); + 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()); + } } } - crossChainOfferSummaries = produceSummaries(repository, initialAtStates, null); } catch (DataException e) { session.close(4003, "generic repository issue"); return; @@ -168,22 +186,25 @@ public class TradeOffersWebSocket extends ApiWebSocket { 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 produceSummaries(Repository repository, List atStates, Long timestamp) throws DataException { List offerSummaries = new ArrayList<>(); - for (ATStateData atState : atStates) { - 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()); - } - - offerSummaries.add(new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp)); - } + for (ATStateData atState : atStates) + offerSummaries.add(produceSummary(repository, atState, timestamp)); return offerSummaries; } From 23a524b4640702227a0f57997bef0bdb4db0a98b Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 6 Aug 2020 08:23:49 +0100 Subject: [PATCH 50/51] BTC-ACCT: change AT so 'cancel' MESSAGE needs to come from AT creator's address (not trade address) so fee can be used instead of PoW for faster cancels --- .../java/org/qortal/crosschain/BTCACCT.java | 52 ++++---- .../java/org/qortal/test/btcacct/AtTests.java | 115 ++++++++++++------ 2 files changed, 107 insertions(+), 60 deletions(-) diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BTCACCT.java index 404c9fb0..1adacfb8 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BTCACCT.java @@ -105,7 +105,7 @@ public class BTCACCT { public static final int SECRET_LENGTH = 32; public static final int MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("a472f32a6799ff85ed99567701b42fa228c58a09d38485ab208ad78d0f2cc813").asBytes(); // SHA256 of AT code bytes + public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ private static final int MODE_VALUE_OFFSET = 68; @@ -343,7 +343,8 @@ public class BTCACCT { Integer labelTradeTxnLoop = null; Integer labelCheckTradeTxn = null; - + Integer labelCheckCancelTxn = null; + Integer labelNotTradeNorCancelTxn = null; Integer labelCheckNonRefundTradeTxn = null; Integer labelTradeTxnExtract = null; Integer labelRedeemTxnLoop = null; @@ -395,38 +396,43 @@ public class BTCACCT { // If transaction type is not MESSAGE type then go look for another transaction codeByteBuffer.put(OpCode.BNE_DAT.compile(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop))); - /* Check transaction's sender. We're expecting AT creator's trade address. */ + /* Check transaction's sender. We're expecting AT creator's trade address for 'trade' message, or AT creator's own address for 'cancel' message. */ // Extract sender address from transaction into B register codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_ADDRESS_FROM_TX_IN_A_INTO_B)); // Save B register into data segment starting at addrMessageSender1 (as pointed to by addrMessageSenderPointer) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrMessageSenderPointer)); - // Compare each part of transaction's sender's address with expected address. If they don't match, look for another transaction. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelTradeTxnLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelTradeTxnLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelTradeTxnLoop))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelTradeTxnLoop))); + // Compare each part of message sender's address with AT creator's trade address. If they don't match, check for cancel situation. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorTradeAddress1, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorTradeAddress2, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorTradeAddress3, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorTradeAddress4, calcOffset(codeByteBuffer, labelCheckCancelTxn))); + // Message sender's address matches AT creator's trade address so go process 'trade' message + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelCheckNonRefundTradeTxn == null ? 0 : labelCheckNonRefundTradeTxn)); - /* Extract trade partner info from message */ + /* Checking message sender for possible cancel message */ + labelCheckCancelTxn = codeByteBuffer.position(); - // Extract message from transaction into B register - codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); - // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); - // Compare each of partner address with creator's address (for offer-cancel scenario). If they don't match, assume address is trade partner. - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); - codeByteBuffer.put(OpCode.BNE_DAT.compile(addrQortalPartnerAddress4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelCheckNonRefundTradeTxn))); + // Compare each part of message sender's address with AT creator's address. If they don't match, look for another transaction. + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender1, addrCreatorAddress1, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender2, addrCreatorAddress2, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); + codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); // Partner address is AT creator's address, so cancel offer and finish. codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.CANCELLED.value)); // We're finished forever (finishing auto-refunds remaining balance to AT creator) codeByteBuffer.put(OpCode.FIN_IMD.compile()); + /* Not trade nor cancel message */ + labelNotTradeNorCancelTxn = codeByteBuffer.position(); + + // Loop to find another transaction + codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop)); + /* Possible switch-to-trade-mode message */ labelCheckNonRefundTradeTxn = codeByteBuffer.position(); - // Not offer-cancel scenario so check we received expected number of message bytes + // Check 'trade' message we received has expected number of message bytes codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(QortalFunctionCode.GET_MESSAGE_LENGTH_FROM_TX_IN_A.value, addrMessageLength)); // If message length matches, branch to info extraction code codeByteBuffer.put(OpCode.BEQ_DAT.compile(addrMessageLength, addrExpectedTradeMessageLength, calcOffset(codeByteBuffer, labelTradeTxnExtract))); @@ -436,9 +442,13 @@ public class BTCACCT { /* Extracting info from 'trade' MESSAGE transaction */ labelTradeTxnExtract = codeByteBuffer.position(); - // Message is expected length, grab next 32 bytes - codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset)); + // Extract message from transaction into B register + codeByteBuffer.put(OpCode.EXT_FUN.compile(FunctionCode.PUT_MESSAGE_FROM_TX_IN_A_INTO_B)); + // Save B register into data segment starting at addrQortalPartnerAddress1 (as pointed to by addrQortalPartnerAddressPointer) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrQortalPartnerAddressPointer)); + // Extract trade partner's Bitcoin public key hash (PKH) + codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerBitcoinPKHOffset)); // Extract partner's Bitcoin PKH (we only really use values from B1-B3) codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerBitcoinPKHPointer)); // Also extract lockTimeB diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/btcacct/AtTests.java index b271075c..fd187938 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/btcacct/AtTests.java @@ -52,7 +52,6 @@ public class AtTests extends Common { public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; public static final long bitcoinAmount = 864200L; - public static final byte[] bitcoinReceivePublicKeyHash = HashCode.fromString("00112233445566778899aabbccddeeff").asBytes(); private static final Random RANDOM = new Random(); @@ -63,9 +62,9 @@ public class AtTests extends Common { @Test public void testCompile() { - Account deployer = Common.getTestAccount(null, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(null); - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + byte[] creationBytes = BTCACCT.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @@ -73,12 +72,14 @@ public class AtTests extends Common { public void testDeploy() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee(); long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -120,12 +121,14 @@ public class AtTests extends Common { public void testOfferCancel() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -140,14 +143,6 @@ public class AtTests extends Common { // AT should process 'cancel' message in next block BlockUtils.mintBlock(repository); - long expectedMinimumBalance = deployersPostDeploymentBalance; - long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; - - long actualBalance = deployer.getConfirmedBalance(Asset.QORT); - - assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); - assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); - describeAt(repository, atAddress); // Check AT is finished @@ -158,9 +153,19 @@ public class AtTests extends Common { CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); + // Check balances + long expectedMinimumBalance = deployersPostDeploymentBalance; + long expectedMaximumBalance = deployersInitialBalance - deployAtFee - messageFee; + + long actualBalance = deployer.getConfirmedBalance(Asset.QORT); + + assertTrue(String.format("Deployer's balance %s should be above minimum %s", actualBalance, expectedMinimumBalance), actualBalance > expectedMinimumBalance); + assertTrue(String.format("Deployer's balance %s should be below maximum %s", actualBalance, expectedMaximumBalance), actualBalance < expectedMaximumBalance); + // Test orphaning BlockUtils.orphanLastBlock(repository); + // Check balances long expectedBalance = deployersPostDeploymentBalance - messageFee; actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -173,12 +178,14 @@ public class AtTests extends Common { public void testOfferCancelInvalidLength() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -192,18 +199,18 @@ public class AtTests extends Common { long messageFee = messageTransaction.getTransactionData().getFee(); // AT should process 'cancel' message in next block - // As message is too short, it will be padded to 32bytes and (probably) contain incorrect sender to be valid cancel + // As message is too short, it will be padded to 32bytes but cancel code doesn't care about message content, so should be ok BlockUtils.mintBlock(repository); describeAt(repository, atAddress); - // Check AT is NOT finished + // Check AT is finished ATData atData = repository.getATRepository().fromATAddress(atAddress); - assertFalse(atData.getIsFinished()); + assertTrue(atData.getIsFinished()); - // AT should still be in OFFERING mode + // AT should be in CANCELLED mode CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.OFFERING, tradeData.mode); + assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); } } @@ -212,12 +219,14 @@ public class AtTests extends Common { public void testTradingInfoProcessing() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -227,14 +236,13 @@ public class AtTests extends Common { // Send trade info to AT byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); Block postDeploymentBlock = BlockUtils.mintBlock(repository); int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long messageFee = messageTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee - messageFee; + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; describeAt(repository, atAddress); @@ -256,6 +264,7 @@ public class AtTests extends Common { // Test orphaning BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + // Check balances long expectedBalance = deployersPostDeploymentBalance; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -269,13 +278,16 @@ public class AtTests extends Common { public void testIncorrectTradeSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -309,12 +321,14 @@ public class AtTests extends Common { public void testAutomaticTradeRefund() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -324,14 +338,14 @@ public class AtTests extends Common { // Send trade info to AT byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); Block postDeploymentBlock = BlockUtils.mintBlock(repository); int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight(); + // Check refund long deployAtFee = deployAtTransaction.getTransactionData().getFee(); - long messageFee = messageTransaction.getTransactionData().getFee(); - long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee - messageFee; + long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); @@ -348,6 +362,7 @@ public class AtTests extends Common { // Test orphaning BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); + // Check balances long expectedBalance = deployersPostDeploymentBalance; long actualBalance = deployer.getConfirmedBalance(Asset.QORT); @@ -360,12 +375,14 @@ public class AtTests extends Common { public void testCorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -375,7 +392,7 @@ public class AtTests extends Common { // Send trade info to AT byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); @@ -398,6 +415,7 @@ public class AtTests extends Common { CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); assertEquals(BTCACCT.Mode.REDEEMED, tradeData.mode); + // Check balances long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; long actualBalance = partner.getConfirmedBalance(Asset.QORT); @@ -406,6 +424,7 @@ public class AtTests extends Common { // Orphan redeem BlockUtils.orphanLastBlock(repository); + // Check balances expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); actualBalance = partner.getConfirmedBalance(Asset.QORT); @@ -423,13 +442,16 @@ public class AtTests extends Common { public void testCorrectSecretsIncorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); + PrivateKeyAccount bystander = Common.getTestAccount(repository, "bob"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); Account at = deployAtTransaction.getATAccount(); @@ -441,7 +463,7 @@ public class AtTests extends Common { // Send trade info to AT byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); @@ -464,11 +486,13 @@ public class AtTests extends Common { CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + // Check balances long expectedBalance = partnersInitialBalance; long actualBalance = partner.getConfirmedBalance(Asset.QORT); assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + // Check eventual refund checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -478,12 +502,14 @@ public class AtTests extends Common { public void testIncorrectSecretsCorrectSender() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); long deployAtFee = deployAtTransaction.getTransactionData().getFee(); Account at = deployAtTransaction.getATAccount(); @@ -495,7 +521,7 @@ public class AtTests extends Common { // Send trade info to AT byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); @@ -542,11 +568,13 @@ public class AtTests extends Common { tradeData = BTCACCT.populateTradeData(repository, atData); assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + // Check balances expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2; actualBalance = partner.getConfirmedBalance(Asset.QORT); assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); + // Check eventual refund checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee); } } @@ -556,12 +584,14 @@ public class AtTests extends Common { public void testCorrectSecretsCorrectSenderInvalidMessageLength() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); Account at = deployAtTransaction.getATAccount(); String atAddress = at.getAddress(); @@ -571,7 +601,7 @@ public class AtTests extends Common { // Send trade info to AT byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress); + MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress); // Give AT time to process message BlockUtils.mintBlock(repository); @@ -601,12 +631,14 @@ public class AtTests extends Common { public void testDescribeDeployed() throws DataException { try (final Repository repository = RepositoryManager.getRepository()) { PrivateKeyAccount deployer = Common.getTestAccount(repository, "chloe"); + PrivateKeyAccount tradeAccount = createTradeAccount(repository); + PrivateKeyAccount partner = Common.getTestAccount(repository, "dilbert"); long deployersInitialBalance = deployer.getConfirmedBalance(Asset.QORT); long partnersInitialBalance = partner.getConfirmedBalance(Asset.QORT); - DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer); + DeployAtTransaction deployAtTransaction = doDeploy(repository, deployer, tradeAccount.getAddress()); List executableAts = repository.getATRepository().getAllExecutableATs(); @@ -634,8 +666,8 @@ public class AtTests extends Common { return (int) (messageTimestamp / 1000L + tradeTimeout * 60); } - private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer) throws DataException { - byte[] creationBytes = BTCACCT.buildQortalAT(deployer.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException { + byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = deployer.getLastReference(); @@ -752,4 +784,9 @@ public class AtTests extends Common { } } + private PrivateKeyAccount createTradeAccount(Repository repository) { + // We actually use a known test account with funds to avoid PoW compute + return Common.getTestAccount(repository, "alice"); + } + } From 8f2985862dfc30364592a100eac1974c36fee0a7 Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 6 Aug 2020 08:52:51 +0100 Subject: [PATCH 51/51] Update BTC-ACCT 'cancel' API call to expect AT creator's as sender --- .../api/model/CrossChainCancelRequest.java | 4 +-- .../api/resource/CrossChainResource.java | 35 ++++++++++++------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java index f87471d0..25a18952 100644 --- a/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java +++ b/src/main/java/org/qortal/api/model/CrossChainCancelRequest.java @@ -8,8 +8,8 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainCancelRequest { - @Schema(description = "AT creator's 'trade' public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") - public byte[] tradePublicKey; + @Schema(description = "AT creator's public key", example = "K6wuddsBV3HzRrXFFezE7P5MoRXp5m3mEDokRDGZB6ry") + public byte[] creatorPublicKey; @Schema(description = "Qortal trade AT address") public String atAddress; diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 40654179..6901af06 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -380,7 +380,7 @@ public class CrossChainResource { summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer", description = "Specify address of cross-chain AT that needs to be cancelled.
    " + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
    " - + "You need to sign output with trade's private key otherwise the MESSAGE transaction will be invalid.", + + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.", requestBody = @RequestBody( required = true, content = @Content( @@ -404,9 +404,9 @@ public class CrossChainResource { public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { Security.checkApiCallAllowed(request); - byte[] tradePublicKey = cancelRequest.tradePublicKey; + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; - if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) @@ -419,16 +419,16 @@ public class CrossChainResource { if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - // Does supplied public key match trade public key? - if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) + // Does supplied public key match AT creator's public key? + if (!Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); // Good to make MESSAGE - String atCreatorAddress = crossChainTradeData.qortalCreator; + String atCreatorAddress = Crypto.toAddress(creatorPublicKey); byte[] messageData = BTCACCT.buildCancelMessage(atCreatorAddress); - byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, cancelRequest.atAddress, messageData); + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData); return Base58.encode(messageTransactionBytes); } catch (DataException e) { @@ -1222,12 +1222,18 @@ public class CrossChainResource { } private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException { - // senderPublicKey is actually ephemeral trade public key, so there is no corresponding account and hence no reference long txTimestamp = NTP.getTime(); - Random random = new Random(); - byte[] lastReference = new byte[Transformer.SIGNATURE_LENGTH]; - random.nextBytes(lastReference); + // senderPublicKey could be ephemeral trade public key where there is no corresponding account and hence no reference + String senderAddress = Crypto.toAddress(senderPublicKey); + byte[] lastReference = repository.getAccountRepository().getLastReference(senderAddress); + final boolean requiresPoW = lastReference == null; + + if (requiresPoW) { + Random random = new Random(); + lastReference = new byte[Transformer.SIGNATURE_LENGTH]; + random.nextBytes(lastReference); + } int version = 4; int nonce = 0; @@ -1240,7 +1246,12 @@ public class CrossChainResource { MessageTransaction messageTransaction = new MessageTransaction(repository, messageTransactionData); - messageTransaction.computeNonce(); + if (requiresPoW) { + messageTransaction.computeNonce(); + } else { + fee = messageTransaction.calcRecommendedFee(); + messageTransactionData.setFee(fee); + } ValidationResult result = messageTransaction.isValidUnconfirmed(); if (result != ValidationResult.OK)