From 04d691991aafb285bc207a1ee57d768850eb33ae Mon Sep 17 00:00:00 2001 From: catbref Date: Thu, 11 Jun 2020 09:33:33 +0100 Subject: [PATCH] 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()); } }