forked from Qortal/qortal
Merge branch 'LTCv3-with-presence' into master
This commit is contained in:
commit
6eb9447bb9
18
pom.xml
18
pom.xml
@ -7,7 +7,8 @@
|
|||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
<properties>
|
<properties>
|
||||||
<skipTests>true</skipTests>
|
<skipTests>true</skipTests>
|
||||||
<bitcoinj.version>0.15.5</bitcoinj.version>
|
<altcoinj.version>bf9fb80</altcoinj.version>
|
||||||
|
<bitcoinj.version>0.15.6</bitcoinj.version>
|
||||||
<bouncycastle.version>1.64</bouncycastle.version>
|
<bouncycastle.version>1.64</bouncycastle.version>
|
||||||
<build.timestamp>${maven.build.timestamp}</build.timestamp>
|
<build.timestamp>${maven.build.timestamp}</build.timestamp>
|
||||||
<ciyam-at.version>1.3.8</ciyam-at.version>
|
<ciyam-at.version>1.3.8</ciyam-at.version>
|
||||||
@ -199,6 +200,10 @@
|
|||||||
<pattern>org.qortal.api.model**</pattern>
|
<pattern>org.qortal.api.model**</pattern>
|
||||||
<template>${project.build.sourceDirectory}/org/qortal/data/package-info.java</template>
|
<template>${project.build.sourceDirectory}/org/qortal/data/package-info.java</template>
|
||||||
</package>
|
</package>
|
||||||
|
<package>
|
||||||
|
<pattern>org.qortal.api.model.**</pattern>
|
||||||
|
<template>${project.build.sourceDirectory}/org/qortal/data/package-info.java</template>
|
||||||
|
</package>
|
||||||
</packages>
|
</packages>
|
||||||
<outputDirectory>${project.build.directory}/generated-sources/package-info</outputDirectory>
|
<outputDirectory>${project.build.directory}/generated-sources/package-info</outputDirectory>
|
||||||
</configuration>
|
</configuration>
|
||||||
@ -383,6 +388,11 @@
|
|||||||
<name>project</name>
|
<name>project</name>
|
||||||
<url>file:${project.basedir}/lib</url>
|
<url>file:${project.basedir}/lib</url>
|
||||||
</repository>
|
</repository>
|
||||||
|
<!-- jitpack for build-on-demand of altcoinj -->
|
||||||
|
<repository>
|
||||||
|
<id>jitpack.io</id>
|
||||||
|
<url>https://jitpack.io</url>
|
||||||
|
</repository>
|
||||||
</repositories>
|
</repositories>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<!-- https://mvnrepository.com/artifact/org.codehaus.mojo/build-helper-maven-plugin -->
|
<!-- https://mvnrepository.com/artifact/org.codehaus.mojo/build-helper-maven-plugin -->
|
||||||
@ -417,6 +427,12 @@
|
|||||||
<artifactId>bitcoinj-core</artifactId>
|
<artifactId>bitcoinj-core</artifactId>
|
||||||
<version>${bitcoinj.version}</version>
|
<version>${bitcoinj.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- For Litecoin, etc. support, requires bitcoinj -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.jjos2372</groupId>
|
||||||
|
<artifactId>altcoinj</artifactId>
|
||||||
|
<version>${altcoinj.version}</version>
|
||||||
|
</dependency>
|
||||||
<!-- Utilities -->
|
<!-- Utilities -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.googlecode.json-simple</groupId>
|
<groupId>com.googlecode.json-simple</groupId>
|
||||||
|
@ -15,7 +15,7 @@ public enum ApiError {
|
|||||||
// COMMON
|
// COMMON
|
||||||
// UNKNOWN(0, 500),
|
// UNKNOWN(0, 500),
|
||||||
JSON(1, 400),
|
JSON(1, 400),
|
||||||
// NO_BALANCE(2, 422),
|
INSUFFICIENT_BALANCE(2, 402),
|
||||||
// NOT_YET_RELEASED(3, 422),
|
// NOT_YET_RELEASED(3, 422),
|
||||||
UNAUTHORIZED(4, 403),
|
UNAUTHORIZED(4, 403),
|
||||||
REPOSITORY_ISSUE(5, 500),
|
REPOSITORY_ISSUE(5, 500),
|
||||||
@ -126,10 +126,10 @@ public enum ApiError {
|
|||||||
// Groups
|
// Groups
|
||||||
GROUP_UNKNOWN(1101, 404),
|
GROUP_UNKNOWN(1101, 404),
|
||||||
|
|
||||||
// Bitcoin
|
// Foreign blockchain
|
||||||
BTC_NETWORK_ISSUE(1201, 500),
|
FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500),
|
||||||
BTC_BALANCE_ISSUE(1202, 402),
|
FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402),
|
||||||
BTC_TOO_SOON(1203, 408);
|
FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408);
|
||||||
|
|
||||||
private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError));
|
private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError));
|
||||||
|
|
||||||
|
@ -43,6 +43,7 @@ import org.qortal.api.websocket.ActiveChatsWebSocket;
|
|||||||
import org.qortal.api.websocket.AdminStatusWebSocket;
|
import org.qortal.api.websocket.AdminStatusWebSocket;
|
||||||
import org.qortal.api.websocket.BlocksWebSocket;
|
import org.qortal.api.websocket.BlocksWebSocket;
|
||||||
import org.qortal.api.websocket.ChatMessagesWebSocket;
|
import org.qortal.api.websocket.ChatMessagesWebSocket;
|
||||||
|
import org.qortal.api.websocket.PresenceWebSocket;
|
||||||
import org.qortal.api.websocket.TradeBotWebSocket;
|
import org.qortal.api.websocket.TradeBotWebSocket;
|
||||||
import org.qortal.api.websocket.TradeOffersWebSocket;
|
import org.qortal.api.websocket.TradeOffersWebSocket;
|
||||||
import org.qortal.settings.Settings;
|
import org.qortal.settings.Settings;
|
||||||
@ -200,6 +201,7 @@ public class ApiService {
|
|||||||
context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages");
|
context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages");
|
||||||
context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers");
|
context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers");
|
||||||
context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot");
|
context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot");
|
||||||
|
context.addServlet(PresenceWebSocket.class, "/websockets/presence");
|
||||||
|
|
||||||
// Start server
|
// Start server
|
||||||
this.server.start();
|
this.server.start();
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
package org.qortal.api.model;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
|
|
||||||
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 CrossChainBitcoinP2SHStatus {
|
|
||||||
|
|
||||||
@Schema(description = "Bitcoin P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)")
|
|
||||||
public String bitcoinP2shAddress;
|
|
||||||
|
|
||||||
@Schema(description = "Bitcoin P2SH balance")
|
|
||||||
public BigDecimal bitcoinP2shBalance;
|
|
||||||
|
|
||||||
@Schema(description = "Can P2SH redeem yet?")
|
|
||||||
public boolean canRedeem;
|
|
||||||
|
|
||||||
@Schema(description = "Can P2SH refund yet?")
|
|
||||||
public boolean canRefund;
|
|
||||||
|
|
||||||
@Schema(description = "Secret extracted by P2SH redeemer")
|
|
||||||
public byte[] secret;
|
|
||||||
|
|
||||||
public CrossChainBitcoinP2SHStatus() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -0,0 +1,31 @@
|
|||||||
|
package org.qortal.api.model;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
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 CrossChainBitcoinyHTLCStatus {
|
||||||
|
|
||||||
|
@Schema(description = "P2SH address", example = "3CdH27kTpV8dcFHVRYjQ8EEV5FJg9X8pSJ (mainnet), 2fMiRRXVsxhZeyfum9ifybZvaMHbQTmwdZw (testnet)")
|
||||||
|
public String bitcoinP2shAddress;
|
||||||
|
|
||||||
|
@Schema(description = "P2SH balance")
|
||||||
|
public BigDecimal bitcoinP2shBalance;
|
||||||
|
|
||||||
|
@Schema(description = "Can HTLC redeem yet?")
|
||||||
|
public boolean canRedeem;
|
||||||
|
|
||||||
|
@Schema(description = "Can HTLC refund yet?")
|
||||||
|
public boolean canRefund;
|
||||||
|
|
||||||
|
@Schema(description = "Secret used by HTLC redeemer")
|
||||||
|
public byte[] secret;
|
||||||
|
|
||||||
|
public CrossChainBitcoinyHTLCStatus() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -4,7 +4,7 @@ import javax.xml.bind.annotation.XmlAccessType;
|
|||||||
import javax.xml.bind.annotation.XmlAccessorType;
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||||
|
|
||||||
import org.qortal.crosschain.BTCACCT;
|
import org.qortal.crosschain.AcctMode;
|
||||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
@ -16,26 +16,41 @@ public class CrossChainOfferSummary {
|
|||||||
// Properties
|
// Properties
|
||||||
|
|
||||||
@Schema(description = "AT's Qortal address")
|
@Schema(description = "AT's Qortal address")
|
||||||
public String qortalAtAddress;
|
private String qortalAtAddress;
|
||||||
|
|
||||||
@Schema(description = "AT creator's Qortal address")
|
@Schema(description = "AT creator's Qortal address")
|
||||||
public String qortalCreator;
|
private String qortalCreator;
|
||||||
|
|
||||||
|
@Schema(description = "AT creator's ephemeral trading key-pair represented as Qortal address")
|
||||||
|
private String qortalCreatorTradeAddress;
|
||||||
|
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
private long qortAmount;
|
private long qortAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Bitcoin amount - DEPRECATED: use foreignAmount")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
@Deprecated
|
||||||
private long btcAmount;
|
private long btcAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Foreign blockchain amount")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
private long foreignAmount;
|
||||||
|
|
||||||
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
|
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
|
||||||
private int tradeTimeout;
|
private int tradeTimeout;
|
||||||
|
|
||||||
private BTCACCT.Mode mode;
|
@Schema(description = "Current AT execution mode")
|
||||||
|
private AcctMode mode;
|
||||||
|
|
||||||
private long timestamp;
|
private long timestamp;
|
||||||
|
|
||||||
|
@Schema(description = "Trade partner's Qortal receiving address")
|
||||||
private String partnerQortalReceivingAddress;
|
private String partnerQortalReceivingAddress;
|
||||||
|
|
||||||
|
private String foreignBlockchain;
|
||||||
|
|
||||||
|
private String acctName;
|
||||||
|
|
||||||
protected CrossChainOfferSummary() {
|
protected CrossChainOfferSummary() {
|
||||||
/* For JAXB */
|
/* For JAXB */
|
||||||
}
|
}
|
||||||
@ -43,12 +58,16 @@ public class CrossChainOfferSummary {
|
|||||||
public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
||||||
this.qortalAtAddress = crossChainTradeData.qortalAtAddress;
|
this.qortalAtAddress = crossChainTradeData.qortalAtAddress;
|
||||||
this.qortalCreator = crossChainTradeData.qortalCreator;
|
this.qortalCreator = crossChainTradeData.qortalCreator;
|
||||||
|
this.qortalCreatorTradeAddress = crossChainTradeData.qortalCreatorTradeAddress;
|
||||||
this.qortAmount = crossChainTradeData.qortAmount;
|
this.qortAmount = crossChainTradeData.qortAmount;
|
||||||
this.btcAmount = crossChainTradeData.expectedBitcoin;
|
this.foreignAmount = crossChainTradeData.expectedForeignAmount;
|
||||||
|
this.btcAmount = this.foreignAmount; // Duplicate for deprecated field
|
||||||
this.tradeTimeout = crossChainTradeData.tradeTimeout;
|
this.tradeTimeout = crossChainTradeData.tradeTimeout;
|
||||||
this.mode = crossChainTradeData.mode;
|
this.mode = crossChainTradeData.mode;
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress;
|
this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress;
|
||||||
|
this.foreignBlockchain = crossChainTradeData.foreignBlockchain;
|
||||||
|
this.acctName = crossChainTradeData.acctName;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getQortalAtAddress() {
|
public String getQortalAtAddress() {
|
||||||
@ -59,6 +78,10 @@ public class CrossChainOfferSummary {
|
|||||||
return this.qortalCreator;
|
return this.qortalCreator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getQortalCreatorTradeAddress() {
|
||||||
|
return this.qortalCreatorTradeAddress;
|
||||||
|
}
|
||||||
|
|
||||||
public long getQortAmount() {
|
public long getQortAmount() {
|
||||||
return this.qortAmount;
|
return this.qortAmount;
|
||||||
}
|
}
|
||||||
@ -67,11 +90,15 @@ public class CrossChainOfferSummary {
|
|||||||
return this.btcAmount;
|
return this.btcAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getForeignAmount() {
|
||||||
|
return this.foreignAmount;
|
||||||
|
}
|
||||||
|
|
||||||
public int getTradeTimeout() {
|
public int getTradeTimeout() {
|
||||||
return this.tradeTimeout;
|
return this.tradeTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BTCACCT.Mode getMode() {
|
public AcctMode getMode() {
|
||||||
return this.mode;
|
return this.mode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,10 +110,18 @@ public class CrossChainOfferSummary {
|
|||||||
return this.partnerQortalReceivingAddress;
|
return this.partnerQortalReceivingAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getForeignBlockchain() {
|
||||||
|
return this.foreignBlockchain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAcctName() {
|
||||||
|
return this.acctName;
|
||||||
|
}
|
||||||
|
|
||||||
// For debugging mostly
|
// For debugging mostly
|
||||||
|
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return String.format("%s: %s", this.qortalAtAddress, this.mode.name());
|
return String.format("%s: %s", this.qortalAtAddress, this.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,8 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
|||||||
|
|
||||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
// All properties to be converted to JSON via JAXB
|
// All properties to be converted to JSON via JAXB
|
||||||
@XmlAccessorType(XmlAccessType.FIELD)
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
public class CrossChainTradeSummary {
|
public class CrossChainTradeSummary {
|
||||||
@ -15,9 +17,14 @@ public class CrossChainTradeSummary {
|
|||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
private long qortAmount;
|
private long qortAmount;
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
@Schema(description = "DEPRECATED: use foreignAmount instead")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
private long btcAmount;
|
private long btcAmount;
|
||||||
|
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
private long foreignAmount;
|
||||||
|
|
||||||
protected CrossChainTradeSummary() {
|
protected CrossChainTradeSummary() {
|
||||||
/* For JAXB */
|
/* For JAXB */
|
||||||
}
|
}
|
||||||
@ -25,7 +32,8 @@ public class CrossChainTradeSummary {
|
|||||||
public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) {
|
||||||
this.tradeTimestamp = timestamp;
|
this.tradeTimestamp = timestamp;
|
||||||
this.qortAmount = crossChainTradeData.qortAmount;
|
this.qortAmount = crossChainTradeData.qortAmount;
|
||||||
this.btcAmount = crossChainTradeData.expectedBitcoin;
|
this.foreignAmount = crossChainTradeData.expectedForeignAmount;
|
||||||
|
this.btcAmount = this.foreignAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getTradeTimestamp() {
|
public long getTradeTimestamp() {
|
||||||
@ -40,4 +48,7 @@ public class CrossChainTradeSummary {
|
|||||||
return this.btcAmount;
|
return this.btcAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public long getForeignAmount() {
|
||||||
|
return this.foreignAmount;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
157
src/main/java/org/qortal/api/model/SimpleForeignTransaction.java
Normal file
157
src/main/java/org/qortal/api/model/SimpleForeignTransaction.java
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package org.qortal.api.model;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
public class SimpleForeignTransaction {
|
||||||
|
|
||||||
|
public static class AddressAmount {
|
||||||
|
public final String address;
|
||||||
|
public final long amount;
|
||||||
|
|
||||||
|
protected AddressAmount() {
|
||||||
|
/* For JAXB */
|
||||||
|
this.address = null;
|
||||||
|
this.amount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AddressAmount(String address, long amount) {
|
||||||
|
this.address = address;
|
||||||
|
this.amount = amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String txHash;
|
||||||
|
private long timestamp;
|
||||||
|
|
||||||
|
private List<AddressAmount> inputs;
|
||||||
|
|
||||||
|
public static class Output {
|
||||||
|
public final List<String> addresses;
|
||||||
|
public final long amount;
|
||||||
|
|
||||||
|
protected Output() {
|
||||||
|
/* For JAXB */
|
||||||
|
this.addresses = null;
|
||||||
|
this.amount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Output(List<String> addresses, long amount) {
|
||||||
|
this.addresses = addresses;
|
||||||
|
this.amount = amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private List<Output> outputs;
|
||||||
|
|
||||||
|
private long totalAmount;
|
||||||
|
private long fees;
|
||||||
|
|
||||||
|
private Boolean isSentNotReceived;
|
||||||
|
|
||||||
|
protected SimpleForeignTransaction() {
|
||||||
|
/* For JAXB */
|
||||||
|
}
|
||||||
|
|
||||||
|
private SimpleForeignTransaction(Builder builder) {
|
||||||
|
this.txHash = builder.txHash;
|
||||||
|
this.timestamp = builder.timestamp;
|
||||||
|
this.inputs = Collections.unmodifiableList(builder.inputs);
|
||||||
|
this.outputs = Collections.unmodifiableList(builder.outputs);
|
||||||
|
|
||||||
|
Objects.requireNonNull(this.txHash);
|
||||||
|
if (timestamp <= 0)
|
||||||
|
throw new IllegalArgumentException("timestamp must be positive");
|
||||||
|
|
||||||
|
long totalGrossAmount = this.inputs.stream().map(addressAmount -> addressAmount.amount).reduce(0L, Long::sum);
|
||||||
|
this.totalAmount = this.outputs.stream().map(addressAmount -> addressAmount.amount).reduce(0L, Long::sum);
|
||||||
|
|
||||||
|
this.fees = totalGrossAmount - this.totalAmount;
|
||||||
|
|
||||||
|
this.isSentNotReceived = builder.isSentNotReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTxHash() {
|
||||||
|
return this.txHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTimestamp() {
|
||||||
|
return this.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AddressAmount> getInputs() {
|
||||||
|
return this.inputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Output> getOutputs() {
|
||||||
|
return this.outputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTotalAmount() {
|
||||||
|
return this.totalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getFees() {
|
||||||
|
return this.fees;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean isSentNotReceived() {
|
||||||
|
return this.isSentNotReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private String txHash;
|
||||||
|
private long timestamp;
|
||||||
|
private List<AddressAmount> inputs = new ArrayList<>();
|
||||||
|
private List<Output> outputs = new ArrayList<>();
|
||||||
|
private Boolean isSentNotReceived;
|
||||||
|
|
||||||
|
public Builder txHash(String txHash) {
|
||||||
|
this.txHash = Objects.requireNonNull(txHash);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder timestamp(long timestamp) {
|
||||||
|
if (timestamp <= 0)
|
||||||
|
throw new IllegalArgumentException("timestamp must be positive");
|
||||||
|
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder input(String address, long amount) {
|
||||||
|
Objects.requireNonNull(address);
|
||||||
|
if (amount < 0)
|
||||||
|
throw new IllegalArgumentException("amount must be zero or positive");
|
||||||
|
|
||||||
|
AddressAmount input = new AddressAmount(address, amount);
|
||||||
|
inputs.add(input);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder output(List<String> addresses, long amount) {
|
||||||
|
Objects.requireNonNull(addresses);
|
||||||
|
if (amount < 0)
|
||||||
|
throw new IllegalArgumentException("amount must be zero or positive");
|
||||||
|
|
||||||
|
Output output = new Output(addresses, amount);
|
||||||
|
outputs.add(output);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Builder isSentNotReceived(Boolean isSentNotReceived) {
|
||||||
|
this.isSentNotReceived = isSentNotReceived;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public SimpleForeignTransaction build() {
|
||||||
|
return new SimpleForeignTransaction(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,23 +0,0 @@
|
|||||||
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 = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
|
||||||
public String atAddress;
|
|
||||||
|
|
||||||
@Schema(description = "Bitcoin BIP32 extended private key", example = "xprv___________________________________________________________________________________________________________")
|
|
||||||
public String xprv58;
|
|
||||||
|
|
||||||
@Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")
|
|
||||||
public String receivingAddress;
|
|
||||||
|
|
||||||
public TradeBotRespondRequest() {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,4 +1,4 @@
|
|||||||
package org.qortal.api.model;
|
package org.qortal.api.model.crosschain;
|
||||||
|
|
||||||
import javax.xml.bind.annotation.XmlAccessType;
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
import javax.xml.bind.annotation.XmlAccessorType;
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
@ -9,16 +9,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
@XmlAccessorType(XmlAccessType.FIELD)
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
public class BitcoinSendRequest {
|
public class BitcoinSendRequest {
|
||||||
|
|
||||||
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ")
|
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________")
|
||||||
public String xprv58;
|
public String xprv58;
|
||||||
|
|
||||||
@Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE")
|
@Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE")
|
||||||
public String receivingAddress;
|
public String receivingAddress;
|
||||||
|
|
||||||
@Schema(description = "Amount of BTC to send")
|
@Schema(description = "Amount of BTC to send", type = "number")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
public long bitcoinAmount;
|
public long bitcoinAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 BTC (100 sats) per byte", example = "0.00000100", type = "number")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
public Long feePerByte;
|
||||||
|
|
||||||
public BitcoinSendRequest() {
|
public BitcoinSendRequest() {
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
|||||||
|
package org.qortal.api.model.crosschain;
|
||||||
|
|
||||||
|
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 LitecoinSendRequest {
|
||||||
|
|
||||||
|
@Schema(description = "Litecoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________")
|
||||||
|
public String xprv58;
|
||||||
|
|
||||||
|
@Schema(description = "Recipient's Litecoin address ('legacy' P2PKH only)", example = "LiTecoinEaterAddressDontSendhLfzKD")
|
||||||
|
public String receivingAddress;
|
||||||
|
|
||||||
|
@Schema(description = "Amount of LTC to send", type = "number")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
public long litecoinAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 LTC (100 sats) per byte", example = "0.00000100", type = "number")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
public Long feePerByte;
|
||||||
|
|
||||||
|
public LitecoinSendRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
package org.qortal.api.model;
|
package org.qortal.api.model.crosschain;
|
||||||
|
|
||||||
import javax.xml.bind.annotation.XmlAccessType;
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
import javax.xml.bind.annotation.XmlAccessorType;
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||||
|
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
@XmlAccessorType(XmlAccessType.FIELD)
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
@ -12,22 +14,30 @@ public class TradeBotCreateRequest {
|
|||||||
@Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB")
|
@Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB")
|
||||||
public byte[] creatorPublicKey;
|
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 = "80.40000000", type = "number")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
public long qortAmount;
|
public long qortAmount;
|
||||||
|
|
||||||
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "81")
|
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "80.50000000", type = "number")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
public long fundingQortAmount;
|
public long fundingQortAmount;
|
||||||
|
|
||||||
@Schema(description = "Bitcoin amount wanted in return", example = "0.00864200")
|
@Deprecated
|
||||||
|
@Schema(description = "Bitcoin amount wanted in return. DEPRECATED: use foreignAmount instead", example = "0.00864200", type = "number", hidden = true)
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
public long bitcoinAmount;
|
public Long bitcoinAmount;
|
||||||
|
|
||||||
|
@Schema(description = "Foreign blockchain. Note: default (BITCOIN) to be removed in the future", example = "BITCOIN", implementation = SupportedBlockchain.class)
|
||||||
|
public SupportedBlockchain foreignBlockchain;
|
||||||
|
|
||||||
|
@Schema(description = "Foreign blockchain amount wanted in return", example = "0.00864200", type = "number")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
public Long foreignAmount;
|
||||||
|
|
||||||
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
|
@Schema(description = "Suggested trade timeout (minutes)", example = "10080")
|
||||||
public int tradeTimeout;
|
public int tradeTimeout;
|
||||||
|
|
||||||
@Schema(description = "Bitcoin address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE")
|
@Schema(description = "Foreign blockchain address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE")
|
||||||
public String receivingAddress;
|
public String receivingAddress;
|
||||||
|
|
||||||
public TradeBotCreateRequest() {
|
public TradeBotCreateRequest() {
|
@ -0,0 +1,29 @@
|
|||||||
|
package org.qortal.api.model.crosschain;
|
||||||
|
|
||||||
|
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 = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
|
||||||
|
public String atAddress;
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
@Schema(description = "Bitcoin BIP32 extended private key. DEPRECATED: use foreignKey instead", hidden = true,
|
||||||
|
example = "xprv___________________________________________________________________________________________________________")
|
||||||
|
public String xprv58;
|
||||||
|
|
||||||
|
@Schema(description = "Foreign blockchain private key, e.g. BIP32 'm' key for Bitcoin/Litecoin starting with 'xprv'",
|
||||||
|
example = "xprv___________________________________________________________________________________________________________")
|
||||||
|
public String foreignKey;
|
||||||
|
|
||||||
|
@Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")
|
||||||
|
public String receivingAddress;
|
||||||
|
|
||||||
|
public TradeBotRespondRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,363 @@
|
|||||||
|
package org.qortal.api.resource;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.core.Context;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
import org.qortal.account.PublicKeyAccount;
|
||||||
|
import org.qortal.api.ApiError;
|
||||||
|
import org.qortal.api.ApiErrors;
|
||||||
|
import org.qortal.api.ApiExceptionFactory;
|
||||||
|
import org.qortal.api.Security;
|
||||||
|
import org.qortal.api.model.CrossChainBuildRequest;
|
||||||
|
import org.qortal.api.model.CrossChainSecretRequest;
|
||||||
|
import org.qortal.api.model.CrossChainTradeRequest;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.crosschain.BitcoinACCTv1;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.AcctMode;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.data.at.ATData;
|
||||||
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
|
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.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;
|
||||||
|
import org.qortal.transform.transaction.DeployAtTransactionTransformer;
|
||||||
|
import org.qortal.transform.transaction.MessageTransactionTransformer;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
@Path("/crosschain/BitcoinACCTv1")
|
||||||
|
@Tag(name = "Cross-Chain (BitcoinACCTv1)")
|
||||||
|
public class CrossChainBitcoinACCTv1Resource {
|
||||||
|
|
||||||
|
@Context
|
||||||
|
HttpServletRequest request;
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/build")
|
||||||
|
@Operation(
|
||||||
|
summary = "Build Bitcoin cross-chain trading AT",
|
||||||
|
description = "Returns raw, unsigned DEPLOY_AT transaction",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(
|
||||||
|
implementation = CrossChainBuildRequest.class
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@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)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||||
|
|
||||||
|
if (tradeRequest.hashOfSecretB == null || tradeRequest.hashOfSecretB.length != Bitcoiny.HASH160_LENGTH)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||||
|
|
||||||
|
if (tradeRequest.tradeTimeout == null)
|
||||||
|
tradeRequest.tradeTimeout = 7 * 24 * 60; // 7 days
|
||||||
|
else
|
||||||
|
if (tradeRequest.tradeTimeout < 10 || tradeRequest.tradeTimeout > 50000)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||||
|
|
||||||
|
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.qortAmount)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||||
|
|
||||||
|
if (tradeRequest.bitcoinAmount <= 0)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
PublicKeyAccount creatorAccount = new PublicKeyAccount(repository, creatorPublicKey);
|
||||||
|
|
||||||
|
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(creatorAccount.getAddress(), tradeRequest.bitcoinPublicKeyHash, tradeRequest.hashOfSecretB,
|
||||||
|
tradeRequest.qortAmount, tradeRequest.bitcoinAmount, tradeRequest.tradeTimeout);
|
||||||
|
|
||||||
|
long txTimestamp = NTP.getTime();
|
||||||
|
byte[] lastReference = creatorAccount.getLastReference();
|
||||||
|
if (lastReference == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_REFERENCE);
|
||||||
|
|
||||||
|
long fee = 0;
|
||||||
|
String name = "QORT-BTC cross-chain trade";
|
||||||
|
String description = "Qortal-Bitcoin cross-chain trade";
|
||||||
|
String atType = "ACCT";
|
||||||
|
String tags = "QORT-BTC ACCT";
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null);
|
||||||
|
TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, tradeRequest.fundingQortAmount, Asset.QORT);
|
||||||
|
|
||||||
|
Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
|
||||||
|
|
||||||
|
fee = deployAtTransaction.calcRecommendedFee();
|
||||||
|
deployAtTransactionData.setFee(fee);
|
||||||
|
|
||||||
|
ValidationResult result = deployAtTransaction.isValidUnconfirmed();
|
||||||
|
if (result != ValidationResult.OK)
|
||||||
|
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||||
|
|
||||||
|
byte[] bytes = DeployAtTransactionTransformer.toBytes(deployAtTransactionData);
|
||||||
|
return Base58.encode(bytes);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||||
|
} catch (DataException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/trademessage")
|
||||||
|
@Operation(
|
||||||
|
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.<br>"
|
||||||
|
+ "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!<br>"
|
||||||
|
+ "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(
|
||||||
|
implementation = CrossChainTradeRequest.class
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||||
|
public String buildTradeMessage(CrossChainTradeRequest tradeRequest) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
byte[] tradePublicKey = tradeRequest.tradePublicKey;
|
||||||
|
|
||||||
|
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.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress);
|
||||||
|
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
|
if (crossChainTradeData.mode != AcctMode.OFFERING)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
// Does supplied public key match trade public key?
|
||||||
|
if (!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();
|
||||||
|
BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData);
|
||||||
|
if (offerMessageData == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
|
||||||
|
|
||||||
|
// Good to make MESSAGE
|
||||||
|
|
||||||
|
byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH;
|
||||||
|
byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
|
||||||
|
int lockTimeA = (int) offerMessageData.lockTimeA;
|
||||||
|
|
||||||
|
String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
|
||||||
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
|
||||||
|
|
||||||
|
byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
|
byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData);
|
||||||
|
|
||||||
|
return Base58.encode(messageTransactionBytes);
|
||||||
|
} catch (DataException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/redeemmessage")
|
||||||
|
@Operation(
|
||||||
|
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.<br>"
|
||||||
|
+ "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!<br>"
|
||||||
|
+ "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(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(
|
||||||
|
implementation = CrossChainSecretRequest.class
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
|
||||||
|
public String buildRedeemMessage(CrossChainSecretRequest secretRequest) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
byte[] partnerPublicKey = secretRequest.partnerPublicKey;
|
||||||
|
|
||||||
|
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))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
if (secretRequest.secretA == null || secretRequest.secretA.length != BitcoinACCTv1.SECRET_LENGTH)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||||
|
|
||||||
|
if (secretRequest.secretB == null || secretRequest.secretB.length != BitcoinACCTv1.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 = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
|
if (crossChainTradeData.mode != AcctMode.TRADING)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
String partnerAddress = Crypto.toAddress(partnerPublicKey);
|
||||||
|
|
||||||
|
// 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 = BitcoinACCTv1.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress);
|
||||||
|
byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData);
|
||||||
|
|
||||||
|
return Base58.encode(messageTransactionBytes);
|
||||||
|
} 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)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||||
|
|
||||||
|
// Must be correct AT - check functionality using code hash
|
||||||
|
if (!Arrays.equals(atData.getCodeHash(), BitcoinACCTv1.CODE_BYTES_HASH))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
// No point sending message to AT that's finished
|
||||||
|
if (atData.getIsFinished())
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
return atData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] buildAtMessage(Repository repository, byte[] senderPublicKey, String atAddress, byte[] messageData) throws DataException {
|
||||||
|
long txTimestamp = NTP.getTime();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
long amount = 0L;
|
||||||
|
Long assetId = null; // no assetId as amount is zero
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (requiresPoW) {
|
||||||
|
messageTransaction.computeNonce();
|
||||||
|
} else {
|
||||||
|
fee = messageTransaction.calcRecommendedFee();
|
||||||
|
messageTransactionData.setFee(fee);
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidationResult result = messageTransaction.isValidUnconfirmed();
|
||||||
|
if (result != ValidationResult.OK)
|
||||||
|
throw TransactionsResource.createTransactionInvalidException(request, result);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return MessageTransactionTransformer.toBytes(messageTransactionData);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,167 @@
|
|||||||
|
package org.qortal.api.resource;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.core.Context;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Transaction;
|
||||||
|
import org.qortal.api.ApiError;
|
||||||
|
import org.qortal.api.ApiErrors;
|
||||||
|
import org.qortal.api.ApiExceptionFactory;
|
||||||
|
import org.qortal.api.Security;
|
||||||
|
import org.qortal.api.model.crosschain.BitcoinSendRequest;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.BitcoinyTransaction;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
|
||||||
|
@Path("/crosschain/btc")
|
||||||
|
@Tag(name = "Cross-Chain (Bitcoin)")
|
||||||
|
public class CrossChainBitcoinResource {
|
||||||
|
|
||||||
|
@Context
|
||||||
|
HttpServletRequest request;
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/walletbalance")
|
||||||
|
@Operation(
|
||||||
|
summary = "Returns BTC balance for hierarchical, deterministic BIP32 wallet",
|
||||||
|
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN,
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string",
|
||||||
|
description = "BIP32 'm' private/public key in base58",
|
||||||
|
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||||
|
public String getBitcoinWalletBalance(String key58) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||||
|
|
||||||
|
if (!bitcoin.isValidDeterministicKey(key58))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
Long balance = bitcoin.getWalletBalance(key58);
|
||||||
|
if (balance == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
|
||||||
|
return balance.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/wallettransactions")
|
||||||
|
@Operation(
|
||||||
|
summary = "Returns transactions for hierarchical, deterministic BIP32 wallet",
|
||||||
|
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN,
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string",
|
||||||
|
description = "BIP32 'm' private/public key in base58",
|
||||||
|
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) )
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||||
|
public List<BitcoinyTransaction> getBitcoinWalletTransactions(String key58) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||||
|
|
||||||
|
if (!bitcoin.isValidDeterministicKey(key58))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return bitcoin.getWalletTransactions(key58);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/send")
|
||||||
|
@Operation(
|
||||||
|
summary = "Sends BTC from hierarchical, deterministic BIP32 wallet to specific address",
|
||||||
|
description = "Currently only supports 'legacy' P2PKH Bitcoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(
|
||||||
|
implementation = BitcoinSendRequest.class
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||||
|
public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
if (bitcoinSendRequest.bitcoinAmount <= 0)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
if (bitcoinSendRequest.feePerByte != null && bitcoinSendRequest.feePerByte <= 0)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
Bitcoin bitcoin = Bitcoin.getInstance();
|
||||||
|
|
||||||
|
if (!bitcoin.isValidAddress(bitcoinSendRequest.receivingAddress))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
if (!bitcoin.isValidDeterministicKey(bitcoinSendRequest.xprv58))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
Transaction spendTransaction = bitcoin.buildSpend(bitcoinSendRequest.xprv58,
|
||||||
|
bitcoinSendRequest.receivingAddress,
|
||||||
|
bitcoinSendRequest.bitcoinAmount,
|
||||||
|
bitcoinSendRequest.feePerByte);
|
||||||
|
|
||||||
|
if (spendTransaction == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
bitcoin.broadcastTransaction(spendTransaction);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spendTransaction.getTxId().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,175 @@
|
|||||||
|
package org.qortal.api.resource;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.ws.rs.GET;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.PathParam;
|
||||||
|
import javax.ws.rs.core.Context;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.TransactionOutput;
|
||||||
|
import org.qortal.api.ApiError;
|
||||||
|
import org.qortal.api.ApiErrors;
|
||||||
|
import org.qortal.api.ApiExceptionFactory;
|
||||||
|
import org.qortal.api.Security;
|
||||||
|
import org.qortal.api.model.CrossChainBitcoinyHTLCStatus;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
@Path("/crosschain/htlc")
|
||||||
|
@Tag(name = "Cross-Chain (Hash time-locked contracts)")
|
||||||
|
public class CrossChainHtlcResource {
|
||||||
|
|
||||||
|
@Context
|
||||||
|
HttpServletRequest request;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}")
|
||||||
|
@Operation(
|
||||||
|
summary = "Returns HTLC address based on trade info",
|
||||||
|
description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.",
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA})
|
||||||
|
public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName,
|
||||||
|
@PathParam("refundPKH") String refundHex,
|
||||||
|
@PathParam("locktime") int lockTime,
|
||||||
|
@PathParam("redeemPKH") String redeemHex,
|
||||||
|
@PathParam("hashOfSecret") String hashOfSecretHex) {
|
||||||
|
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||||
|
if (blockchain == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
byte[] refunderPubKeyHash;
|
||||||
|
byte[] redeemerPubKeyHash;
|
||||||
|
byte[] hashOfSecret;
|
||||||
|
|
||||||
|
try {
|
||||||
|
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
|
||||||
|
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
|
||||||
|
|
||||||
|
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
|
||||||
|
if (hashOfSecret.length != 20)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
|
||||||
|
|
||||||
|
return bitcoiny.deriveP2shAddress(redeemScript);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}")
|
||||||
|
@Operation(
|
||||||
|
summary = "Checks HTLC status",
|
||||||
|
description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.",
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
|
||||||
|
public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName,
|
||||||
|
@PathParam("refundPKH") String refundHex,
|
||||||
|
@PathParam("locktime") int lockTime,
|
||||||
|
@PathParam("redeemPKH") String redeemHex,
|
||||||
|
@PathParam("hashOfSecret") String hashOfSecretHex) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
|
||||||
|
if (blockchain == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
byte[] refunderPubKeyHash;
|
||||||
|
byte[] redeemerPubKeyHash;
|
||||||
|
byte[] hashOfSecret;
|
||||||
|
|
||||||
|
try {
|
||||||
|
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
|
||||||
|
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
|
||||||
|
|
||||||
|
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
|
||||||
|
if (hashOfSecret.length != 20)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
|
||||||
|
|
||||||
|
String p2shAddress = bitcoiny.deriveP2shAddress(redeemScript);
|
||||||
|
|
||||||
|
long now = NTP.getTime();
|
||||||
|
|
||||||
|
try {
|
||||||
|
int medianBlockTime = bitcoiny.getMedianBlockTime();
|
||||||
|
|
||||||
|
// Check P2SH is funded
|
||||||
|
long p2shBalance = bitcoiny.getConfirmedBalance(p2shAddress.toString());
|
||||||
|
|
||||||
|
CrossChainBitcoinyHTLCStatus htlcStatus = new CrossChainBitcoinyHTLCStatus();
|
||||||
|
htlcStatus.bitcoinP2shAddress = p2shAddress;
|
||||||
|
htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8);
|
||||||
|
|
||||||
|
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString());
|
||||||
|
|
||||||
|
if (p2shBalance > 0L && !fundingOutputs.isEmpty()) {
|
||||||
|
htlcStatus.canRedeem = now >= medianBlockTime * 1000L;
|
||||||
|
htlcStatus.canRefund = now >= lockTime * 1000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= medianBlockTime * 1000L) {
|
||||||
|
// See if we can extract secret
|
||||||
|
htlcStatus.secret = BitcoinyHTLC.findHtlcSecret(bitcoiny, htlcStatus.bitcoinP2shAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return htlcStatus;
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: refund
|
||||||
|
|
||||||
|
// TODO: redeem
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,167 @@
|
|||||||
|
package org.qortal.api.resource;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.core.Context;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Transaction;
|
||||||
|
import org.qortal.api.ApiError;
|
||||||
|
import org.qortal.api.ApiErrors;
|
||||||
|
import org.qortal.api.ApiExceptionFactory;
|
||||||
|
import org.qortal.api.Security;
|
||||||
|
import org.qortal.api.model.crosschain.LitecoinSendRequest;
|
||||||
|
import org.qortal.crosschain.BitcoinyTransaction;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
|
||||||
|
@Path("/crosschain/ltc")
|
||||||
|
@Tag(name = "Cross-Chain (Litecoin)")
|
||||||
|
public class CrossChainLitecoinResource {
|
||||||
|
|
||||||
|
@Context
|
||||||
|
HttpServletRequest request;
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/walletbalance")
|
||||||
|
@Operation(
|
||||||
|
summary = "Returns LTC balance for hierarchical, deterministic BIP32 wallet",
|
||||||
|
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN,
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string",
|
||||||
|
description = "BIP32 'm' private/public key in base58",
|
||||||
|
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "balance (satoshis)"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||||
|
public String getLitecoinWalletBalance(String key58) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
|
||||||
|
if (!litecoin.isValidDeterministicKey(key58))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
Long balance = litecoin.getWalletBalance(key58);
|
||||||
|
if (balance == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
|
||||||
|
return balance.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/wallettransactions")
|
||||||
|
@Operation(
|
||||||
|
summary = "Returns transactions for hierarchical, deterministic BIP32 wallet",
|
||||||
|
description = "Supply BIP32 'm' private/public key in base58, starting with 'xprv'/'xpub' for mainnet, 'tprv'/'tpub' for testnet",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN,
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string",
|
||||||
|
description = "BIP32 'm' private/public key in base58",
|
||||||
|
example = "tpubD6NzVbkrYhZ4XTPc4btCZ6SMgn8CxmWkj6VBVZ1tfcJfMq4UwAjZbG8U74gGSypL9XBYk2R2BLbDBe8pcEyBKM1edsGQEPKXNbEskZozeZc"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(array = @ArraySchema( schema = @Schema( implementation = BitcoinyTransaction.class ) ) )
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||||
|
public List<BitcoinyTransaction> getLitecoinWalletTransactions(String key58) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
|
||||||
|
if (!litecoin.isValidDeterministicKey(key58))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return litecoin.getWalletTransactions(key58);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/send")
|
||||||
|
@Operation(
|
||||||
|
summary = "Sends LTC from hierarchical, deterministic BIP32 wallet to specific address",
|
||||||
|
description = "Currently only supports 'legacy' P2PKH Litecoin addresses. Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.APPLICATION_JSON,
|
||||||
|
schema = @Schema(
|
||||||
|
implementation = LitecoinSendRequest.class
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string", description = "transaction hash"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
|
||||||
|
public String sendBitcoin(LitecoinSendRequest litecoinSendRequest) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
if (litecoinSendRequest.litecoinAmount <= 0)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
if (litecoinSendRequest.feePerByte != null && litecoinSendRequest.feePerByte <= 0)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
|
||||||
|
if (!litecoin.isValidAddress(litecoinSendRequest.receivingAddress))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
if (!litecoin.isValidDeterministicKey(litecoinSendRequest.xprv58))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
Transaction spendTransaction = litecoin.buildSpend(litecoinSendRequest.xprv58,
|
||||||
|
litecoinSendRequest.receivingAddress,
|
||||||
|
litecoinSendRequest.litecoinAmount,
|
||||||
|
litecoinSendRequest.feePerByte);
|
||||||
|
|
||||||
|
if (spendTransaction == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||||
|
|
||||||
|
try {
|
||||||
|
litecoin.broadcastTransaction(spendTransaction);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return spendTransaction.getTxId().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,286 @@
|
|||||||
|
package org.qortal.api.resource;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.ws.rs.DELETE;
|
||||||
|
import javax.ws.rs.GET;
|
||||||
|
import javax.ws.rs.POST;
|
||||||
|
import javax.ws.rs.Path;
|
||||||
|
import javax.ws.rs.QueryParam;
|
||||||
|
import javax.ws.rs.core.Context;
|
||||||
|
import javax.ws.rs.core.MediaType;
|
||||||
|
|
||||||
|
import org.qortal.account.Account;
|
||||||
|
import org.qortal.account.PublicKeyAccount;
|
||||||
|
import org.qortal.api.ApiError;
|
||||||
|
import org.qortal.api.ApiErrors;
|
||||||
|
import org.qortal.api.ApiExceptionFactory;
|
||||||
|
import org.qortal.api.Security;
|
||||||
|
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||||
|
import org.qortal.api.model.crosschain.TradeBotRespondRequest;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.controller.tradebot.AcctTradeBot;
|
||||||
|
import org.qortal.controller.tradebot.TradeBot;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchain;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
import org.qortal.crosschain.ACCT;
|
||||||
|
import org.qortal.crosschain.AcctMode;
|
||||||
|
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.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
|
||||||
|
@Path("/crosschain/tradebot")
|
||||||
|
@Tag(name = "Cross-Chain (Trade-Bot)")
|
||||||
|
public class CrossChainTradeBotResource {
|
||||||
|
|
||||||
|
@Context
|
||||||
|
HttpServletRequest request;
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Operation(
|
||||||
|
summary = "List current trade-bot states",
|
||||||
|
responses = {
|
||||||
|
@ApiResponse(
|
||||||
|
content = @Content(
|
||||||
|
array = @ArraySchema(
|
||||||
|
schema = @Schema(
|
||||||
|
implementation = TradeBotData.class
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.REPOSITORY_ISSUE})
|
||||||
|
public List<TradeBotData> getTradeBotStates(
|
||||||
|
@Parameter(
|
||||||
|
description = "Limit to specific blockchain",
|
||||||
|
example = "LITECOIN",
|
||||||
|
schema = @Schema(implementation = SupportedBlockchain.class)
|
||||||
|
) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||||
|
|
||||||
|
if (foreignBlockchain == null)
|
||||||
|
return allTradeBotData;
|
||||||
|
|
||||||
|
return allTradeBotData.stream().filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain.name())).collect(Collectors.toList());
|
||||||
|
} catch (DataException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/create")
|
||||||
|
@Operation(
|
||||||
|
summary = "Create a trade offer (trade-bot entry)",
|
||||||
|
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.INVALID_CRITERIA, ApiError.INSUFFICIENT_BALANCE, ApiError.REPOSITORY_ISSUE})
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
if (tradeBotCreateRequest.foreignBlockchain == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
ForeignBlockchain foreignBlockchain = tradeBotCreateRequest.foreignBlockchain.getInstance();
|
||||||
|
|
||||||
|
// We prefer foreignAmount to deprecated bitcoinAmount
|
||||||
|
if (tradeBotCreateRequest.foreignAmount == null)
|
||||||
|
tradeBotCreateRequest.foreignAmount = tradeBotCreateRequest.bitcoinAmount;
|
||||||
|
|
||||||
|
if (!foreignBlockchain.isValidAddress(tradeBotCreateRequest.receivingAddress))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
if (tradeBotCreateRequest.tradeTimeout < 60)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
if (tradeBotCreateRequest.foreignAmount == null || tradeBotCreateRequest.foreignAmount <= 0)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
if (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);
|
||||||
|
|
||||||
|
if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INSUFFICIENT_BALANCE);
|
||||||
|
|
||||||
|
byte[] unsignedBytes = TradeBot.getInstance().createTrade(repository, tradeBotCreateRequest);
|
||||||
|
if (unsignedBytes == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
return Base58.encode(unsignedBytes);
|
||||||
|
} catch (DataException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/respond")
|
||||||
|
@Operation(
|
||||||
|
summary = "Respond to a trade offer. NOTE: WILL SPEND FUNDS!)",
|
||||||
|
description = "Start a new trade-bot entry to respond to chosen trade offer.",
|
||||||
|
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"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE})
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) {
|
||||||
|
Security.checkApiCallAllowed(request);
|
||||||
|
|
||||||
|
final String atAddress = tradeBotRespondRequest.atAddress;
|
||||||
|
|
||||||
|
// We prefer foreignKey to deprecated xprv58
|
||||||
|
if (tradeBotRespondRequest.foreignKey == null)
|
||||||
|
tradeBotRespondRequest.foreignKey = tradeBotRespondRequest.xprv58;
|
||||||
|
|
||||||
|
if (tradeBotRespondRequest.foreignKey == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
if (atAddress == null || !Crypto.isValidAtAddress(atAddress))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// TradeBot uses AT's code hash to map to ACCT
|
||||||
|
ACCT acct = TradeBot.getInstance().getAcctUsingAtData(atData);
|
||||||
|
if (acct == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
if (!acct.getBlockchain().isValidWalletKey(tradeBotRespondRequest.foreignKey))
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
|
||||||
|
|
||||||
|
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData);
|
||||||
|
if (crossChainTradeData == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
|
||||||
|
|
||||||
|
if (crossChainTradeData.mode != AcctMode.OFFERING)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
AcctTradeBot.ResponseResult result = TradeBot.getInstance().startResponse(repository, atData, acct, crossChainTradeData,
|
||||||
|
tradeBotRespondRequest.foreignKey, tradeBotRespondRequest.receivingAddress);
|
||||||
|
|
||||||
|
switch (result) {
|
||||||
|
case OK:
|
||||||
|
return "true";
|
||||||
|
|
||||||
|
case BALANCE_ISSUE:
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
|
||||||
|
|
||||||
|
case NETWORK_ISSUE:
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "false";
|
||||||
|
}
|
||||||
|
} catch (DataException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@DELETE
|
||||||
|
@Operation(
|
||||||
|
summary = "Delete completed trade",
|
||||||
|
requestBody = @RequestBody(
|
||||||
|
required = true,
|
||||||
|
content = @Content(
|
||||||
|
mediaType = MediaType.TEXT_PLAIN,
|
||||||
|
schema = @Schema(
|
||||||
|
type = "string",
|
||||||
|
example = "93MB2qRDNVLxbmmPuYpLdAqn3u2x9ZhaVZK5wELHueP8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
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()) {
|
||||||
|
// Handed off to TradeBot
|
||||||
|
return TradeBot.getInstance().deleteEntry(repository, tradePrivateKey) ? "true" : "false";
|
||||||
|
} 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)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.ADDRESS_UNKNOWN);
|
||||||
|
|
||||||
|
// No point sending message to AT that's finished
|
||||||
|
if (atData.getIsFinished())
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
|
||||||
|
|
||||||
|
return atData;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -510,14 +510,19 @@ public class TransactionsResource {
|
|||||||
if (!Controller.getInstance().isUpToDate())
|
if (!Controller.getInstance().isUpToDate())
|
||||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC);
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BLOCKCHAIN_NEEDS_SYNC);
|
||||||
|
|
||||||
|
byte[] rawBytes = Base58.decode(rawBytes58);
|
||||||
|
|
||||||
|
TransactionData transactionData;
|
||||||
|
try {
|
||||||
|
transactionData = TransactionTransformer.fromBytes(rawBytes);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactionData == null)
|
||||||
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
byte[] rawBytes = Base58.decode(rawBytes58);
|
|
||||||
|
|
||||||
TransactionData transactionData = TransactionTransformer.fromBytes(rawBytes);
|
|
||||||
|
|
||||||
if (transactionData == null)
|
|
||||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
||||||
|
|
||||||
Transaction transaction = Transaction.fromData(repository, transactionData);
|
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||||
|
|
||||||
if (!transaction.isSignatureValid())
|
if (!transaction.isSignatureValid())
|
||||||
@ -535,16 +540,9 @@ public class TransactionsResource {
|
|||||||
blockchainLock.unlock();
|
blockchainLock.unlock();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify controller of new transaction
|
|
||||||
Controller.getInstance().onNewTransaction(transactionData, null);
|
|
||||||
|
|
||||||
return "true";
|
return "true";
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e);
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA, e);
|
||||||
} catch (TransformationException e) {
|
|
||||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSFORMATION_ERROR, e);
|
|
||||||
} catch (ApiException e) {
|
|
||||||
throw e;
|
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
|
@ -107,7 +107,9 @@ abstract class ApiWebSocket extends WebSocketServlet {
|
|||||||
|
|
||||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||||
synchronized (SESSIONS_BY_CLASS) {
|
synchronized (SESSIONS_BY_CLASS) {
|
||||||
SESSIONS_BY_CLASS.get(this.getClass()).remove(session);
|
List<Session> sessions = SESSIONS_BY_CLASS.get(this.getClass());
|
||||||
|
if (sessions != null)
|
||||||
|
sessions.remove(session);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
244
src/main/java/org/qortal/api/websocket/PresenceWebSocket.java
Normal file
244
src/main/java/org/qortal/api/websocket/PresenceWebSocket.java
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
package org.qortal.api.websocket;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
|
||||||
|
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.OnWebSocketError;
|
||||||
|
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||||
|
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||||
|
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.TransactionData;
|
||||||
|
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.transaction.PresenceTransaction.PresenceType;
|
||||||
|
import org.qortal.transaction.Transaction.TransactionType;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
@WebSocket
|
||||||
|
@SuppressWarnings("serial")
|
||||||
|
public class PresenceWebSocket extends ApiWebSocket implements Listener {
|
||||||
|
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
private static class PresenceInfo {
|
||||||
|
private final PresenceType presenceType;
|
||||||
|
private final String publicKey;
|
||||||
|
private final long timestamp;
|
||||||
|
private final String address;
|
||||||
|
|
||||||
|
protected PresenceInfo() {
|
||||||
|
this.presenceType = null;
|
||||||
|
this.publicKey = null;
|
||||||
|
this.timestamp = 0L;
|
||||||
|
this.address = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceInfo(PresenceType presenceType, String pubKey58, long timestamp) {
|
||||||
|
this.presenceType = presenceType;
|
||||||
|
this.publicKey = pubKey58;
|
||||||
|
this.timestamp = timestamp;
|
||||||
|
this.address = Crypto.toAddress(Base58.decode(this.publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceType getPresenceType() {
|
||||||
|
return this.presenceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPublicKey() {
|
||||||
|
return this.publicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getTimestamp() {
|
||||||
|
return this.timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAddress() {
|
||||||
|
return this.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Outer map key is PresenceType (enum), inner map key is public key in base58, inner map value is timestamp */
|
||||||
|
private static final Map<PresenceType, Map<String, Long>> currentEntries = Collections.synchronizedMap(new EnumMap<>(PresenceType.class));
|
||||||
|
|
||||||
|
/** (Optional) PresenceType used for filtering by that Session. */
|
||||||
|
private static final Map<Session, PresenceType> sessionPresenceTypes = Collections.synchronizedMap(new HashMap<>());
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configure(WebSocketServletFactory factory) {
|
||||||
|
factory.register(PresenceWebSocket.class);
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
populateCurrentInfo(repository);
|
||||||
|
} catch (DataException e) {
|
||||||
|
// How to fail properly?
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EventBus.INSTANCE.addListener(this::listen);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void listen(Event event) {
|
||||||
|
// We use NewBlockEvent as a proxy for 1-minute timer
|
||||||
|
if (!(event instanceof Controller.NewTransactionEvent) && !(event instanceof Controller.NewBlockEvent))
|
||||||
|
return;
|
||||||
|
|
||||||
|
removeOldEntries();
|
||||||
|
|
||||||
|
if (event instanceof Controller.NewBlockEvent)
|
||||||
|
// We only wanted a chance to cull old entries
|
||||||
|
return;
|
||||||
|
|
||||||
|
TransactionData transactionData = ((Controller.NewTransactionEvent) event).getTransactionData();
|
||||||
|
|
||||||
|
if (transactionData.getType() != TransactionType.PRESENCE)
|
||||||
|
return;
|
||||||
|
|
||||||
|
PresenceTransactionData presenceData = (PresenceTransactionData) transactionData;
|
||||||
|
PresenceType presenceType = presenceData.getPresenceType();
|
||||||
|
|
||||||
|
// Put/replace for this publickey making sure we keep newest timestamp
|
||||||
|
String pubKey58 = Base58.encode(presenceData.getCreatorPublicKey());
|
||||||
|
long ourTimestamp = presenceData.getTimestamp();
|
||||||
|
long computedTimestamp = mergePresence(presenceType, pubKey58, ourTimestamp);
|
||||||
|
|
||||||
|
if (computedTimestamp != ourTimestamp)
|
||||||
|
// nothing changed
|
||||||
|
return;
|
||||||
|
|
||||||
|
List<PresenceInfo> presenceInfo = Collections.singletonList(new PresenceInfo(presenceType, pubKey58, computedTimestamp));
|
||||||
|
|
||||||
|
// Notify sessions
|
||||||
|
for (Session session : getSessions()) {
|
||||||
|
PresenceType sessionPresenceType = sessionPresenceTypes.get(session);
|
||||||
|
|
||||||
|
if (sessionPresenceType == null || sessionPresenceType == presenceType)
|
||||||
|
sendPresenceInfo(session, presenceInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWebSocketConnect
|
||||||
|
@Override
|
||||||
|
public void onWebSocketConnect(Session session) {
|
||||||
|
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||||
|
List<String> presenceTypes = queryParams.get("presenceType");
|
||||||
|
|
||||||
|
// We only support ONE presenceType
|
||||||
|
String presenceTypeName = presenceTypes == null || presenceTypes.isEmpty() ? null : presenceTypes.get(0);
|
||||||
|
|
||||||
|
PresenceType presenceType = presenceTypeName == null ? null : PresenceType.fromString(presenceTypeName);
|
||||||
|
|
||||||
|
// Make sure that if caller does give a presenceType, that it is a valid/known one.
|
||||||
|
if (presenceTypeName != null && presenceType == null) {
|
||||||
|
session.close(4003, "unknown presenceType: " + presenceTypeName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save session's requested PresenceType, if given
|
||||||
|
if (presenceType != null)
|
||||||
|
sessionPresenceTypes.put(session, presenceType);
|
||||||
|
|
||||||
|
List<PresenceInfo> presenceInfo;
|
||||||
|
|
||||||
|
synchronized (currentEntries) {
|
||||||
|
presenceInfo = currentEntries.entrySet().stream()
|
||||||
|
.filter(entry -> presenceType == null ? true : entry.getKey() == presenceType)
|
||||||
|
.flatMap(entry -> entry.getValue().entrySet().stream().map(innerEntry -> new PresenceInfo(entry.getKey(), innerEntry.getKey(), innerEntry.getValue())))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sendPresenceInfo(session, presenceInfo)) {
|
||||||
|
session.close(4002, "websocket issue");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.onWebSocketConnect(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWebSocketClose
|
||||||
|
@Override
|
||||||
|
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||||
|
// clean up
|
||||||
|
sessionPresenceTypes.remove(session);
|
||||||
|
|
||||||
|
super.onWebSocketClose(session, statusCode, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWebSocketError
|
||||||
|
public void onWebSocketError(Session session, Throwable throwable) {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnWebSocketMessage
|
||||||
|
public void onWebSocketMessage(Session session, String message) {
|
||||||
|
/* ignored */
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sendPresenceInfo(Session session, List<PresenceInfo> presenceInfo) {
|
||||||
|
try {
|
||||||
|
StringWriter stringWriter = new StringWriter();
|
||||||
|
marshall(stringWriter, presenceInfo);
|
||||||
|
|
||||||
|
String output = stringWriter.toString();
|
||||||
|
session.getRemote().sendStringByFuture(output);
|
||||||
|
} catch (IOException e) {
|
||||||
|
// No output this time?
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void populateCurrentInfo(Repository repository) throws DataException {
|
||||||
|
// We want ALL PRESENCE transactions
|
||||||
|
|
||||||
|
List<TransactionData> presenceTransactionsData = repository.getTransactionRepository().getUnconfirmedTransactions(TransactionType.PRESENCE, null);
|
||||||
|
|
||||||
|
for (TransactionData transactionData : presenceTransactionsData) {
|
||||||
|
PresenceTransactionData presenceData = (PresenceTransactionData) transactionData;
|
||||||
|
|
||||||
|
PresenceType presenceType = presenceData.getPresenceType();
|
||||||
|
|
||||||
|
// Put/replace for this publickey making sure we keep newest timestamp
|
||||||
|
String pubKey58 = Base58.encode(presenceData.getCreatorPublicKey());
|
||||||
|
long ourTimestamp = presenceData.getTimestamp();
|
||||||
|
|
||||||
|
mergePresence(presenceType, pubKey58, ourTimestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long mergePresence(PresenceType presenceType, String pubKey58, long ourTimestamp) {
|
||||||
|
Map<String, Long> typedPubkeyTimestamps = currentEntries.computeIfAbsent(presenceType, someType -> Collections.synchronizedMap(new HashMap<>()));
|
||||||
|
return typedPubkeyTimestamps.compute(pubKey58, (somePubKey58, currentTimestamp) -> (currentTimestamp == null || currentTimestamp < ourTimestamp) ? ourTimestamp : currentTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void removeOldEntries() {
|
||||||
|
long now = NTP.getTime();
|
||||||
|
|
||||||
|
currentEntries.entrySet().forEach(entry -> {
|
||||||
|
long expiryThreshold = now - entry.getKey().getLifetime();
|
||||||
|
entry.getValue().entrySet().removeIf(pubkeyTimestamp -> pubkeyTimestamp.getValue() < expiryThreshold);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -15,7 +15,8 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
|
|||||||
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
|
||||||
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
||||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||||
import org.qortal.controller.TradeBot;
|
import org.qortal.controller.tradebot.TradeBot;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
import org.qortal.data.crosschain.TradeBotData;
|
import org.qortal.data.crosschain.TradeBotData;
|
||||||
import org.qortal.event.Event;
|
import org.qortal.event.Event;
|
||||||
import org.qortal.event.EventBus;
|
import org.qortal.event.EventBus;
|
||||||
@ -30,7 +31,9 @@ import org.qortal.utils.Base58;
|
|||||||
public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
||||||
|
|
||||||
/** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */
|
/** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */
|
||||||
private static final Map<String, TradeBotData.State> PREVIOUS_STATES = new HashMap<>();
|
private static final Map<String, Integer> PREVIOUS_STATES = new HashMap<>();
|
||||||
|
|
||||||
|
private static final Map<Session, String> sessionBlockchain = Collections.synchronizedMap(new HashMap<>());
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configure(WebSocketServletFactory factory) {
|
public void configure(WebSocketServletFactory factory) {
|
||||||
@ -42,7 +45,7 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
|||||||
// How do we properly fail here?
|
// How do we properly fail here?
|
||||||
return;
|
return;
|
||||||
|
|
||||||
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getState)));
|
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getStateValue)));
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
// No output this time
|
// No output this time
|
||||||
}
|
}
|
||||||
@ -59,35 +62,59 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
|||||||
String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey());
|
String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey());
|
||||||
|
|
||||||
synchronized (PREVIOUS_STATES) {
|
synchronized (PREVIOUS_STATES) {
|
||||||
if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState())
|
Integer previousStateValue = PREVIOUS_STATES.get(tradePrivateKey58);
|
||||||
|
if (previousStateValue != null && previousStateValue == tradeBotData.getStateValue())
|
||||||
// Not changed
|
// Not changed
|
||||||
return;
|
return;
|
||||||
|
|
||||||
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState());
|
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData);
|
List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData);
|
||||||
for (Session session : getSessions())
|
|
||||||
sendEntries(session, tradeBotEntries);
|
for (Session session : getSessions()) {
|
||||||
|
// Only send if this session has this/no preferred blockchain
|
||||||
|
String preferredBlockchain = sessionBlockchain.get(session);
|
||||||
|
|
||||||
|
if (preferredBlockchain == null || preferredBlockchain.equals(tradeBotData.getForeignBlockchain()))
|
||||||
|
sendEntries(session, tradeBotEntries);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnWebSocketConnect
|
@OnWebSocketConnect
|
||||||
@Override
|
@Override
|
||||||
public void onWebSocketConnect(Session session) {
|
public void onWebSocketConnect(Session session) {
|
||||||
|
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||||
|
|
||||||
|
List<String> foreignBlockchains = queryParams.get("foreignBlockchain");
|
||||||
|
final String foreignBlockchain = foreignBlockchains == null ? null : foreignBlockchains.get(0);
|
||||||
|
|
||||||
|
// Make sure blockchain (if any) is valid
|
||||||
|
if (foreignBlockchain != null && SupportedBlockchain.fromString(foreignBlockchain) == null) {
|
||||||
|
session.close(4003, "unknown blockchain: " + foreignBlockchain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// save session's preferred blockchain (if any)
|
||||||
|
sessionBlockchain.put(session, foreignBlockchain);
|
||||||
|
|
||||||
// Send all known trade-bot entries
|
// Send all known trade-bot entries
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
List<TradeBotData> tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
|
List<TradeBotData> tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData();
|
||||||
if (tradeBotEntries == null) {
|
|
||||||
session.close(4001, "repository issue fetching trade-bot entries");
|
// Optional filtering
|
||||||
return;
|
if (foreignBlockchain != null)
|
||||||
}
|
tradeBotEntries = tradeBotEntries.stream()
|
||||||
|
.filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
if (!sendEntries(session, tradeBotEntries)) {
|
if (!sendEntries(session, tradeBotEntries)) {
|
||||||
session.close(4002, "websocket issue");
|
session.close(4002, "websocket issue");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
// No output this time
|
session.close(4001, "repository issue fetching trade-bot entries");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onWebSocketConnect(session);
|
super.onWebSocketConnect(session);
|
||||||
@ -96,6 +123,9 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
|
|||||||
@OnWebSocketClose
|
@OnWebSocketClose
|
||||||
@Override
|
@Override
|
||||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||||
|
// clean up
|
||||||
|
sessionBlockchain.remove(session);
|
||||||
|
|
||||||
super.onWebSocketClose(session, statusCode, reason);
|
super.onWebSocketClose(session, statusCode, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,10 +3,13 @@ package org.qortal.api.websocket;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.StringWriter;
|
import java.io.StringWriter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
@ -20,7 +23,9 @@ import org.eclipse.jetty.websocket.api.annotations.WebSocket;
|
|||||||
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
|
||||||
import org.qortal.api.model.CrossChainOfferSummary;
|
import org.qortal.api.model.CrossChainOfferSummary;
|
||||||
import org.qortal.controller.Controller;
|
import org.qortal.controller.Controller;
|
||||||
import org.qortal.crosschain.BTCACCT;
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
import org.qortal.crosschain.ACCT;
|
||||||
|
import org.qortal.crosschain.AcctMode;
|
||||||
import org.qortal.data.at.ATStateData;
|
import org.qortal.data.at.ATStateData;
|
||||||
import org.qortal.data.block.BlockData;
|
import org.qortal.data.block.BlockData;
|
||||||
import org.qortal.data.crosschain.CrossChainTradeData;
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
@ -30,6 +35,7 @@ import org.qortal.event.Listener;
|
|||||||
import org.qortal.repository.DataException;
|
import org.qortal.repository.DataException;
|
||||||
import org.qortal.repository.Repository;
|
import org.qortal.repository.Repository;
|
||||||
import org.qortal.repository.RepositoryManager;
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.utils.ByteArray;
|
||||||
import org.qortal.utils.NTP;
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
@WebSocket
|
@WebSocket
|
||||||
@ -38,18 +44,23 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
|
|
||||||
private static final Logger LOGGER = LogManager.getLogger(TradeOffersWebSocket.class);
|
private static final Logger LOGGER = LogManager.getLogger(TradeOffersWebSocket.class);
|
||||||
|
|
||||||
private static final Map<String, BTCACCT.Mode> previousAtModes = new HashMap<>();
|
private static class CachedOfferInfo {
|
||||||
|
public final Map<String, AcctMode> previousAtModes = new HashMap<>();
|
||||||
|
|
||||||
// OFFERING
|
// OFFERING
|
||||||
private static final Map<String, CrossChainOfferSummary> currentSummaries = new HashMap<>();
|
public final Map<String, CrossChainOfferSummary> currentSummaries = new HashMap<>();
|
||||||
// REDEEMED/REFUNDED/CANCELLED
|
// REDEEMED/REFUNDED/CANCELLED
|
||||||
private static final Map<String, CrossChainOfferSummary> historicSummaries = new HashMap<>();
|
public final Map<String, CrossChainOfferSummary> historicSummaries = new HashMap<>();
|
||||||
|
}
|
||||||
|
// Manual synchronization
|
||||||
|
private static final Map<String, CachedOfferInfo> cachedInfoByBlockchain = new HashMap<>();
|
||||||
|
|
||||||
private static final Predicate<CrossChainOfferSummary> isHistoric = offerSummary
|
private static final Predicate<CrossChainOfferSummary> isHistoric = offerSummary
|
||||||
-> offerSummary.getMode() == BTCACCT.Mode.REDEEMED
|
-> offerSummary.getMode() == AcctMode.REDEEMED
|
||||||
|| offerSummary.getMode() == BTCACCT.Mode.REFUNDED
|
|| offerSummary.getMode() == AcctMode.REFUNDED
|
||||||
|| offerSummary.getMode() == BTCACCT.Mode.CANCELLED;
|
|| offerSummary.getMode() == AcctMode.CANCELLED;
|
||||||
|
|
||||||
|
private static final Map<Session, String> sessionBlockchain = Collections.synchronizedMap(new HashMap<>());
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void configure(WebSocketServletFactory factory) {
|
public void configure(WebSocketServletFactory factory) {
|
||||||
@ -75,7 +86,6 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData();
|
BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData();
|
||||||
|
|
||||||
// Process any new info
|
// Process any new info
|
||||||
List<CrossChainOfferSummary> crossChainOfferSummaries;
|
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
// Find any new/changed trade ATs since this block
|
// Find any new/changed trade ATs since this block
|
||||||
@ -84,60 +94,77 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
final Long expectedValue = null;
|
final Long expectedValue = null;
|
||||||
final Integer minimumFinalHeight = blockData.getHeight();
|
final Integer minimumFinalHeight = blockData.getHeight();
|
||||||
|
|
||||||
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
|
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||||
null, null, null);
|
|
||||||
|
|
||||||
if (atStates == null)
|
List<CrossChainOfferSummary> crossChainOfferSummaries = new ArrayList<>();
|
||||||
return;
|
|
||||||
|
|
||||||
crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp());
|
synchronized (cachedInfoByBlockchain) {
|
||||||
|
CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo());
|
||||||
|
|
||||||
|
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||||
|
byte[] codeHash = acctInfo.getKey().value;
|
||||||
|
ACCT acct = acctInfo.getValue().get();
|
||||||
|
|
||||||
|
List<ATStateData> atStates = repository.getATRepository().getMatchingFinalATStates(codeHash,
|
||||||
|
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||||
|
null, null, null);
|
||||||
|
|
||||||
|
crossChainOfferSummaries.addAll(produceSummaries(repository, acct, atStates, blockData.getTimestamp()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any entries unchanged from last time
|
||||||
|
crossChainOfferSummaries.removeIf(offerSummary -> cachedInfo.previousAtModes.get(offerSummary.getQortalAtAddress()) == offerSummary.getMode());
|
||||||
|
|
||||||
|
// Skip to next blockchain if nothing has changed (for this blockchain)
|
||||||
|
if (crossChainOfferSummaries.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Update
|
||||||
|
for (CrossChainOfferSummary offerSummary : crossChainOfferSummaries) {
|
||||||
|
String offerAtAddress = offerSummary.getQortalAtAddress();
|
||||||
|
|
||||||
|
cachedInfo.previousAtModes.put(offerAtAddress, offerSummary.getMode());
|
||||||
|
LOGGER.trace(() -> String.format("Block height: %d, AT: %s, mode: %s", blockData.getHeight(), offerAtAddress, offerSummary.getMode().name()));
|
||||||
|
|
||||||
|
switch (offerSummary.getMode()) {
|
||||||
|
case OFFERING:
|
||||||
|
cachedInfo.currentSummaries.put(offerAtAddress, offerSummary);
|
||||||
|
cachedInfo.historicSummaries.remove(offerAtAddress);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case REDEEMED:
|
||||||
|
case REFUNDED:
|
||||||
|
case CANCELLED:
|
||||||
|
cachedInfo.currentSummaries.remove(offerAtAddress);
|
||||||
|
cachedInfo.historicSummaries.put(offerAtAddress, offerSummary);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case TRADING:
|
||||||
|
cachedInfo.currentSummaries.remove(offerAtAddress);
|
||||||
|
cachedInfo.historicSummaries.remove(offerAtAddress);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any historic offers that are over 24 hours old
|
||||||
|
final long tooOldTimestamp = NTP.getTime() - 24 * 60 * 60 * 1000L;
|
||||||
|
cachedInfo.historicSummaries.values().removeIf(historicSummary -> historicSummary.getTimestamp() < tooOldTimestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify sessions
|
||||||
|
for (Session session : getSessions()) {
|
||||||
|
// Only send if this session has this/no preferred blockchain
|
||||||
|
String preferredBlockchain = sessionBlockchain.get(session);
|
||||||
|
|
||||||
|
if (preferredBlockchain == null || preferredBlockchain.equals(blockchain.name()))
|
||||||
|
sendOfferSummaries(session, crossChainOfferSummaries);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
// No output this time
|
// No output this time
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
synchronized (previousAtModes) {
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
// Update
|
|
||||||
for (CrossChainOfferSummary offerSummary : crossChainOfferSummaries) {
|
|
||||||
previousAtModes.put(offerSummary.qortalAtAddress, offerSummary.getMode());
|
|
||||||
LOGGER.trace(() -> String.format("Block height: %d, AT: %s, mode: %s", blockData.getHeight(), offerSummary.qortalAtAddress, offerSummary.getMode().name()));
|
|
||||||
|
|
||||||
switch (offerSummary.getMode()) {
|
|
||||||
case OFFERING:
|
|
||||||
currentSummaries.put(offerSummary.qortalAtAddress, offerSummary);
|
|
||||||
historicSummaries.remove(offerSummary.qortalAtAddress);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case REDEEMED:
|
|
||||||
case REFUNDED:
|
|
||||||
case CANCELLED:
|
|
||||||
currentSummaries.remove(offerSummary.qortalAtAddress);
|
|
||||||
historicSummaries.put(offerSummary.qortalAtAddress, offerSummary);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case TRADING:
|
|
||||||
currentSummaries.remove(offerSummary.qortalAtAddress);
|
|
||||||
historicSummaries.remove(offerSummary.qortalAtAddress);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any historic offers that are over 24 hours old
|
|
||||||
final long tooOldTimestamp = NTP.getTime() - 24 * 60 * 60 * 1000L;
|
|
||||||
historicSummaries.values().removeIf(historicSummary -> historicSummary.getTimestamp() < tooOldTimestamp);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notify sessions
|
|
||||||
for (Session session : getSessions())
|
|
||||||
sendOfferSummaries(session, crossChainOfferSummaries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnWebSocketConnect
|
@OnWebSocketConnect
|
||||||
@ -146,13 +173,36 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
Map<String, List<String>> queryParams = session.getUpgradeRequest().getParameterMap();
|
||||||
final boolean includeHistoric = queryParams.get("includeHistoric") != null;
|
final boolean includeHistoric = queryParams.get("includeHistoric") != null;
|
||||||
|
|
||||||
|
List<String> foreignBlockchains = queryParams.get("foreignBlockchain");
|
||||||
|
final String foreignBlockchain = foreignBlockchains == null ? null : foreignBlockchains.get(0);
|
||||||
|
|
||||||
|
// Make sure blockchain (if any) is valid
|
||||||
|
if (foreignBlockchain != null && SupportedBlockchain.fromString(foreignBlockchain) == null) {
|
||||||
|
session.close(4003, "unknown blockchain: " + foreignBlockchain);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save session's preferred blockchain, if given
|
||||||
|
if (foreignBlockchain != null)
|
||||||
|
sessionBlockchain.put(session, foreignBlockchain);
|
||||||
|
|
||||||
List<CrossChainOfferSummary> crossChainOfferSummaries = new ArrayList<>();
|
List<CrossChainOfferSummary> crossChainOfferSummaries = new ArrayList<>();
|
||||||
|
|
||||||
synchronized (previousAtModes) {
|
synchronized (cachedInfoByBlockchain) {
|
||||||
crossChainOfferSummaries.addAll(currentSummaries.values());
|
Collection<CachedOfferInfo> cachedInfos;
|
||||||
|
|
||||||
if (includeHistoric)
|
if (foreignBlockchain == null)
|
||||||
crossChainOfferSummaries.addAll(historicSummaries.values());
|
// No preferred blockchain, so iterate through all of them
|
||||||
|
cachedInfos = cachedInfoByBlockchain.values();
|
||||||
|
else
|
||||||
|
cachedInfos = Collections.singleton(cachedInfoByBlockchain.computeIfAbsent(foreignBlockchain, k -> new CachedOfferInfo()));
|
||||||
|
|
||||||
|
for (CachedOfferInfo cachedInfo : cachedInfos) {
|
||||||
|
crossChainOfferSummaries.addAll(cachedInfo.currentSummaries.values());
|
||||||
|
|
||||||
|
if (includeHistoric)
|
||||||
|
crossChainOfferSummaries.addAll(cachedInfo.historicSummaries.values());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sendOfferSummaries(session, crossChainOfferSummaries)) {
|
if (!sendOfferSummaries(session, crossChainOfferSummaries)) {
|
||||||
@ -166,6 +216,9 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
@OnWebSocketClose
|
@OnWebSocketClose
|
||||||
@Override
|
@Override
|
||||||
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
public void onWebSocketClose(Session session, int statusCode, String reason) {
|
||||||
|
// clean up
|
||||||
|
sessionBlockchain.remove(session);
|
||||||
|
|
||||||
super.onWebSocketClose(session, statusCode, reason);
|
super.onWebSocketClose(session, statusCode, reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,22 +250,34 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
private static void populateCurrentSummaries(Repository repository) throws DataException {
|
private static void populateCurrentSummaries(Repository repository) throws DataException {
|
||||||
// We want ALL OFFERING trades
|
// We want ALL OFFERING trades
|
||||||
Boolean isFinished = Boolean.FALSE;
|
Boolean isFinished = Boolean.FALSE;
|
||||||
Integer dataByteOffset = BTCACCT.MODE_BYTE_OFFSET;
|
Long expectedValue = (long) AcctMode.OFFERING.value;
|
||||||
Long expectedValue = (long) BTCACCT.Mode.OFFERING.value;
|
|
||||||
Integer minimumFinalHeight = null;
|
Integer minimumFinalHeight = null;
|
||||||
|
|
||||||
List<ATStateData> initialAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
|
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||||
null, null, null);
|
|
||||||
|
|
||||||
if (initialAtStates == null)
|
CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo());
|
||||||
throw new DataException("Couldn't fetch current trades from repository");
|
|
||||||
|
|
||||||
// Save initial AT modes
|
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||||
previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING)));
|
byte[] codeHash = acctInfo.getKey().value;
|
||||||
|
ACCT acct = acctInfo.getValue().get();
|
||||||
|
|
||||||
// Convert to offer summaries
|
Integer dataByteOffset = acct.getModeByteOffset();
|
||||||
currentSummaries.putAll(produceSummaries(repository, initialAtStates, null).stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, offerSummary -> offerSummary)));
|
List<ATStateData> initialAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash,
|
||||||
|
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||||
|
null, null, null);
|
||||||
|
|
||||||
|
if (initialAtStates == null)
|
||||||
|
throw new DataException("Couldn't fetch current trades from repository");
|
||||||
|
|
||||||
|
// Save initial AT modes
|
||||||
|
cachedInfo.previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> AcctMode.OFFERING)));
|
||||||
|
|
||||||
|
// Convert to offer summaries
|
||||||
|
cachedInfo.currentSummaries.putAll(produceSummaries(repository, acct, initialAtStates, null).stream()
|
||||||
|
.collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, offerSummary -> offerSummary)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void populateHistoricSummaries(Repository repository) throws DataException {
|
private static void populateHistoricSummaries(Repository repository) throws DataException {
|
||||||
@ -228,33 +293,44 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
Long expectedValue = null;
|
Long expectedValue = null;
|
||||||
++minimumFinalHeight; // because height is just *before* timestamp
|
++minimumFinalHeight; // because height is just *before* timestamp
|
||||||
|
|
||||||
List<ATStateData> historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH,
|
for (SupportedBlockchain blockchain : SupportedBlockchain.values()) {
|
||||||
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
Map<ByteArray, Supplier<ACCT>> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain);
|
||||||
null, null, null);
|
|
||||||
|
|
||||||
if (historicAtStates == null)
|
CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo());
|
||||||
throw new DataException("Couldn't fetch historic trades from repository");
|
|
||||||
|
|
||||||
for (ATStateData historicAtState : historicAtStates) {
|
for (Map.Entry<ByteArray, Supplier<ACCT>> acctInfo : acctsByCodeHash.entrySet()) {
|
||||||
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null);
|
byte[] codeHash = acctInfo.getKey().value;
|
||||||
|
ACCT acct = acctInfo.getValue().get();
|
||||||
|
|
||||||
if (!isHistoric.test(historicOfferSummary))
|
List<ATStateData> historicAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash,
|
||||||
continue;
|
isFinished, dataByteOffset, expectedValue, minimumFinalHeight,
|
||||||
|
null, null, null);
|
||||||
|
|
||||||
// Add summary to initial burst
|
if (historicAtStates == null)
|
||||||
historicSummaries.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary);
|
throw new DataException("Couldn't fetch historic trades from repository");
|
||||||
|
|
||||||
// Save initial AT mode
|
for (ATStateData historicAtState : historicAtStates) {
|
||||||
previousAtModes.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary.getMode());
|
CrossChainOfferSummary historicOfferSummary = produceSummary(repository, acct, historicAtState, null);
|
||||||
|
|
||||||
|
if (!isHistoric.test(historicOfferSummary))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Add summary to initial burst
|
||||||
|
cachedInfo.historicSummaries.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary);
|
||||||
|
|
||||||
|
// Save initial AT mode
|
||||||
|
cachedInfo.previousAtModes.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary.getMode());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static CrossChainOfferSummary produceSummary(Repository repository, ATStateData atState, Long timestamp) throws DataException {
|
private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException {
|
||||||
CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState);
|
CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState);
|
||||||
|
|
||||||
long atStateTimestamp;
|
long atStateTimestamp;
|
||||||
|
|
||||||
if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING)
|
if (crossChainTradeData.mode == AcctMode.OFFERING)
|
||||||
// We want when trade was created, not when it was last updated
|
// We want when trade was created, not when it was last updated
|
||||||
atStateTimestamp = crossChainTradeData.creationTimestamp;
|
atStateTimestamp = crossChainTradeData.creationTimestamp;
|
||||||
else
|
else
|
||||||
@ -263,11 +339,11 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener {
|
|||||||
return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp);
|
return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, List<ATStateData> atStates, Long timestamp) throws DataException {
|
private static List<CrossChainOfferSummary> produceSummaries(Repository repository, ACCT acct, List<ATStateData> atStates, Long timestamp) throws DataException {
|
||||||
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
|
List<CrossChainOfferSummary> offerSummaries = new ArrayList<>();
|
||||||
|
|
||||||
for (ATStateData atState : atStates)
|
for (ATStateData atState : atStates)
|
||||||
offerSummaries.add(produceSummary(repository, atState, timestamp));
|
offerSummaries.add(produceSummary(repository, acct, atState, timestamp));
|
||||||
|
|
||||||
return offerSummaries;
|
return offerSummaries;
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,7 @@ import org.ciyam.at.ExecutionException;
|
|||||||
import org.ciyam.at.FunctionData;
|
import org.ciyam.at.FunctionData;
|
||||||
import org.ciyam.at.IllegalFunctionCodeException;
|
import org.ciyam.at.IllegalFunctionCodeException;
|
||||||
import org.ciyam.at.MachineState;
|
import org.ciyam.at.MachineState;
|
||||||
import org.qortal.crosschain.BTC;
|
import org.qortal.crosschain.Bitcoin;
|
||||||
import org.qortal.crypto.Crypto;
|
import org.qortal.crypto.Crypto;
|
||||||
import org.qortal.data.transaction.TransactionData;
|
import org.qortal.data.transaction.TransactionData;
|
||||||
import org.qortal.settings.Settings;
|
import org.qortal.settings.Settings;
|
||||||
@ -108,7 +108,7 @@ public enum QortalFunctionCode {
|
|||||||
CONVERT_B_TO_P2SH(0x0511, 0, false) {
|
CONVERT_B_TO_P2SH(0x0511, 0, false) {
|
||||||
@Override
|
@Override
|
||||||
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
protected void postCheckExecute(FunctionData functionData, MachineState state, short rawFunctionCode) throws ExecutionException {
|
||||||
byte addressPrefix = Settings.getInstance().getBitcoinNet() == BTC.BitcoinNet.MAIN ? 0x05 : (byte) 0xc4;
|
byte addressPrefix = Settings.getInstance().getBitcoinNet() == Bitcoin.BitcoinNet.MAIN ? 0x05 : (byte) 0xc4;
|
||||||
|
|
||||||
convertAddressInB(addressPrefix, state);
|
convertAddressInB(addressPrefix, state);
|
||||||
}
|
}
|
||||||
|
@ -294,8 +294,12 @@ public class BlockMinter extends Thread {
|
|||||||
newBlock.getMinter().getAddress()));
|
newBlock.getMinter().getAddress()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify controller after we're released blockchain lock
|
// Notify network after we're released blockchain lock
|
||||||
newBlockMinted = true;
|
newBlockMinted = true;
|
||||||
|
|
||||||
|
// Notify Controller
|
||||||
|
repository.discardChanges(); // clear transaction status to prevent deadlocks
|
||||||
|
Controller.getInstance().onNewBlock(newBlock.getBlockData());
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
// Unable to process block - report and discard
|
// Unable to process block - report and discard
|
||||||
LOGGER.error("Unable to process newly minted block?", e);
|
LOGGER.error("Unable to process newly minted block?", e);
|
||||||
@ -306,12 +310,9 @@ public class BlockMinter extends Thread {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newBlockMinted) {
|
if (newBlockMinted) {
|
||||||
// Notify Controller and broadcast our new chain to network
|
// Broadcast our new chain to network
|
||||||
BlockData newBlockData = newBlock.getBlockData();
|
BlockData newBlockData = newBlock.getBlockData();
|
||||||
|
|
||||||
repository.discardChanges(); // clear transaction status to prevent deadlocks
|
|
||||||
Controller.getInstance().onNewBlock(newBlockData);
|
|
||||||
|
|
||||||
Network network = Network.getInstance();
|
Network network = Network.getInstance();
|
||||||
network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData));
|
network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData));
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@ import org.qortal.block.Block;
|
|||||||
import org.qortal.block.BlockChain;
|
import org.qortal.block.BlockChain;
|
||||||
import org.qortal.block.BlockChain.BlockTimingByHeight;
|
import org.qortal.block.BlockChain.BlockTimingByHeight;
|
||||||
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
import org.qortal.controller.Synchronizer.SynchronizationResult;
|
||||||
|
import org.qortal.controller.tradebot.TradeBot;
|
||||||
import org.qortal.crypto.Crypto;
|
import org.qortal.crypto.Crypto;
|
||||||
import org.qortal.data.account.MintingAccountData;
|
import org.qortal.data.account.MintingAccountData;
|
||||||
import org.qortal.data.account.RewardShareData;
|
import org.qortal.data.account.RewardShareData;
|
||||||
@ -799,11 +800,14 @@ public class Controller extends Thread {
|
|||||||
|
|
||||||
List<TransactionData> transactions = repository.getTransactionRepository().getUnconfirmedTransactions();
|
List<TransactionData> transactions = repository.getTransactionRepository().getUnconfirmedTransactions();
|
||||||
|
|
||||||
for (TransactionData transactionData : transactions)
|
for (TransactionData transactionData : transactions) {
|
||||||
if (now >= Transaction.getDeadline(transactionData)) {
|
Transaction transaction = Transaction.fromData(repository, transactionData);
|
||||||
LOGGER.info(String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
|
||||||
|
if (now >= transaction.getDeadline()) {
|
||||||
|
LOGGER.info(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature())));
|
||||||
repository.getTransactionRepository().delete(transactionData);
|
repository.getTransactionRepository().delete(transactionData);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repository.saveChanges();
|
repository.saveChanges();
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
@ -1032,11 +1036,31 @@ public class Controller extends Thread {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Callback for when we've received a new transaction via API or peer. */
|
public static class NewTransactionEvent implements Event {
|
||||||
public void onNewTransaction(TransactionData transactionData, Peer peer) {
|
private final TransactionData transactionData;
|
||||||
|
|
||||||
|
public NewTransactionEvent(TransactionData transactionData) {
|
||||||
|
this.transactionData = transactionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TransactionData getTransactionData() {
|
||||||
|
return this.transactionData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when we've received a new transaction via API or peer.
|
||||||
|
* <p>
|
||||||
|
* @implSpec performs actions in a new thread
|
||||||
|
*/
|
||||||
|
public void onNewTransaction(TransactionData transactionData) {
|
||||||
this.callbackExecutor.execute(() -> {
|
this.callbackExecutor.execute(() -> {
|
||||||
// Notify all peers (except maybe peer that sent it to us if applicable)
|
// Notify all peers
|
||||||
Network.getInstance().broadcast(broadcastPeer -> broadcastPeer == peer ? null : new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature())));
|
Message newTransactionSignatureMessage = new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature()));
|
||||||
|
Network.getInstance().broadcast(broadcastPeer -> newTransactionSignatureMessage);
|
||||||
|
|
||||||
|
// Notify listeners
|
||||||
|
EventBus.INSTANCE.notify(new NewTransactionEvent(transactionData));
|
||||||
|
|
||||||
// If this is a CHAT transaction, there may be extra listeners to notify
|
// If this is a CHAT transaction, there may be extra listeners to notify
|
||||||
if (transactionData.getType() == TransactionType.CHAT)
|
if (transactionData.getType() == TransactionType.CHAT)
|
||||||
@ -1215,9 +1239,6 @@ public class Controller extends Thread {
|
|||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
LOGGER.error(String.format("Repository issue while processing transaction %s from peer %s", Base58.encode(transactionData.getSignature()), peer), e);
|
LOGGER.error(String.format("Repository issue while processing transaction %s from peer %s", Base58.encode(transactionData.getSignature()), peer), e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify controller so it can notify other peers, etc.
|
|
||||||
Controller.getInstance().onNewTransaction(transactionData, peer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) {
|
private void onNetworkGetBlockSummariesMessage(Peer peer, Message message) {
|
||||||
|
@ -0,0 +1,30 @@
|
|||||||
|
package org.qortal.controller.tradebot;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||||
|
import org.qortal.crosschain.ACCT;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.data.at.ATData;
|
||||||
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
|
import org.qortal.data.crosschain.TradeBotData;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
|
||||||
|
public interface AcctTradeBot {
|
||||||
|
|
||||||
|
public enum ResponseResult { OK, BALANCE_ISSUE, NETWORK_ISSUE, TRADE_ALREADY_EXISTS }
|
||||||
|
|
||||||
|
/** Returns list of state names for trade-bot entries that have ended, e.g. redeemed, refunded or cancelled. */
|
||||||
|
public List<String> getEndStates();
|
||||||
|
|
||||||
|
public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException;
|
||||||
|
|
||||||
|
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
|
||||||
|
CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException;
|
||||||
|
|
||||||
|
public boolean canDelete(Repository repository, TradeBotData tradeBotData);
|
||||||
|
|
||||||
|
public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException;
|
||||||
|
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,884 @@
|
|||||||
|
package org.qortal.controller.tradebot;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.stream.Collectors.toMap;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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.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.crosschain.TradeBotCreateRequest;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.crosschain.ACCT;
|
||||||
|
import org.qortal.crosschain.AcctMode;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.crosschain.LitecoinACCTv1;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
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.transaction.DeployAtTransaction;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performing cross-chain trading steps on behalf of user.
|
||||||
|
* <p>
|
||||||
|
* We deal with three different independent state-spaces here:
|
||||||
|
* <ul>
|
||||||
|
* <li>Qortal blockchain</li>
|
||||||
|
* <li>Foreign blockchain</li>
|
||||||
|
* <li>Trade-bot entries</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class LitecoinACCTv1TradeBot implements AcctTradeBot {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger(LitecoinACCTv1TradeBot.class);
|
||||||
|
|
||||||
|
public enum State implements TradeBot.StateNameAndValueSupplier {
|
||||||
|
BOB_WAITING_FOR_AT_CONFIRM(10, false, false),
|
||||||
|
BOB_WAITING_FOR_MESSAGE(15, true, true),
|
||||||
|
BOB_WAITING_FOR_AT_REDEEM(25, true, true),
|
||||||
|
BOB_DONE(30, false, false),
|
||||||
|
BOB_REFUNDED(35, false, false),
|
||||||
|
|
||||||
|
ALICE_WAITING_FOR_AT_LOCK(85, true, true),
|
||||||
|
ALICE_DONE(95, false, false),
|
||||||
|
ALICE_REFUNDING_A(105, true, true),
|
||||||
|
ALICE_REFUNDED(110, false, false);
|
||||||
|
|
||||||
|
private static final Map<Integer, State> map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
|
||||||
|
|
||||||
|
public final int value;
|
||||||
|
public final boolean requiresAtData;
|
||||||
|
public final boolean requiresTradeData;
|
||||||
|
|
||||||
|
State(int value, boolean requiresAtData, boolean requiresTradeData) {
|
||||||
|
this.value = value;
|
||||||
|
this.requiresAtData = requiresAtData;
|
||||||
|
this.requiresTradeData = requiresTradeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static State valueOf(int value) {
|
||||||
|
return map.get(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getState() {
|
||||||
|
return this.name();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getStateValue() {
|
||||||
|
return this.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */
|
||||||
|
private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms
|
||||||
|
|
||||||
|
private static LitecoinACCTv1TradeBot instance;
|
||||||
|
|
||||||
|
private final List<String> endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDED).stream()
|
||||||
|
.map(State::name)
|
||||||
|
.collect(Collectors.toUnmodifiableList());
|
||||||
|
|
||||||
|
private LitecoinACCTv1TradeBot() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized LitecoinACCTv1TradeBot getInstance() {
|
||||||
|
if (instance == null)
|
||||||
|
instance = new LitecoinACCTv1TradeBot();
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getEndStates() {
|
||||||
|
return this.endStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for LTC.
|
||||||
|
* <p>
|
||||||
|
* Generates:
|
||||||
|
* <ul>
|
||||||
|
* <li>new 'trade' private key</li>
|
||||||
|
* </ul>
|
||||||
|
* Derives:
|
||||||
|
* <ul>
|
||||||
|
* <li>'native' (as in Qortal) public key, public key hash, address (starting with Q)</li>
|
||||||
|
* <li>'foreign' (as in Litecoin) public key, public key hash</li>
|
||||||
|
* </ul>
|
||||||
|
* A Qortal AT is then constructed including the following as constants in the 'data segment':
|
||||||
|
* <ul>
|
||||||
|
* <li>'native'/Qortal 'trade' address - used as a MESSAGE contact</li>
|
||||||
|
* <li>'foreign'/Litecoin public key hash - used by Alice's P2SH scripts to allow redeem</li>
|
||||||
|
* <li>QORT amount on offer by Bob</li>
|
||||||
|
* <li>LTC amount expected in return by Bob (from Alice)</li>
|
||||||
|
* <li>trading timeout, in case things go wrong and everyone needs to refund</li>
|
||||||
|
* </ul>
|
||||||
|
* Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
|
||||||
|
* <p>
|
||||||
|
* Trade-bot will wait for Bob's AT to be deployed before taking next step.
|
||||||
|
* <p>
|
||||||
|
* @param repository
|
||||||
|
* @param tradeBotCreateRequest
|
||||||
|
* @return raw, unsigned DEPLOY_AT transaction
|
||||||
|
* @throws DataException
|
||||||
|
*/
|
||||||
|
public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
|
||||||
|
byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
|
||||||
|
|
||||||
|
byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
|
||||||
|
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
|
||||||
|
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
|
||||||
|
|
||||||
|
byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
|
||||||
|
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
|
||||||
|
|
||||||
|
// Convert Litecoin receiving address into public key hash (we only support P2PKH at this time)
|
||||||
|
Address litecoinReceivingAddress;
|
||||||
|
try {
|
||||||
|
litecoinReceivingAddress = Address.fromString(Litecoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress);
|
||||||
|
} catch (AddressFormatException e) {
|
||||||
|
throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
|
||||||
|
}
|
||||||
|
if (litecoinReceivingAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
throw new DataException("Unsupported Litecoin receiving address: " + tradeBotCreateRequest.receivingAddress);
|
||||||
|
|
||||||
|
byte[] litecoinReceivingAccountInfo = litecoinReceivingAddress.getHash();
|
||||||
|
|
||||||
|
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/LTC ACCT";
|
||||||
|
String description = "QORT/LTC cross-chain trade";
|
||||||
|
String aTType = "ACCT";
|
||||||
|
String tags = "ACCT QORT LTC";
|
||||||
|
byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, tradeBotCreateRequest.qortAmount,
|
||||||
|
tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout);
|
||||||
|
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, LitecoinACCTv1.NAME,
|
||||||
|
State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value,
|
||||||
|
creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
|
||||||
|
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
||||||
|
null, null,
|
||||||
|
SupportedBlockchain.LITECOIN.name(),
|
||||||
|
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||||
|
tradeBotCreateRequest.foreignAmount, null, null, null, litecoinReceivingAccountInfo);
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, () -> 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);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw new DataException("Failed to transform DEPLOY_AT transaction?", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a trade-bot entry from the 'Alice' viewpoint, i.e. matching LTC to an existing offer.
|
||||||
|
* <p>
|
||||||
|
* Requires a chosen trade offer from Bob, passed by <tt>crossChainTradeData</tt>
|
||||||
|
* and access to a Litecoin wallet via <tt>xprv58</tt>.
|
||||||
|
* <p>
|
||||||
|
* The <tt>crossChainTradeData</tt> contains the current trade offer state
|
||||||
|
* as extracted from the AT's data segment.
|
||||||
|
* <p>
|
||||||
|
* Access to a funded wallet is via a Litecoin BIP32 hierarchical deterministic key,
|
||||||
|
* passed via <tt>xprv58</tt>.
|
||||||
|
* <b>This key will be stored in your node's database</b>
|
||||||
|
* 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).
|
||||||
|
* <p>
|
||||||
|
* As an example, the xprv58 can be extract from a <i>legacy, password-less</i>
|
||||||
|
* Electrum wallet by going to the console tab and entering:<br>
|
||||||
|
* <tt>wallet.keystore.xprv</tt><br>
|
||||||
|
* which should result in a base58 string starting with either 'xprv' (for Litecoin main-net)
|
||||||
|
* or 'tprv' for (Litecoin test-net).
|
||||||
|
* <p>
|
||||||
|
* It is envisaged that the value in <tt>xprv58</tt> will actually come from a Qortal-UI-managed wallet.
|
||||||
|
* <p>
|
||||||
|
* If sufficient funds are available, <b>this method will actually fund the P2SH-A</b>
|
||||||
|
* with the Litecoin amount expected by 'Bob'.
|
||||||
|
* <p>
|
||||||
|
* If the Litecoin transaction is successfully broadcast to the network then
|
||||||
|
* we also send a MESSAGE to Bob's trade-bot to let them know.
|
||||||
|
* <p>
|
||||||
|
* The trade-bot entry is saved to the repository and the cross-chain trading process commences.
|
||||||
|
* <p>
|
||||||
|
* @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 Litecoin network, false otherwise
|
||||||
|
* @throws DataException
|
||||||
|
*/
|
||||||
|
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
|
||||||
|
byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
|
||||||
|
byte[] secretA = TradeBot.generateSecret();
|
||||||
|
byte[] hashOfSecretA = Crypto.hash160(secretA);
|
||||||
|
|
||||||
|
byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
|
||||||
|
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
|
||||||
|
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
|
||||||
|
|
||||||
|
byte[] tradeForeignPublicKey = TradeBot.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: add tradeTimeout to now
|
||||||
|
long now = NTP.getTime();
|
||||||
|
int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L);
|
||||||
|
|
||||||
|
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, LitecoinACCTv1.NAME,
|
||||||
|
State.ALICE_WAITING_FOR_AT_LOCK.name(), State.ALICE_WAITING_FOR_AT_LOCK.value,
|
||||||
|
receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount,
|
||||||
|
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
||||||
|
secretA, hashOfSecretA,
|
||||||
|
SupportedBlockchain.LITECOIN.name(),
|
||||||
|
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||||
|
crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash);
|
||||||
|
|
||||||
|
// Check we have enough funds via xprv58 to fund P2SH to cover expectedForeignAmount
|
||||||
|
long p2shFee;
|
||||||
|
try {
|
||||||
|
p2shFee = Litecoin.getInstance().getP2shFee(now);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
LOGGER.debug("Couldn't estimate Litecoin fees?");
|
||||||
|
return ResponseResult.NETWORK_ISSUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||||
|
// Do not include fee for funding transaction as this is covered by buildSpend()
|
||||||
|
long amountA = crossChainTradeData.expectedForeignAmount + p2shFee /*redeeming/refunding P2SH-A*/;
|
||||||
|
|
||||||
|
// P2SH-A to be funded
|
||||||
|
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA);
|
||||||
|
String p2shAddress = Litecoin.getInstance().deriveP2shAddress(redeemScriptBytes);
|
||||||
|
|
||||||
|
// Build transaction for funding P2SH-A
|
||||||
|
Transaction p2shFundingTransaction = Litecoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA);
|
||||||
|
if (p2shFundingTransaction == null) {
|
||||||
|
LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?");
|
||||||
|
return ResponseResult.BALANCE_ISSUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Litecoin.getInstance().broadcastTransaction(p2shFundingTransaction);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
// We couldn't fund P2SH-A at this time
|
||||||
|
LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?");
|
||||||
|
return ResponseResult.NETWORK_ISSUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to send MESSAGE to Bob's Qortal trade address
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA());
|
||||||
|
String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress;
|
||||||
|
|
||||||
|
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
|
||||||
|
if (!isMessageAlreadySent) {
|
||||||
|
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||||
|
MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, 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 Bob's trade-bot %s: %s", messageRecipient, result.name()));
|
||||||
|
return ResponseResult.NETWORK_ISSUE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Messaged Bob. Waiting for AT-lock", p2shAddress));
|
||||||
|
|
||||||
|
return ResponseResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean canDelete(Repository repository, TradeBotData tradeBotData) {
|
||||||
|
State tradeBotState = State.valueOf(tradeBotData.getStateValue());
|
||||||
|
if (tradeBotState == null)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
switch (tradeBotState) {
|
||||||
|
case BOB_WAITING_FOR_AT_CONFIRM:
|
||||||
|
case ALICE_DONE:
|
||||||
|
case BOB_DONE:
|
||||||
|
case ALICE_REFUNDED:
|
||||||
|
case BOB_REFUNDED:
|
||||||
|
return true;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException {
|
||||||
|
State tradeBotState = State.valueOf(tradeBotData.getStateValue());
|
||||||
|
if (tradeBotState == null) {
|
||||||
|
LOGGER.info(() -> String.format("Trade-bot entry for AT %s has invalid state?", tradeBotData.getAtAddress()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ATData atData = null;
|
||||||
|
CrossChainTradeData tradeData = null;
|
||||||
|
|
||||||
|
if (tradeBotState.requiresAtData) {
|
||||||
|
// Attempt to fetch AT data
|
||||||
|
atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress());
|
||||||
|
if (atData == null) {
|
||||||
|
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tradeBotState.requiresTradeData) {
|
||||||
|
tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
if (tradeData == null) {
|
||||||
|
LOGGER.warn(() -> String.format("Unable to fetch ACCT trade data for AT %s from repository", tradeBotData.getAtAddress()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (tradeBotState) {
|
||||||
|
case BOB_WAITING_FOR_AT_CONFIRM:
|
||||||
|
handleBobWaitingForAtConfirm(repository, tradeBotData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case BOB_WAITING_FOR_MESSAGE:
|
||||||
|
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||||
|
handleBobWaitingForMessage(repository, tradeBotData, atData, tradeData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ALICE_WAITING_FOR_AT_LOCK:
|
||||||
|
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||||
|
handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case BOB_WAITING_FOR_AT_REDEEM:
|
||||||
|
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||||
|
handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ALICE_DONE:
|
||||||
|
case BOB_DONE:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ALICE_REFUNDING_A:
|
||||||
|
TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData);
|
||||||
|
handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case ALICE_REFUNDED:
|
||||||
|
case BOB_REFUNDED:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade-bot is waiting for Bob's AT to deploy.
|
||||||
|
* <p>
|
||||||
|
* 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())) {
|
||||||
|
if (NTP.getTime() - tradeBotData.getTimestamp() <= MAX_AT_CONFIRMATION_PERIOD)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// We've waited ages for AT to be confirmed into a block but something has gone awry.
|
||||||
|
// After this long we assume transaction loss so give up with trade-bot entry too.
|
||||||
|
tradeBotData.setState(State.BOB_REFUNDED.name());
|
||||||
|
tradeBotData.setStateValue(State.BOB_REFUNDED.value);
|
||||||
|
tradeBotData.setTimestamp(NTP.getTime());
|
||||||
|
// We delete trade-bot entry here instead of saving, hence not using updateTradeBotState()
|
||||||
|
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
|
||||||
|
repository.saveChanges();
|
||||||
|
|
||||||
|
LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress()));
|
||||||
|
TradeBot.notifyStateChange(tradeBotData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE,
|
||||||
|
() -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade-bot is waiting for MESSAGE from Alice's trade-bot, containing Alice's trade info.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Assuming trade is still on offer, trade-bot checks the contents of MESSAGE from Alice's trade-bot.
|
||||||
|
* <p>
|
||||||
|
* Details from Alice are used to derive P2SH-A address and this is checked for funding balance.
|
||||||
|
* <p>
|
||||||
|
* Assuming P2SH-A has at least expected Litecoin balance,
|
||||||
|
* Bob's trade-bot constructs a zero-fee, PoW MESSAGE to send to Bob's AT with more trade details.
|
||||||
|
* <p>
|
||||||
|
* On processing this MESSAGE, Bob's AT should switch into 'TRADE' mode and only trade with Alice.
|
||||||
|
* <p>
|
||||||
|
* Trade-bot's next step is to wait for Alice to redeem the AT, which will allow Bob to
|
||||||
|
* extract secret-A needed to redeem Alice's P2SH.
|
||||||
|
* @throws ForeignBlockchainException
|
||||||
|
*/
|
||||||
|
private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData,
|
||||||
|
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||||
|
// If AT has finished then Bob likely cancelled his trade offer
|
||||||
|
if (atData.getIsFinished()) {
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
|
||||||
|
() -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
|
||||||
|
String address = tradeBotData.getTradeNativeAddress();
|
||||||
|
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null);
|
||||||
|
|
||||||
|
for (MessageTransactionData messageTransactionData : messageTransactionsData) {
|
||||||
|
if (messageTransactionData.isText())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// We're expecting: HASH160(secret-A), Alice's Litecoin pubkeyhash and lockTime-A
|
||||||
|
byte[] messageData = messageTransactionData.getData();
|
||||||
|
LitecoinACCTv1.OfferMessageData offerMessageData = LitecoinACCTv1.extractOfferMessageData(messageData);
|
||||||
|
if (offerMessageData == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
byte[] aliceForeignPublicKeyHash = offerMessageData.partnerLitecoinPKH;
|
||||||
|
byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
|
||||||
|
int lockTimeA = (int) offerMessageData.lockTimeA;
|
||||||
|
long messageTimestamp = messageTransactionData.getTimestamp();
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(messageTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Determine P2SH-A address and confirm funded
|
||||||
|
byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
|
||||||
|
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||||
|
|
||||||
|
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||||
|
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||||
|
final long minimumAmountA = tradeBotData.getForeignAmount() + p2shFee;
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||||
|
|
||||||
|
switch (htlcStatusA) {
|
||||||
|
case UNFUNDED:
|
||||||
|
case FUNDING_IN_PROGRESS:
|
||||||
|
// There might be another MESSAGE from someone else with an actually funded P2SH-A...
|
||||||
|
continue;
|
||||||
|
|
||||||
|
case REDEEM_IN_PROGRESS:
|
||||||
|
case REDEEMED:
|
||||||
|
// We've already redeemed this?
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
|
||||||
|
() -> String.format("P2SH-A %s already spent? Assuming trade complete", p2shAddressA));
|
||||||
|
return;
|
||||||
|
|
||||||
|
case REFUND_IN_PROGRESS:
|
||||||
|
case REFUNDED:
|
||||||
|
// This P2SH-A is burnt, but there might be another MESSAGE from someone else with an actually funded P2SH-A...
|
||||||
|
continue;
|
||||||
|
|
||||||
|
case FUNDED:
|
||||||
|
// Fall-through out of switch...
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good to go - send MESSAGE to AT
|
||||||
|
|
||||||
|
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 = LitecoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
String messageRecipient = tradeBotData.getAtAddress();
|
||||||
|
|
||||||
|
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData);
|
||||||
|
if (!isMessageAlreadySent) {
|
||||||
|
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||||
|
MessageTransaction outgoingMessageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, outgoingMessageData, false, false);
|
||||||
|
|
||||||
|
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", messageRecipient, result.name()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM,
|
||||||
|
() -> String.format("Locked AT %s to %s. Waiting for AT redeem", tradeBotData.getAtAddress(), aliceNativeAddress));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade-bot is waiting for Bob's AT to switch to TRADE mode and lock trade to Alice only.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Assuming Bob's AT is locked to Alice, trade-bot checks AT's state data to make sure it is correct.
|
||||||
|
* <p>
|
||||||
|
* If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice.
|
||||||
|
* <p>
|
||||||
|
* In revealing a valid secret-A, Bob can then redeem the LTC funds from P2SH-A.
|
||||||
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException
|
||||||
|
*/
|
||||||
|
private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData,
|
||||||
|
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||||
|
if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
|
||||||
|
return;
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
int lockTimeA = tradeBotData.getLockTimeA();
|
||||||
|
|
||||||
|
// Refund P2SH-A if we've passed lockTime-A
|
||||||
|
if (NTP.getTime() >= lockTimeA * 1000L) {
|
||||||
|
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||||
|
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||||
|
|
||||||
|
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||||
|
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||||
|
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||||
|
|
||||||
|
switch (htlcStatusA) {
|
||||||
|
case UNFUNDED:
|
||||||
|
case FUNDING_IN_PROGRESS:
|
||||||
|
case FUNDED:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case REDEEM_IN_PROGRESS:
|
||||||
|
case REDEEMED:
|
||||||
|
// Already redeemed?
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||||
|
() -> String.format("P2SH-A %s already spent? Assuming trade completed", p2shAddressA));
|
||||||
|
return;
|
||||||
|
|
||||||
|
case REFUND_IN_PROGRESS:
|
||||||
|
case REFUNDED:
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
|
||||||
|
() -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
|
||||||
|
return;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
|
||||||
|
() -> atData.getIsFinished()
|
||||||
|
? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)
|
||||||
|
: String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're waiting for AT to be in TRADE mode
|
||||||
|
if (crossChainTradeData.mode != AcctMode.TRADING)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above
|
||||||
|
|
||||||
|
// Find our MESSAGE to AT from previous state
|
||||||
|
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(tradeBotData.getTradeNativePublicKey(),
|
||||||
|
crossChainTradeData.qortalCreatorTradeAddress, null, null, null);
|
||||||
|
if (messageTransactionsData == null || messageTransactionsData.isEmpty()) {
|
||||||
|
LOGGER.warn(() -> String.format("Unable to find our message to trade creator %s?", crossChainTradeData.qortalCreatorTradeAddress));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp();
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(recipientMessageTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Our calculated refundTimeout should match AT's refundTimeout
|
||||||
|
if (refundTimeout != crossChainTradeData.refundTimeout) {
|
||||||
|
LOGGER.debug(() -> String.format("Trade AT refundTimeout '%d' doesn't match our refundTimeout '%d'", crossChainTradeData.refundTimeout, refundTimeout));
|
||||||
|
// We'll eventually refund
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're good to redeem AT
|
||||||
|
|
||||||
|
// Send 'redeem' MESSAGE to AT using both secret
|
||||||
|
byte[] secretA = tradeBotData.getSecret();
|
||||||
|
String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secretA, qortalReceivingAddress);
|
||||||
|
String messageRecipient = tradeBotData.getAtAddress();
|
||||||
|
|
||||||
|
boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData);
|
||||||
|
if (!isMessageAlreadySent) {
|
||||||
|
PrivateKeyAccount sender = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||||
|
MessageTransaction messageTransaction = MessageTransaction.build(repository, sender, Group.NO_GROUP, messageRecipient, 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", messageRecipient, result.name()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||||
|
() -> String.format("Redeeming AT %s. Funds should arrive at %s",
|
||||||
|
tradeBotData.getAtAddress(), qortalReceivingAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade-bot is waiting for Alice to redeem Bob's AT, thus revealing secret-A which is required to spend the LTC funds from P2SH-A.
|
||||||
|
* <p>
|
||||||
|
* 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.
|
||||||
|
* <p>
|
||||||
|
* Assuming trade-bot can extract a valid secret-A from Alice's MESSAGE then trade-bot uses that to redeem the LTC funds from P2SH-A
|
||||||
|
* to Bob's 'foreign'/Litecoin trade legacy-format address, as derived from trade private key.
|
||||||
|
* <p>
|
||||||
|
* (This could potentially be 'improved' to send LTC to any address of Bob's choosing by changing the transaction output).
|
||||||
|
* <p>
|
||||||
|
* If trade-bot successfully broadcasts the transaction, then this specific trade is done.
|
||||||
|
* @throws ForeignBlockchainException
|
||||||
|
*/
|
||||||
|
private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData,
|
||||||
|
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||||
|
// AT should be 'finished' once Alice has redeemed QORT funds
|
||||||
|
if (!atData.getIsFinished())
|
||||||
|
// Not finished yet
|
||||||
|
return;
|
||||||
|
|
||||||
|
// If AT is not REDEEMED then something has gone wrong
|
||||||
|
if (crossChainTradeData.mode != AcctMode.REDEEMED) {
|
||||||
|
// Not redeemed so must be refunded/cancelled
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED,
|
||||||
|
() -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] secretA = LitecoinACCTv1.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
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
|
||||||
|
byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo();
|
||||||
|
int lockTimeA = crossChainTradeData.lockTimeA;
|
||||||
|
byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA);
|
||||||
|
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||||
|
|
||||||
|
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||||
|
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||||
|
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||||
|
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||||
|
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||||
|
|
||||||
|
switch (htlcStatusA) {
|
||||||
|
case UNFUNDED:
|
||||||
|
case FUNDING_IN_PROGRESS:
|
||||||
|
// P2SH-A suddenly not funded? Our best bet at this point is to hope for AT auto-refund
|
||||||
|
return;
|
||||||
|
|
||||||
|
case REDEEM_IN_PROGRESS:
|
||||||
|
case REDEEMED:
|
||||||
|
// Double-check that we have redeemed P2SH-A...
|
||||||
|
break;
|
||||||
|
|
||||||
|
case REFUND_IN_PROGRESS:
|
||||||
|
case REFUNDED:
|
||||||
|
// Wait for AT to auto-refund
|
||||||
|
return;
|
||||||
|
|
||||||
|
case FUNDED: {
|
||||||
|
Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||||
|
ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||||
|
List<TransactionOutput> fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA);
|
||||||
|
|
||||||
|
Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(litecoin.getNetworkParameters(), redeemAmount, redeemKey,
|
||||||
|
fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
|
||||||
|
|
||||||
|
litecoin.broadcastTransaction(p2shRedeemTransaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String receivingAddress = litecoin.pkhToAddress(receivingAccountInfo);
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE,
|
||||||
|
() -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade-bot is attempting to refund P2SH-A.
|
||||||
|
* @throws ForeignBlockchainException
|
||||||
|
*/
|
||||||
|
private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData,
|
||||||
|
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||||
|
int lockTimeA = tradeBotData.getLockTimeA();
|
||||||
|
|
||||||
|
// We can't refund P2SH-A until lockTime-A has passed
|
||||||
|
if (NTP.getTime() <= lockTimeA * 1000L)
|
||||||
|
return;
|
||||||
|
|
||||||
|
Litecoin litecoin = Litecoin.getInstance();
|
||||||
|
|
||||||
|
// We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113)
|
||||||
|
int medianBlockTime = litecoin.getMedianBlockTime();
|
||||||
|
if (medianBlockTime <= lockTimeA)
|
||||||
|
return;
|
||||||
|
|
||||||
|
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret());
|
||||||
|
String p2shAddressA = litecoin.deriveP2shAddress(redeemScriptA);
|
||||||
|
|
||||||
|
// Fee for redeem/refund is subtracted from P2SH-A balance.
|
||||||
|
long feeTimestamp = calcFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout);
|
||||||
|
long p2shFee = Litecoin.getInstance().getP2shFee(feeTimestamp);
|
||||||
|
long minimumAmountA = crossChainTradeData.expectedForeignAmount + p2shFee;
|
||||||
|
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(litecoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
|
||||||
|
|
||||||
|
switch (htlcStatusA) {
|
||||||
|
case UNFUNDED:
|
||||||
|
case FUNDING_IN_PROGRESS:
|
||||||
|
// Still waiting for P2SH-A to be funded...
|
||||||
|
return;
|
||||||
|
|
||||||
|
case REDEEM_IN_PROGRESS:
|
||||||
|
case REDEEMED:
|
||||||
|
// Too late!
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||||
|
() -> String.format("P2SH-A %s already spent!", p2shAddressA));
|
||||||
|
return;
|
||||||
|
|
||||||
|
case REFUND_IN_PROGRESS:
|
||||||
|
case REFUNDED:
|
||||||
|
break;
|
||||||
|
|
||||||
|
case FUNDED:{
|
||||||
|
Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount);
|
||||||
|
ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
|
||||||
|
List<TransactionOutput> fundingOutputs = litecoin.getUnspentOutputs(p2shAddressA);
|
||||||
|
|
||||||
|
// Determine receive address for refund
|
||||||
|
String receiveAddress = litecoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
|
||||||
|
Address receiving = Address.fromString(litecoin.getNetworkParameters(), receiveAddress);
|
||||||
|
|
||||||
|
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(litecoin.getNetworkParameters(), refundAmount, refundKey,
|
||||||
|
fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash());
|
||||||
|
|
||||||
|
litecoin.broadcastTransaction(p2shRefundTransaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED,
|
||||||
|
() -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else.
|
||||||
|
* <p>
|
||||||
|
* Will automatically update trade-bot state to <tt>ALICE_REFUNDING_A</tt> or <tt>ALICE_DONE</tt> as necessary.
|
||||||
|
*
|
||||||
|
* @throws DataException
|
||||||
|
* @throws ForeignBlockchainException
|
||||||
|
*/
|
||||||
|
private boolean aliceUnexpectedState(Repository repository, TradeBotData tradeBotData,
|
||||||
|
ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
|
||||||
|
// This is OK
|
||||||
|
if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.OFFERING)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress);
|
||||||
|
|
||||||
|
if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING)
|
||||||
|
if (isAtLockedToUs) {
|
||||||
|
// AT is trading with us - OK
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
|
||||||
|
() -> String.format("AT %s trading with someone else: %s. Refunding & aborting trade", tradeBotData.getAtAddress(), crossChainTradeData.qortalPartnerAddress));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.REDEEMED && isAtLockedToUs) {
|
||||||
|
// We've redeemed already?
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
|
||||||
|
() -> String.format("AT %s already redeemed by us. Trade completed", tradeBotData.getAtAddress()));
|
||||||
|
} else {
|
||||||
|
// Any other state is not good, so start defensive refund
|
||||||
|
TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
|
||||||
|
() -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long calcFeeTimestamp(int lockTimeA, int tradeTimeout) {
|
||||||
|
return (lockTimeA - tradeTimeout * 60) * 1000L;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
362
src/main/java/org/qortal/controller/tradebot/TradeBot.java
Normal file
362
src/main/java/org/qortal/controller/tradebot/TradeBot.java
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
package org.qortal.controller.tradebot;
|
||||||
|
|
||||||
|
import java.awt.TrayIcon.MessageType;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.apache.logging.log4j.util.Supplier;
|
||||||
|
import org.bitcoinj.core.ECKey;
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.api.model.crosschain.TradeBotCreateRequest;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.controller.tradebot.AcctTradeBot.ResponseResult;
|
||||||
|
import org.qortal.crosschain.ACCT;
|
||||||
|
import org.qortal.crosschain.BitcoinACCTv1;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.LitecoinACCTv1;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
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.PresenceTransactionData;
|
||||||
|
import org.qortal.event.Event;
|
||||||
|
import org.qortal.event.EventBus;
|
||||||
|
import org.qortal.event.Listener;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.gui.SysTray;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
import org.qortal.transaction.PresenceTransaction;
|
||||||
|
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||||
|
import org.qortal.transaction.Transaction.ValidationResult;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performing cross-chain trading steps on behalf of user.
|
||||||
|
* <p>
|
||||||
|
* We deal with three different independent state-spaces here:
|
||||||
|
* <ul>
|
||||||
|
* <li>Qortal blockchain</li>
|
||||||
|
* <li>Foreign blockchain</li>
|
||||||
|
* <li>Trade-bot entries</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class TradeBot implements Listener {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger(TradeBot.class);
|
||||||
|
private static final Random RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
public interface StateNameAndValueSupplier {
|
||||||
|
public String getState();
|
||||||
|
public int getStateValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Map<Class<? extends ACCT>, Supplier<AcctTradeBot>> acctTradeBotSuppliers = new HashMap<>();
|
||||||
|
static {
|
||||||
|
acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance);
|
||||||
|
acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TradeBot instance;
|
||||||
|
|
||||||
|
private final Map<String, Long> presenceTimestampsByAtAddress = Collections.synchronizedMap(new HashMap<>());
|
||||||
|
|
||||||
|
private TradeBot() {
|
||||||
|
EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized TradeBot getInstance() {
|
||||||
|
if (instance == null)
|
||||||
|
instance = new TradeBot();
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ACCT getAcctUsingAtData(ATData atData) {
|
||||||
|
byte[] codeHash = atData.getCodeHash();
|
||||||
|
if (codeHash == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return SupportedBlockchain.getAcctByCodeHash(codeHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||||
|
ACCT acct = this.getAcctUsingAtData(atData);
|
||||||
|
if (acct == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return acct.populateTradeData(repository, atData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new trade-bot entry from the "Bob" viewpoint,
|
||||||
|
* i.e. OFFERing QORT in exchange for foreign blockchain currency.
|
||||||
|
* <p>
|
||||||
|
* Generates:
|
||||||
|
* <ul>
|
||||||
|
* <li>new 'trade' private key</li>
|
||||||
|
* <li>secret(s)</li>
|
||||||
|
* </ul>
|
||||||
|
* Derives:
|
||||||
|
* <ul>
|
||||||
|
* <li>'native' (as in Qortal) public key, public key hash, address (starting with Q)</li>
|
||||||
|
* <li>'foreign' public key, public key hash</li>
|
||||||
|
* <li>hash(es) of secret(s)</li>
|
||||||
|
* </ul>
|
||||||
|
* A Qortal AT is then constructed including the following as constants in the 'data segment':
|
||||||
|
* <ul>
|
||||||
|
* <li>'native' (Qortal) 'trade' address - used to MESSAGE AT</li>
|
||||||
|
* <li>'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain</li>
|
||||||
|
* <li>hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed</li>
|
||||||
|
* <li>QORT amount on offer by Bob</li>
|
||||||
|
* <li>foreign currency amount expected in return by Bob (from Alice)</li>
|
||||||
|
* <li>trading timeout, in case things go wrong and everyone needs to refund</li>
|
||||||
|
* </ul>
|
||||||
|
* Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
|
||||||
|
* <p>
|
||||||
|
* Trade-bot will wait for Bob's AT to be deployed before taking next step.
|
||||||
|
* <p>
|
||||||
|
* @param repository
|
||||||
|
* @param tradeBotCreateRequest
|
||||||
|
* @return raw, unsigned DEPLOY_AT transaction
|
||||||
|
* @throws DataException
|
||||||
|
*/
|
||||||
|
public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
|
||||||
|
// Fetch latest ACCT version for requested foreign blockchain
|
||||||
|
ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct();
|
||||||
|
|
||||||
|
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||||
|
if (acctTradeBot == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return acctTradeBot.createTrade(repository, tradeBotCreateRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a trade-bot entry from the 'Alice' viewpoint,
|
||||||
|
* i.e. matching foreign blockchain currency to an existing QORT offer.
|
||||||
|
* <p>
|
||||||
|
* Requires a chosen trade offer from Bob, passed by <tt>crossChainTradeData</tt>
|
||||||
|
* and access to a foreign blockchain wallet via <tt>foreignKey</tt>.
|
||||||
|
* <p>
|
||||||
|
* @param repository
|
||||||
|
* @param crossChainTradeData chosen trade OFFER that Alice wants to match
|
||||||
|
* @param foreignKey foreign blockchain wallet key
|
||||||
|
* @throws DataException
|
||||||
|
*/
|
||||||
|
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
|
||||||
|
CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException {
|
||||||
|
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||||
|
if (acctTradeBot == null) {
|
||||||
|
LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress()));
|
||||||
|
return ResponseResult.NETWORK_ISSUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Alice doesn't already have an existing, on-going trade-bot entry for this AT.
|
||||||
|
if (repository.getCrossChainRepository().existsTradeWithAtExcludingStates(atData.getATAddress(), acctTradeBot.getEndStates()))
|
||||||
|
return ResponseResult.TRADE_ALREADY_EXISTS;
|
||||||
|
|
||||||
|
return acctTradeBot.startResponse(repository, atData, acct, crossChainTradeData, foreignKey, receivingAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean deleteEntry(Repository repository, byte[] tradePrivateKey) throws DataException {
|
||||||
|
TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey);
|
||||||
|
if (tradeBotData == null)
|
||||||
|
// Can't delete what we don't have!
|
||||||
|
return false;
|
||||||
|
|
||||||
|
boolean canDelete = false;
|
||||||
|
|
||||||
|
ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
|
||||||
|
if (acct == null)
|
||||||
|
// We can't/no longer support this ACCT
|
||||||
|
canDelete = true;
|
||||||
|
else {
|
||||||
|
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||||
|
canDelete = acctTradeBot == null || acctTradeBot.canDelete(repository, tradeBotData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canDelete) {
|
||||||
|
repository.getCrossChainRepository().delete(tradePrivateKey);
|
||||||
|
repository.saveChanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
return canDelete;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void listen(Event event) {
|
||||||
|
if (!(event instanceof Controller.NewBlockEvent))
|
||||||
|
return;
|
||||||
|
|
||||||
|
synchronized (this) {
|
||||||
|
List<TradeBotData> allTradeBotData;
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
|
||||||
|
} catch (DataException e) {
|
||||||
|
LOGGER.error("Couldn't run trade bot due to repository issue", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (TradeBotData tradeBotData : allTradeBotData)
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
// Find ACCT-specific trade-bot for this entry
|
||||||
|
ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
|
||||||
|
if (acct == null) {
|
||||||
|
LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
|
||||||
|
if (acctTradeBot == null) {
|
||||||
|
LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
acctTradeBot.progress(repository, tradeBotData);
|
||||||
|
} catch (DataException e) {
|
||||||
|
LOGGER.error("Couldn't run trade bot due to repository issue", e);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
LOGGER.warn(() -> String.format("Foreign blockchain issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package*/ static byte[] generateTradePrivateKey() {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
|
||||||
|
return PrivateKeyAccount.toPublicKey(privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
|
||||||
|
return ECKey.fromPrivate(privateKey).getPubKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package*/ static byte[] generateSecret() {
|
||||||
|
byte[] secret = new byte[32];
|
||||||
|
RANDOM.nextBytes(secret);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
|
||||||
|
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData,
|
||||||
|
String newState, int newStateValue, Supplier<String> logMessageSupplier) throws DataException {
|
||||||
|
tradeBotData.setState(newState);
|
||||||
|
tradeBotData.setStateValue(newStateValue);
|
||||||
|
tradeBotData.setTimestamp(NTP.getTime());
|
||||||
|
repository.getCrossChainRepository().save(tradeBotData);
|
||||||
|
repository.saveChanges();
|
||||||
|
|
||||||
|
if (Settings.getInstance().isTradebotSystrayEnabled())
|
||||||
|
SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO);
|
||||||
|
|
||||||
|
if (logMessageSupplier != null)
|
||||||
|
LOGGER.info(logMessageSupplier);
|
||||||
|
|
||||||
|
LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState));
|
||||||
|
|
||||||
|
notifyStateChange(tradeBotData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
|
||||||
|
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, StateNameAndValueSupplier newStateSupplier, Supplier<String> logMessageSupplier) throws DataException {
|
||||||
|
updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
|
||||||
|
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, Supplier<String> logMessageSupplier) throws DataException {
|
||||||
|
updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package*/ static void notifyStateChange(TradeBotData tradeBotData) {
|
||||||
|
StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
|
||||||
|
EventBus.INSTANCE.notify(stateChangeEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) {
|
||||||
|
Supplier<AcctTradeBot> acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass());
|
||||||
|
if (acctTradeBotSupplier == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return acctTradeBotSupplier.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRESENCE-related
|
||||||
|
/*package*/ void updatePresence(Repository repository, TradeBotData tradeBotData, CrossChainTradeData tradeData)
|
||||||
|
throws DataException {
|
||||||
|
String atAddress = tradeBotData.getAtAddress();
|
||||||
|
|
||||||
|
PrivateKeyAccount tradeNativeAccount = new PrivateKeyAccount(repository, tradeBotData.getTradePrivateKey());
|
||||||
|
String signerAddress = tradeNativeAccount.getAddress();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* There's no point in Alice trying to build a PRESENCE transaction
|
||||||
|
* for an AT that isn't locked to her, as other peers won't be able
|
||||||
|
* to validate the PRESENCE transaction as signing public key won't
|
||||||
|
* be visible.
|
||||||
|
*/
|
||||||
|
if (!signerAddress.equals(tradeData.qortalCreatorTradeAddress) && !signerAddress.equals(tradeData.qortalPartnerAddress))
|
||||||
|
// Signer is neither Bob, nor Alice, or trade not yet locked to Alice
|
||||||
|
return;
|
||||||
|
|
||||||
|
long now = NTP.getTime();
|
||||||
|
long threshold = now - PresenceType.TRADE_BOT.getLifetime();
|
||||||
|
|
||||||
|
long timestamp = presenceTimestampsByAtAddress.compute(atAddress, (k, v) -> (v == null || v < threshold) ? now : v);
|
||||||
|
|
||||||
|
// If timestamp hasn't been updated then nothing to do
|
||||||
|
if (timestamp != now)
|
||||||
|
return;
|
||||||
|
|
||||||
|
int txGroupId = Group.NO_GROUP;
|
||||||
|
byte[] reference = new byte[TransactionTransformer.SIGNATURE_LENGTH];
|
||||||
|
byte[] creatorPublicKey = tradeNativeAccount.getPublicKey();
|
||||||
|
long fee = 0L;
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null);
|
||||||
|
|
||||||
|
int nonce = 0;
|
||||||
|
byte[] timestampSignature = tradeNativeAccount.sign(Longs.toByteArray(timestamp));
|
||||||
|
|
||||||
|
PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature);
|
||||||
|
|
||||||
|
PresenceTransaction presenceTransaction = new PresenceTransaction(repository, transactionData);
|
||||||
|
presenceTransaction.computeNonce();
|
||||||
|
|
||||||
|
presenceTransaction.sign(tradeNativeAccount);
|
||||||
|
|
||||||
|
ValidationResult result = presenceTransaction.importAsUnconfirmed();
|
||||||
|
if (result != ValidationResult.OK)
|
||||||
|
LOGGER.debug(() -> String.format("Unable to build trade-bot PRESENCE transaction for %s: %s", tradeBotData.getAtAddress(), result.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
23
src/main/java/org/qortal/crosschain/ACCT.java
Normal file
23
src/main/java/org/qortal/crosschain/ACCT.java
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import org.qortal.data.at.ATData;
|
||||||
|
import org.qortal.data.at.ATStateData;
|
||||||
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
|
||||||
|
public interface ACCT {
|
||||||
|
|
||||||
|
public byte[] getCodeBytesHash();
|
||||||
|
|
||||||
|
public int getModeByteOffset();
|
||||||
|
|
||||||
|
public ForeignBlockchain getBlockchain();
|
||||||
|
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException;
|
||||||
|
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException;
|
||||||
|
|
||||||
|
public byte[] buildCancelMessage(String creatorQortalAddress);
|
||||||
|
|
||||||
|
}
|
21
src/main/java/org/qortal/crosschain/AcctMode.java
Normal file
21
src/main/java/org/qortal/crosschain/AcctMode.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.stream.Collectors.toMap;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public enum AcctMode {
|
||||||
|
OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
|
||||||
|
|
||||||
|
public final int value;
|
||||||
|
private static final Map<Integer, AcctMode> map = stream(AcctMode.values()).collect(toMap(mode -> mode.value, mode -> mode));
|
||||||
|
|
||||||
|
AcctMode(int value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AcctMode valueOf(int value) {
|
||||||
|
return map.get(value);
|
||||||
|
}
|
||||||
|
}
|
@ -1,559 +0,0 @@
|
|||||||
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.Context;
|
|
||||||
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.ChildNumber;
|
|
||||||
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.crypto.Crypto;
|
|
||||||
import org.qortal.settings.Settings;
|
|
||||||
import org.qortal.utils.BitTwiddling;
|
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
|
||||||
|
|
||||||
public class BTC {
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
public static final boolean INCLUDE_UNCONFIRMED = true;
|
|
||||||
public static final boolean EXCLUDE_UNCONFIRMED = false;
|
|
||||||
|
|
||||||
protected static final Logger LOGGER = LogManager.getLogger(BTC.class);
|
|
||||||
|
|
||||||
// Temporary values until a dynamic fee system is written.
|
|
||||||
private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees.
|
|
||||||
private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch
|
|
||||||
private static final long NEW_FEE_AMOUNT = 10_000L;
|
|
||||||
private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
|
|
||||||
|
|
||||||
private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
|
|
||||||
private static final MonetaryFormat FORMAT = new MonetaryFormat().minDecimals(8).postfixCode();
|
|
||||||
|
|
||||||
public enum BitcoinNet {
|
|
||||||
MAIN {
|
|
||||||
@Override
|
|
||||||
public NetworkParameters getParams() {
|
|
||||||
return MainNetParams.get();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
TEST3 {
|
|
||||||
@Override
|
|
||||||
public NetworkParameters getParams() {
|
|
||||||
return TestNet3Params.get();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
REGTEST {
|
|
||||||
@Override
|
|
||||||
public NetworkParameters getParams() {
|
|
||||||
return RegTestParams.get();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
public abstract NetworkParameters getParams();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BTC instance;
|
|
||||||
private final NetworkParameters params;
|
|
||||||
private final ElectrumX electrumX;
|
|
||||||
private final Context bitcoinjContext;
|
|
||||||
|
|
||||||
// Let ECKey.equals() do the hard work
|
|
||||||
private final Set<ECKey> spentKeys = new HashSet<>();
|
|
||||||
|
|
||||||
// Constructors and instance
|
|
||||||
|
|
||||||
private BTC() {
|
|
||||||
BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
|
|
||||||
this.params = bitcoinNet.getParams();
|
|
||||||
|
|
||||||
LOGGER.info(() -> String.format("Starting Bitcoin support using %s", bitcoinNet.name()));
|
|
||||||
|
|
||||||
this.electrumX = ElectrumX.getInstance(bitcoinNet.name());
|
|
||||||
this.bitcoinjContext = new Context(this.params);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static synchronized BTC getInstance() {
|
|
||||||
if (instance == null)
|
|
||||||
instance = new BTC();
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters & setters
|
|
||||||
|
|
||||||
public NetworkParameters getNetworkParameters() {
|
|
||||||
return this.params;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static synchronized void resetForTesting() {
|
|
||||||
instance = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actual useful methods for use by other classes
|
|
||||||
|
|
||||||
public static String format(Coin amount) {
|
|
||||||
return BTC.FORMAT.format(amount).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String format(long amount) {
|
|
||||||
return format(Coin.valueOf(amount));
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isValidXprv(String xprv58) {
|
|
||||||
try {
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
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) {
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
public String deriveP2shAddress(byte[] redeemScriptBytes) {
|
|
||||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
|
||||||
return p2shAddress.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns median timestamp from latest 11 blocks, in seconds.
|
|
||||||
* <p>
|
|
||||||
* @throws BitcoinException if error occurs
|
|
||||||
*/
|
|
||||||
public Integer getMedianBlockTime() throws BitcoinException {
|
|
||||||
int height = this.electrumX.getCurrentHeight();
|
|
||||||
|
|
||||||
// Grab latest 11 blocks
|
|
||||||
List<byte[]> blockHeaders = this.electrumX.getBlockHeaders(height - 11, 11);
|
|
||||||
if (blockHeaders.size() < 11)
|
|
||||||
throw new BitcoinException("Not enough blocks to determine median block time");
|
|
||||||
|
|
||||||
List<Integer> blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
|
|
||||||
|
|
||||||
// Descending order
|
|
||||||
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
|
|
||||||
|
|
||||||
// Pick median
|
|
||||||
return blockTimestamps.get(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns estimated BTC fee, in sats per 1000bytes, optionally for historic timestamp.
|
|
||||||
*
|
|
||||||
* @param timestamp optional milliseconds since epoch, or null for 'now'
|
|
||||||
* @return sats per 1000bytes, or throws BitcoinException if something went wrong
|
|
||||||
*/
|
|
||||||
public long estimateFee(Long timestamp) throws BitcoinException {
|
|
||||||
if (!this.params.getId().equals(NetworkParameters.ID_MAINNET))
|
|
||||||
return NON_MAINNET_FEE;
|
|
||||||
|
|
||||||
// TODO: This will need to be replaced with something better in the near future!
|
|
||||||
if (timestamp != null && timestamp < NEW_FEE_TIMESTAMP)
|
|
||||||
return OLD_FEE_AMOUNT;
|
|
||||||
|
|
||||||
return NEW_FEE_AMOUNT;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns confirmed balance, based on passed payment script.
|
|
||||||
* <p>
|
|
||||||
* @return confirmed balance, or zero if script unknown
|
|
||||||
* @throws BitcoinException if there was an error
|
|
||||||
*/
|
|
||||||
public long getConfirmedBalance(String base58Address) throws BitcoinException {
|
|
||||||
return this.electrumX.getConfirmedBalance(addressToScript(base58Address));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns list of unspent outputs pertaining to passed address.
|
|
||||||
* <p>
|
|
||||||
* @return list of unspent outputs, or empty list if address unknown
|
|
||||||
* @throws BitcoinException if there was an error.
|
|
||||||
*/
|
|
||||||
public List<TransactionOutput> getUnspentOutputs(String base58Address) throws BitcoinException {
|
|
||||||
List<UnspentOutput> unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address), false);
|
|
||||||
|
|
||||||
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
|
|
||||||
for (UnspentOutput unspentOutput : unspentOutputs) {
|
|
||||||
List<TransactionOutput> transactionOutputs = this.getOutputs(unspentOutput.hash);
|
|
||||||
|
|
||||||
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
|
|
||||||
}
|
|
||||||
|
|
||||||
return unspentTransactionOutputs;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns list of outputs pertaining to passed transaction hash.
|
|
||||||
* <p>
|
|
||||||
* @return list of outputs, or empty list if transaction unknown
|
|
||||||
* @throws BitcoinException if there was an error.
|
|
||||||
*/
|
|
||||||
public List<TransactionOutput> getOutputs(byte[] txHash) throws BitcoinException {
|
|
||||||
byte[] rawTransactionBytes = this.electrumX.getRawTransaction(txHash);
|
|
||||||
|
|
||||||
// XXX bitcoinj: replace with getTransaction() below
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
Transaction transaction = new Transaction(this.params, rawTransactionBytes);
|
|
||||||
return transaction.getOutputs();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns list of transaction hashes pertaining to passed address.
|
|
||||||
* <p>
|
|
||||||
* @return list of unspent outputs, or empty list if script unknown
|
|
||||||
* @throws BitcoinException if there was an error.
|
|
||||||
*/
|
|
||||||
public List<TransactionHash> getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws BitcoinException {
|
|
||||||
return this.electrumX.getAddressTransactions(addressToScript(base58Address), includeUnconfirmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns list of raw, confirmed transactions involving given address.
|
|
||||||
* <p>
|
|
||||||
* @throws BitcoinException if there was an error
|
|
||||||
*/
|
|
||||||
public List<byte[]> getAddressTransactions(String base58Address) throws BitcoinException {
|
|
||||||
List<TransactionHash> transactionHashes = this.electrumX.getAddressTransactions(addressToScript(base58Address), false);
|
|
||||||
|
|
||||||
List<byte[]> rawTransactions = new ArrayList<>();
|
|
||||||
for (TransactionHash transactionInfo : transactionHashes) {
|
|
||||||
byte[] rawTransaction = this.electrumX.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes());
|
|
||||||
rawTransactions.add(rawTransaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
return rawTransactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns transaction info for passed transaction hash.
|
|
||||||
* <p>
|
|
||||||
* @throws BitcoinException.NotFoundException if transaction unknown
|
|
||||||
* @throws BitcoinException if error occurs
|
|
||||||
*/
|
|
||||||
public BitcoinTransaction getTransaction(String txHash) throws BitcoinException {
|
|
||||||
return this.electrumX.getTransaction(txHash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Broadcasts raw transaction to Bitcoin network.
|
|
||||||
* <p>
|
|
||||||
* @throws BitcoinException if error occurs
|
|
||||||
*/
|
|
||||||
public void broadcastTransaction(Transaction transaction) throws BitcoinException {
|
|
||||||
this.electrumX.broadcastTransaction(transaction.bitcoinSerialize());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt>.
|
|
||||||
*
|
|
||||||
* @param xprv58 BIP32 extended Bitcoin private key
|
|
||||||
* @param recipient P2PKH address
|
|
||||||
* @param amount unscaled amount
|
|
||||||
* @return transaction, or null if insufficient funds
|
|
||||||
*/
|
|
||||||
public Transaction buildSpend(String xprv58, String recipient, long amount) {
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
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));
|
|
||||||
|
|
||||||
Address destination = Address.fromString(this.params, recipient);
|
|
||||||
SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
|
|
||||||
|
|
||||||
if (this.params == TestNet3Params.get())
|
|
||||||
// Much smaller fee for TestNet3
|
|
||||||
sendRequest.feePerKb = Coin.valueOf(2000L);
|
|
||||||
|
|
||||||
try {
|
|
||||||
wallet.completeTx(sendRequest);
|
|
||||||
return sendRequest.tx;
|
|
||||||
} catch (InsufficientMoneyException e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns unspent Bitcoin balance given 'm' BIP32 key.
|
|
||||||
*
|
|
||||||
* @param xprv58 BIP32 extended Bitcoin private key
|
|
||||||
* @return unspent BTC balance, or null if unable to determine balance
|
|
||||||
*/
|
|
||||||
public Long getWalletBalance(String xprv58) {
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
|
||||||
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet, WalletAwareUTXOProvider.KeySearchMode.REQUEST_MORE_IF_ANY_SPENT));
|
|
||||||
|
|
||||||
Coin balance = wallet.getBalance();
|
|
||||||
if (balance == null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return balance.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns first unused receive address given 'm' BIP32 key.
|
|
||||||
*
|
|
||||||
* @param xprv58 BIP32 extended Bitcoin private key
|
|
||||||
* @return Bitcoin P2PKH address
|
|
||||||
* @throws BitcoinException if something went wrong
|
|
||||||
*/
|
|
||||||
public String getUnusedReceiveAddress(String xprv58) throws BitcoinException {
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
|
||||||
DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
|
|
||||||
|
|
||||||
keyChain.setLookaheadSize(WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT);
|
|
||||||
keyChain.maybeLookAhead();
|
|
||||||
|
|
||||||
final int keyChainPathSize = keyChain.getAccountPath().size();
|
|
||||||
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
|
||||||
|
|
||||||
int ki = 0;
|
|
||||||
do {
|
|
||||||
for (; ki < keys.size(); ++ki) {
|
|
||||||
DeterministicKey dKey = keys.get(ki);
|
|
||||||
List<ChildNumber> dKeyPath = dKey.getPath();
|
|
||||||
|
|
||||||
// If keyChain is based on 'm', then make sure dKey is m/0/ki
|
|
||||||
if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
// Check unspent
|
|
||||||
Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
|
|
||||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
|
||||||
|
|
||||||
List<UnspentOutput> unspentOutputs = this.electrumX.getUnspentOutputs(script, false);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If there are no unspent outputs then either:
|
|
||||||
* a) all the outputs have been spent
|
|
||||||
* b) address has never been used
|
|
||||||
*
|
|
||||||
* For case (a) we want to remember not to check this address (key) again.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (unspentOutputs.isEmpty()) {
|
|
||||||
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
|
||||||
if (this.spentKeys.contains(dKey)) {
|
|
||||||
wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) dKey);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask for transaction history - if it's empty then key has never been used
|
|
||||||
List<TransactionHash> historicTransactionHashes = this.electrumX.getAddressTransactions(script, false);
|
|
||||||
|
|
||||||
if (!historicTransactionHashes.isEmpty()) {
|
|
||||||
// Fully spent key - case (a)
|
|
||||||
this.spentKeys.add(dKey);
|
|
||||||
wallet.getActiveKeyChain().markKeyAsUsed(dKey);
|
|
||||||
} else {
|
|
||||||
// Key never been used - case (b)
|
|
||||||
return address.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Key has unspent outputs, hence used, so no good to us
|
|
||||||
this.spentKeys.remove(dKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate some more keys
|
|
||||||
keyChain.setLookaheadSize(keyChain.getLookaheadSize() + WalletAwareUTXOProvider.LOOKAHEAD_INCREMENT);
|
|
||||||
keyChain.maybeLookAhead();
|
|
||||||
|
|
||||||
// This returns all keys, including those already in 'keys'
|
|
||||||
List<DeterministicKey> allLeafKeys = keyChain.getLeafKeys();
|
|
||||||
// Add only new keys onto our list of keys to search
|
|
||||||
List<DeterministicKey> newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
|
|
||||||
keys.addAll(newKeys);
|
|
||||||
// Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
|
|
||||||
|
|
||||||
// Process new keys
|
|
||||||
} while (true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UTXOProvider support
|
|
||||||
|
|
||||||
static class WalletAwareUTXOProvider implements UTXOProvider {
|
|
||||||
private static final int LOOKAHEAD_INCREMENT = 3;
|
|
||||||
|
|
||||||
private final BTC btc;
|
|
||||||
private final Wallet wallet;
|
|
||||||
|
|
||||||
enum KeySearchMode {
|
|
||||||
REQUEST_MORE_IF_ALL_SPENT, REQUEST_MORE_IF_ANY_SPENT;
|
|
||||||
}
|
|
||||||
private final KeySearchMode keySearchMode;
|
|
||||||
private final DeterministicKeyChain keyChain;
|
|
||||||
|
|
||||||
public WalletAwareUTXOProvider(BTC btc, Wallet wallet, KeySearchMode keySearchMode) {
|
|
||||||
this.btc = btc;
|
|
||||||
this.wallet = wallet;
|
|
||||||
this.keySearchMode = keySearchMode;
|
|
||||||
this.keyChain = this.wallet.getActiveKeyChain();
|
|
||||||
|
|
||||||
// Set up wallet's key chain
|
|
||||||
this.keyChain.setLookaheadSize(LOOKAHEAD_INCREMENT);
|
|
||||||
this.keyChain.maybeLookAhead();
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<UTXO> getOpenTransactionOutputs(List<ECKey> keys) throws UTXOProviderException {
|
|
||||||
List<UTXO> allUnspentOutputs = new ArrayList<>();
|
|
||||||
final boolean coinbase = false;
|
|
||||||
|
|
||||||
int ki = 0;
|
|
||||||
do {
|
|
||||||
boolean areAllKeysUnspent = true;
|
|
||||||
boolean areAllKeysSpent = true;
|
|
||||||
|
|
||||||
for (; ki < keys.size(); ++ki) {
|
|
||||||
ECKey key = keys.get(ki);
|
|
||||||
|
|
||||||
Address address = Address.fromKey(btc.params, key, ScriptType.P2PKH);
|
|
||||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
|
||||||
|
|
||||||
List<UnspentOutput> unspentOutputs;
|
|
||||||
try {
|
|
||||||
unspentOutputs = btc.electrumX.getUnspentOutputs(script, false);
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* If there are no unspent outputs then either:
|
|
||||||
* a) all the outputs have been spent
|
|
||||||
* b) address has never been used
|
|
||||||
*
|
|
||||||
* For case (a) we want to remember not to check this address (key) again.
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (unspentOutputs.isEmpty()) {
|
|
||||||
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
|
||||||
if (btc.spentKeys.contains(key)) {
|
|
||||||
wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
|
||||||
areAllKeysUnspent = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ask for transaction history - if it's empty then key has never been used
|
|
||||||
List<TransactionHash> historicTransactionHashes;
|
|
||||||
try {
|
|
||||||
historicTransactionHashes = btc.electrumX.getAddressTransactions(script, false);
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
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
|
|
||||||
btc.spentKeys.remove(key);
|
|
||||||
areAllKeysSpent = false;
|
|
||||||
|
|
||||||
for (UnspentOutput unspentOutput : unspentOutputs) {
|
|
||||||
List<TransactionOutput> transactionOutputs;
|
|
||||||
try {
|
|
||||||
transactionOutputs = btc.getOutputs(unspentOutput.hash);
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
throw new UTXOProviderException(String.format("Unable to fetch outputs for TX %s",
|
|
||||||
HashCode.fromBytes(unspentOutput.hash)));
|
|
||||||
}
|
|
||||||
|
|
||||||
TransactionOutput transactionOutput = transactionOutputs.get(unspentOutput.index);
|
|
||||||
|
|
||||||
UTXO utxo = new UTXO(Sha256Hash.wrap(unspentOutput.hash), unspentOutput.index,
|
|
||||||
Coin.valueOf(unspentOutput.value), unspentOutput.height, coinbase,
|
|
||||||
transactionOutput.getScriptPubKey());
|
|
||||||
|
|
||||||
allUnspentOutputs.add(utxo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ALL_SPENT && areAllKeysSpent)
|
|
||||||
|| (this.keySearchMode == KeySearchMode.REQUEST_MORE_IF_ANY_SPENT && !areAllKeysUnspent)) {
|
|
||||||
// Generate some more keys
|
|
||||||
this.keyChain.setLookaheadSize(this.keyChain.getLookaheadSize() + LOOKAHEAD_INCREMENT);
|
|
||||||
this.keyChain.maybeLookAhead();
|
|
||||||
|
|
||||||
// This returns all keys, including those already in 'keys'
|
|
||||||
List<DeterministicKey> allLeafKeys = this.keyChain.getLeafKeys();
|
|
||||||
// Add only new keys onto our list of keys to search
|
|
||||||
List<DeterministicKey> newKeys = allLeafKeys.subList(ki, allLeafKeys.size());
|
|
||||||
keys.addAll(newKeys);
|
|
||||||
// Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have processed all keys, then we're done
|
|
||||||
} while (ki < keys.size());
|
|
||||||
|
|
||||||
return allUnspentOutputs;
|
|
||||||
}
|
|
||||||
|
|
||||||
public int getChainHeadHeight() throws UTXOProviderException {
|
|
||||||
try {
|
|
||||||
return btc.electrumX.getCurrentHeight();
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
throw new UTXOProviderException("Unable to determine Bitcoin chain height");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public NetworkParameters getParams() {
|
|
||||||
return btc.params;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility methods for us
|
|
||||||
|
|
||||||
private byte[] addressToScript(String base58Address) {
|
|
||||||
Context.propagate(bitcoinjContext);
|
|
||||||
Address address = Address.fromString(this.params, base58Address);
|
|
||||||
return ScriptBuilder.createOutputScript(address).getProgram();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
195
src/main/java/org/qortal/crosschain/Bitcoin.java
Normal file
195
src/main/java/org/qortal/crosschain/Bitcoin.java
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Context;
|
||||||
|
import org.bitcoinj.core.NetworkParameters;
|
||||||
|
import org.bitcoinj.params.MainNetParams;
|
||||||
|
import org.bitcoinj.params.RegTestParams;
|
||||||
|
import org.bitcoinj.params.TestNet3Params;
|
||||||
|
import org.qortal.crosschain.ElectrumX.Server;
|
||||||
|
import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
|
||||||
|
public class Bitcoin extends Bitcoiny {
|
||||||
|
|
||||||
|
public static final String CURRENCY_CODE = "BTC";
|
||||||
|
|
||||||
|
// Temporary values until a dynamic fee system is written.
|
||||||
|
private static final long OLD_FEE_AMOUNT = 4_000L; // Not 5000 so that existing P2SH-B can output 1000, avoiding dust issue, leaving 4000 for fees.
|
||||||
|
private static final long NEW_FEE_TIMESTAMP = 1598280000000L; // milliseconds since epoch
|
||||||
|
private static final long NEW_FEE_AMOUNT = 10_000L;
|
||||||
|
|
||||||
|
private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
|
||||||
|
|
||||||
|
private static final Map<ElectrumX.Server.ConnectionType, Integer> DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class);
|
||||||
|
static {
|
||||||
|
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
|
||||||
|
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BitcoinNet {
|
||||||
|
MAIN {
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return MainNetParams.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<ElectrumX.Server> getServers() {
|
||||||
|
return Arrays.asList(
|
||||||
|
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||||
|
new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
|
||||||
|
new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
|
||||||
|
new Server("electrumx.electricnewyear.net", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("dxm.no-ip.biz", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("kirsche.emzy.de", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("2AZZARITA.hopto.org", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("xtrum.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum.srvmin.network", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrumx.alexridevski.net", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("bitcoin.lukechilds.co", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum.poiuty.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("horsey.cryptocowboys.net", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum.emzy.de", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum-server.ninja", Server.ConnectionType.TCP, 50081),
|
||||||
|
new Server("bitcoin.electrumx.multicoin.co", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("esx.geekhosters.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("bitcoin.grey.pw", Server.ConnectionType.TCP, 50003),
|
||||||
|
new Server("exs.ignorelist.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum.coinext.com.br", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("skbxmit.coinjoined.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("alviss.coinjoined.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum2.privateservers.network", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrumx.schulzemic.net", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("bitcoins.sk", Server.ConnectionType.TCP, 56001),
|
||||||
|
new Server("node.mendonca.xyz", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getGenesisHash() {
|
||||||
|
return "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) {
|
||||||
|
// TODO: This will need to be replaced with something better in the near future!
|
||||||
|
if (timestamp != null && timestamp < NEW_FEE_TIMESTAMP)
|
||||||
|
return OLD_FEE_AMOUNT;
|
||||||
|
|
||||||
|
return NEW_FEE_AMOUNT;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST3 {
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return TestNet3Params.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<ElectrumX.Server> getServers() {
|
||||||
|
return Arrays.asList(
|
||||||
|
new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 60001),
|
||||||
|
new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 60002),
|
||||||
|
new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("testnet.qtornado.com", Server.ConnectionType.TCP, 51001),
|
||||||
|
new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002),
|
||||||
|
new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001),
|
||||||
|
new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002),
|
||||||
|
new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getGenesisHash() {
|
||||||
|
return "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) {
|
||||||
|
return NON_MAINNET_FEE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
REGTEST {
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return RegTestParams.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<ElectrumX.Server> getServers() {
|
||||||
|
return Arrays.asList(
|
||||||
|
new Server("localhost", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("localhost", Server.ConnectionType.SSL, 50002));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getGenesisHash() {
|
||||||
|
// This is unique to each regtest instance
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) {
|
||||||
|
return NON_MAINNET_FEE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public abstract NetworkParameters getParams();
|
||||||
|
public abstract Collection<ElectrumX.Server> getServers();
|
||||||
|
public abstract String getGenesisHash();
|
||||||
|
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Bitcoin instance;
|
||||||
|
|
||||||
|
private final BitcoinNet bitcoinNet;
|
||||||
|
|
||||||
|
// Constructors and instance
|
||||||
|
|
||||||
|
private Bitcoin(BitcoinNet bitcoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||||
|
super(blockchain, bitcoinjContext, currencyCode);
|
||||||
|
this.bitcoinNet = bitcoinNet;
|
||||||
|
|
||||||
|
LOGGER.info(() -> String.format("Starting Bitcoin support using %s", this.bitcoinNet.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized Bitcoin getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
BitcoinNet bitcoinNet = Settings.getInstance().getBitcoinNet();
|
||||||
|
|
||||||
|
BitcoinyBlockchainProvider electrumX = new ElectrumX("Bitcoin-" + bitcoinNet.name(), bitcoinNet.getGenesisHash(), bitcoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS);
|
||||||
|
Context bitcoinjContext = new Context(bitcoinNet.getParams());
|
||||||
|
|
||||||
|
instance = new Bitcoin(bitcoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters & setters
|
||||||
|
|
||||||
|
public static synchronized void resetForTesting() {
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual useful methods for use by other classes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns estimated BTC fee, in sats per 1000bytes, optionally for historic timestamp.
|
||||||
|
*
|
||||||
|
* @param timestamp optional milliseconds since epoch, or null for 'now'
|
||||||
|
* @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) throws ForeignBlockchainException {
|
||||||
|
return this.bitcoinNet.getP2shFee(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,13 +1,10 @@
|
|||||||
package org.qortal.crosschain;
|
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 static org.ciyam.at.OpCode.calcOffset;
|
||||||
|
|
||||||
import java.nio.ByteBuffer;
|
import java.nio.ByteBuffer;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.ciyam.at.API;
|
import org.ciyam.at.API;
|
||||||
import org.ciyam.at.CompilationException;
|
import org.ciyam.at.CompilationException;
|
||||||
@ -101,11 +98,12 @@ import com.google.common.primitives.Bytes;
|
|||||||
* </li>
|
* </li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public class BTCACCT {
|
public class BitcoinACCTv1 implements ACCT {
|
||||||
|
|
||||||
|
public static final String NAME = BitcoinACCTv1.class.getSimpleName();
|
||||||
|
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
|
||||||
|
|
||||||
public static final int SECRET_LENGTH = 32;
|
public static final int SECRET_LENGTH = 32;
|
||||||
public static final int MIN_LOCKTIME = 1500000000;
|
|
||||||
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
|
|
||||||
|
|
||||||
/** <b>Value</b> offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */
|
/** <b>Value</b> 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;
|
private static final int MODE_VALUE_OFFSET = 68;
|
||||||
@ -126,22 +124,31 @@ public class BTCACCT {
|
|||||||
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 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 static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
|
||||||
|
|
||||||
public enum Mode {
|
private static BitcoinACCTv1 instance;
|
||||||
OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4);
|
|
||||||
|
|
||||||
public final int value;
|
private BitcoinACCTv1() {
|
||||||
private static final Map<Integer, Mode> 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() {
|
public static synchronized BitcoinACCTv1 getInstance() {
|
||||||
|
if (instance == null)
|
||||||
|
instance = new BitcoinACCTv1();
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getCodeBytesHash() {
|
||||||
|
return CODE_BYTES_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getModeByteOffset() {
|
||||||
|
return MODE_BYTE_OFFSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ForeignBlockchain getBlockchain() {
|
||||||
|
return Bitcoin.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -156,7 +163,6 @@ public class BTCACCT {
|
|||||||
* @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT
|
* @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 bitcoinAmount how much BTC the AT creator is expecting to trade
|
||||||
* @param tradeTimeout suggested timeout for entire trade
|
* @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) {
|
||||||
// Labels for data segment addresses
|
// Labels for data segment addresses
|
||||||
@ -419,7 +425,7 @@ public class BTCACCT {
|
|||||||
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender3, addrCreatorAddress3, 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)));
|
codeByteBuffer.put(OpCode.BNE_DAT.compile(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn)));
|
||||||
// Partner address is AT creator's address, so cancel offer and finish.
|
// Partner address is AT creator's address, so cancel offer and finish.
|
||||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.CANCELLED.value));
|
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.CANCELLED.value));
|
||||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||||
|
|
||||||
@ -470,7 +476,7 @@ public class BTCACCT {
|
|||||||
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout));
|
codeByteBuffer.put(OpCode.EXT_FUN_RET_DAT_2.compile(FunctionCode.ADD_MINUTES_TO_TIMESTAMP, addrRefundTimestamp, addrLastTxnTimestamp, addrRefundTimeout));
|
||||||
|
|
||||||
/* We are in 'trade mode' */
|
/* We are in 'trade mode' */
|
||||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.TRADING.value));
|
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value));
|
||||||
|
|
||||||
// Set restart position to after this opcode
|
// Set restart position to after this opcode
|
||||||
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||||
@ -568,7 +574,7 @@ public class BTCACCT {
|
|||||||
// Pay AT's balance to receiving address
|
// Pay AT's balance to receiving address
|
||||||
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount));
|
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount));
|
||||||
// Set redeemed mode
|
// Set redeemed mode
|
||||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REDEEMED.value));
|
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REDEEMED.value));
|
||||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||||
|
|
||||||
@ -578,7 +584,7 @@ public class BTCACCT {
|
|||||||
labelRefund = codeByteBuffer.position();
|
labelRefund = codeByteBuffer.position();
|
||||||
|
|
||||||
// Set refunded mode
|
// Set refunded mode
|
||||||
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REFUNDED.value));
|
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.REFUNDED.value));
|
||||||
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
// We're finished forever (finishing auto-refunds remaining balance to AT creator)
|
||||||
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
codeByteBuffer.put(OpCode.FIN_IMD.compile());
|
||||||
} catch (CompilationException e) {
|
} catch (CompilationException e) {
|
||||||
@ -591,7 +597,7 @@ public class BTCACCT {
|
|||||||
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
||||||
codeByteBuffer.get(codeBytes);
|
codeByteBuffer.get(codeBytes);
|
||||||
|
|
||||||
assert Arrays.equals(Crypto.digest(codeBytes), BTCACCT.CODE_BYTES_HASH)
|
assert Arrays.equals(Crypto.digest(codeBytes), BitcoinACCTv1.CODE_BYTES_HASH)
|
||||||
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
|
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(codeBytes)));
|
||||||
|
|
||||||
final short ciyamAtVersion = 2;
|
final short ciyamAtVersion = 2;
|
||||||
@ -604,41 +610,34 @@ public class BTCACCT {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||||
*
|
|
||||||
* @param repository
|
|
||||||
* @param atAddress
|
|
||||||
* @throws DataException
|
|
||||||
*/
|
*/
|
||||||
public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
@Override
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||||
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
|
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
|
||||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||||
*
|
|
||||||
* @param repository
|
|
||||||
* @param atAddress
|
|
||||||
* @throws DataException
|
|
||||||
*/
|
*/
|
||||||
public static CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
|
@Override
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
|
||||||
ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
|
ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
|
||||||
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns CrossChainTradeData with useful info extracted from AT.
|
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||||
*
|
|
||||||
* @param repository
|
|
||||||
* @param atAddress
|
|
||||||
* @throws DataException
|
|
||||||
*/
|
*/
|
||||||
public static CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
|
public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
|
||||||
byte[] addressBytes = new byte[25]; // for general use
|
byte[] addressBytes = new byte[25]; // for general use
|
||||||
String atAddress = atStateData.getATAddress();
|
String atAddress = atStateData.getATAddress();
|
||||||
|
|
||||||
CrossChainTradeData tradeData = new CrossChainTradeData();
|
CrossChainTradeData tradeData = new CrossChainTradeData();
|
||||||
|
|
||||||
|
tradeData.foreignBlockchain = SupportedBlockchain.BITCOIN.name();
|
||||||
|
tradeData.acctName = NAME;
|
||||||
|
|
||||||
tradeData.qortalAtAddress = atAddress;
|
tradeData.qortalAtAddress = atAddress;
|
||||||
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
|
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
|
||||||
tradeData.creationTimestamp = creationTimestamp;
|
tradeData.creationTimestamp = creationTimestamp;
|
||||||
@ -658,9 +657,9 @@ public class BTCACCT {
|
|||||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||||
|
|
||||||
// Creator's Bitcoin/foreign public key hash
|
// Creator's Bitcoin/foreign public key hash
|
||||||
tradeData.creatorBitcoinPKH = new byte[20];
|
tradeData.creatorForeignPKH = new byte[20];
|
||||||
dataByteBuffer.get(tradeData.creatorBitcoinPKH);
|
dataByteBuffer.get(tradeData.creatorForeignPKH);
|
||||||
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
|
||||||
|
|
||||||
// Hash of secret B
|
// Hash of secret B
|
||||||
tradeData.hashOfSecretB = new byte[20];
|
tradeData.hashOfSecretB = new byte[20];
|
||||||
@ -671,7 +670,7 @@ public class BTCACCT {
|
|||||||
tradeData.qortAmount = dataByteBuffer.getLong();
|
tradeData.qortAmount = dataByteBuffer.getLong();
|
||||||
|
|
||||||
// Expected BTC amount
|
// Expected BTC amount
|
||||||
tradeData.expectedBitcoin = dataByteBuffer.getLong();
|
tradeData.expectedForeignAmount = dataByteBuffer.getLong();
|
||||||
|
|
||||||
// Trade timeout
|
// Trade timeout
|
||||||
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
|
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
|
||||||
@ -784,26 +783,28 @@ public class BTCACCT {
|
|||||||
|
|
||||||
// Trade AT's 'mode'
|
// Trade AT's 'mode'
|
||||||
long modeValue = dataByteBuffer.getLong();
|
long modeValue = dataByteBuffer.getLong();
|
||||||
Mode mode = Mode.valueOf((int) (modeValue & 0xffL));
|
AcctMode acctMode = AcctMode.valueOf((int) (modeValue & 0xffL));
|
||||||
|
|
||||||
/* End of variables */
|
/* End of variables */
|
||||||
|
|
||||||
if (mode != null && mode != Mode.OFFERING) {
|
if (acctMode != null && acctMode != AcctMode.OFFERING) {
|
||||||
tradeData.mode = mode;
|
tradeData.mode = acctMode;
|
||||||
tradeData.refundTimeout = refundTimeout;
|
tradeData.refundTimeout = refundTimeout;
|
||||||
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
|
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
|
||||||
tradeData.qortalPartnerAddress = qortalRecipient;
|
tradeData.qortalPartnerAddress = qortalRecipient;
|
||||||
tradeData.hashOfSecretA = hashOfSecretA;
|
tradeData.hashOfSecretA = hashOfSecretA;
|
||||||
tradeData.partnerBitcoinPKH = partnerBitcoinPKH;
|
tradeData.partnerForeignPKH = partnerBitcoinPKH;
|
||||||
tradeData.lockTimeA = lockTimeA;
|
tradeData.lockTimeA = lockTimeA;
|
||||||
tradeData.lockTimeB = lockTimeB;
|
tradeData.lockTimeB = lockTimeB;
|
||||||
|
|
||||||
if (mode == Mode.REDEEMED)
|
if (acctMode == AcctMode.REDEEMED)
|
||||||
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
|
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
|
||||||
} else {
|
} else {
|
||||||
tradeData.mode = Mode.OFFERING;
|
tradeData.mode = AcctMode.OFFERING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tradeData.duplicateDeprecated();
|
||||||
|
|
||||||
return tradeData;
|
return tradeData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -843,7 +844,8 @@ public class BTCACCT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
|
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
|
||||||
public static byte[] buildCancelMessage(String creatorQortalAddress) {
|
@Override
|
||||||
|
public byte[] buildCancelMessage(String creatorQortalAddress) {
|
||||||
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
|
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
|
||||||
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
|
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
|
||||||
|
|
||||||
@ -866,7 +868,7 @@ public class BTCACCT {
|
|||||||
|
|
||||||
/** Returns P2SH-B lockTime (epoch seconds) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */
|
/** 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) {
|
public static int calcLockTimeB(long offerMessageTimestamp, int lockTimeA) {
|
||||||
// lockTimeB is halfway between offerMessageTimesamp and lockTimeA
|
// lockTimeB is halfway between offerMessageTimestamp and lockTimeA
|
||||||
return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L);
|
return (int) ((lockTimeA + (offerMessageTimestamp / 1000L)) / 2L);
|
||||||
}
|
}
|
||||||
|
|
@ -1,57 +0,0 @@
|
|||||||
package org.qortal.crosschain;
|
|
||||||
|
|
||||||
@SuppressWarnings("serial")
|
|
||||||
public class BitcoinException extends Exception {
|
|
||||||
|
|
||||||
public BitcoinException() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public BitcoinException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class NetworkException extends BitcoinException {
|
|
||||||
private final Integer daemonErrorCode;
|
|
||||||
|
|
||||||
public NetworkException() {
|
|
||||||
super();
|
|
||||||
this.daemonErrorCode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public NetworkException(String message) {
|
|
||||||
super(message);
|
|
||||||
this.daemonErrorCode = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public NetworkException(int errorCode, String message) {
|
|
||||||
super(message);
|
|
||||||
this.daemonErrorCode = errorCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Integer getDaemonErrorCode() {
|
|
||||||
return this.daemonErrorCode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class NotFoundException extends BitcoinException {
|
|
||||||
public NotFoundException() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public NotFoundException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class InsufficientFundsException extends BitcoinException {
|
|
||||||
public InsufficientFundsException() {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
public InsufficientFundsException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
package org.qortal.crosschain;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
interface BitcoinNetworkProvider {
|
|
||||||
|
|
||||||
/** Returns current blockchain height. */
|
|
||||||
int getCurrentHeight() throws BitcoinException;
|
|
||||||
|
|
||||||
/** Returns a list of raw block headers, starting at <tt>startHeight</tt> (inclusive), up to <tt>count</tt> max. */
|
|
||||||
List<byte[]> getRawBlockHeaders(int startHeight, int count) throws BitcoinException;
|
|
||||||
|
|
||||||
/** Returns balance of address represented by <tt>scriptPubKey</tt>. */
|
|
||||||
long getConfirmedBalance(byte[] scriptPubKey) throws BitcoinException;
|
|
||||||
|
|
||||||
/** Returns raw, serialized, transaction bytes given <tt>txHash</tt>. */
|
|
||||||
byte[] getRawTransaction(String txHash) throws BitcoinException;
|
|
||||||
|
|
||||||
/** Returns unpacked transaction given <tt>txHash</tt>. */
|
|
||||||
BitcoinTransaction getTransaction(String txHash) throws BitcoinException;
|
|
||||||
|
|
||||||
/** Returns list of transaction hashes (and heights) for address represented by <tt>scriptPubKey</tt>, optionally including unconfirmed transactions. */
|
|
||||||
List<TransactionHash> getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws BitcoinException;
|
|
||||||
|
|
||||||
/** Returns list of unspent transaction outputs for address represented by <tt>scriptPubKey</tt>, optionally including unconfirmed transactions. */
|
|
||||||
List<UnspentOutput> getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws BitcoinException;
|
|
||||||
|
|
||||||
/** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */
|
|
||||||
boolean broadcastTransaction(byte[] rawTransaction) throws BitcoinException;
|
|
||||||
|
|
||||||
}
|
|
704
src/main/java/org/qortal/crosschain/Bitcoiny.java
Normal file
704
src/main/java/org/qortal/crosschain/Bitcoiny.java
Normal file
@ -0,0 +1,704 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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.AddressFormatException;
|
||||||
|
import org.bitcoinj.core.Coin;
|
||||||
|
import org.bitcoinj.core.Context;
|
||||||
|
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.ChildNumber;
|
||||||
|
import org.bitcoinj.crypto.DeterministicHierarchy;
|
||||||
|
import org.bitcoinj.crypto.DeterministicKey;
|
||||||
|
import org.bitcoinj.script.Script.ScriptType;
|
||||||
|
import org.bitcoinj.script.ScriptBuilder;
|
||||||
|
import org.bitcoinj.wallet.DeterministicKeyChain;
|
||||||
|
import org.bitcoinj.wallet.SendRequest;
|
||||||
|
import org.bitcoinj.wallet.Wallet;
|
||||||
|
import org.qortal.api.model.SimpleForeignTransaction;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.utils.Amounts;
|
||||||
|
import org.qortal.utils.BitTwiddling;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
/** Bitcoin-like (Bitcoin, Litecoin, etc.) support */
|
||||||
|
public abstract class Bitcoiny implements ForeignBlockchain {
|
||||||
|
|
||||||
|
protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class);
|
||||||
|
|
||||||
|
public static final int HASH160_LENGTH = 20;
|
||||||
|
|
||||||
|
protected final BitcoinyBlockchainProvider blockchain;
|
||||||
|
protected final Context bitcoinjContext;
|
||||||
|
protected final String currencyCode;
|
||||||
|
|
||||||
|
protected final NetworkParameters params;
|
||||||
|
|
||||||
|
/** Keys that have been previously marked as fully spent,<br>
|
||||||
|
* i.e. keys with transactions but with no unspent outputs. */
|
||||||
|
protected final Set<ECKey> spentKeys = Collections.synchronizedSet(new HashSet<>());
|
||||||
|
|
||||||
|
/** How many bitcoinj wallet keys to generate in each batch. */
|
||||||
|
private static final int WALLET_KEY_LOOKAHEAD_INCREMENT = 3;
|
||||||
|
|
||||||
|
/** Byte offset into raw block headers to block timestamp. */
|
||||||
|
private static final int TIMESTAMP_OFFSET = 4 + 32 + 32;
|
||||||
|
|
||||||
|
// Constructors and instance
|
||||||
|
|
||||||
|
protected Bitcoiny(BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||||
|
this.blockchain = blockchain;
|
||||||
|
this.bitcoinjContext = bitcoinjContext;
|
||||||
|
this.currencyCode = currencyCode;
|
||||||
|
|
||||||
|
this.params = this.bitcoinjContext.getParams();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters & setters
|
||||||
|
|
||||||
|
public BitcoinyBlockchainProvider getBlockchainProvider() {
|
||||||
|
return this.blockchain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Context getBitcoinjContext() {
|
||||||
|
return this.bitcoinjContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrencyCode() {
|
||||||
|
return this.currencyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkParameters getNetworkParameters() {
|
||||||
|
return this.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface obligations
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isValidAddress(String address) {
|
||||||
|
try {
|
||||||
|
ScriptType addressType = Address.fromString(this.params, address).getOutputScriptType();
|
||||||
|
|
||||||
|
return addressType == ScriptType.P2PKH || addressType == ScriptType.P2SH;
|
||||||
|
} catch (AddressFormatException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isValidWalletKey(String walletKey) {
|
||||||
|
return this.isValidDeterministicKey(walletKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual useful methods for use by other classes
|
||||||
|
|
||||||
|
public String format(Coin amount) {
|
||||||
|
return this.format(amount.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String format(long amount) {
|
||||||
|
return Amounts.prettyAmount(amount) + " " + this.currencyCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isValidDeterministicKey(String key58) {
|
||||||
|
try {
|
||||||
|
Context.propagate(this.bitcoinjContext);
|
||||||
|
DeterministicKey.deserializeB58(null, key58, this.params);
|
||||||
|
return true;
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns P2PKH address using passed public key hash. */
|
||||||
|
public String pkhToAddress(byte[] publicKeyHash) {
|
||||||
|
Context.propagate(this.bitcoinjContext);
|
||||||
|
return LegacyAddress.fromPubKeyHash(this.params, publicKeyHash).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns P2SH address using passed redeem script. */
|
||||||
|
public String deriveP2shAddress(byte[] redeemScriptBytes) {
|
||||||
|
Context.propagate(bitcoinjContext);
|
||||||
|
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||||
|
return LegacyAddress.fromScriptHash(this.params, redeemScriptHash).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns median timestamp from latest 11 blocks, in seconds.
|
||||||
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException if error occurs
|
||||||
|
*/
|
||||||
|
public int getMedianBlockTime() throws ForeignBlockchainException {
|
||||||
|
int height = this.blockchain.getCurrentHeight();
|
||||||
|
|
||||||
|
// Grab latest 11 blocks
|
||||||
|
List<byte[]> blockHeaders = this.blockchain.getRawBlockHeaders(height - 11, 11);
|
||||||
|
if (blockHeaders.size() < 11)
|
||||||
|
throw new ForeignBlockchainException("Not enough blocks to determine median block time");
|
||||||
|
|
||||||
|
List<Integer> blockTimestamps = blockHeaders.stream().map(blockHeader -> BitTwiddling.intFromLEBytes(blockHeader, TIMESTAMP_OFFSET)).collect(Collectors.toList());
|
||||||
|
|
||||||
|
// Descending order
|
||||||
|
blockTimestamps.sort((a, b) -> Integer.compare(b, a));
|
||||||
|
|
||||||
|
// Pick median
|
||||||
|
return blockTimestamps.get(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns fee per transaction KB. To be overridden for testnet/regtest. */
|
||||||
|
public Coin getFeePerKb() {
|
||||||
|
return this.bitcoinjContext.getFeePerKb();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns fixed P2SH spending fee, in sats per 1000bytes, optionally for historic timestamp.
|
||||||
|
*
|
||||||
|
* @param timestamp optional milliseconds since epoch, or null for 'now'
|
||||||
|
* @return sats per 1000bytes
|
||||||
|
* @throws ForeignBlockchainException if something went wrong
|
||||||
|
*/
|
||||||
|
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns confirmed balance, based on passed payment script.
|
||||||
|
* <p>
|
||||||
|
* @return confirmed balance, or zero if script unknown
|
||||||
|
* @throws ForeignBlockchainException if there was an error
|
||||||
|
*/
|
||||||
|
public long getConfirmedBalance(String base58Address) throws ForeignBlockchainException {
|
||||||
|
return this.blockchain.getConfirmedBalance(addressToScriptPubKey(base58Address));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list of unspent outputs pertaining to passed address.
|
||||||
|
* <p>
|
||||||
|
* @return list of unspent outputs, or empty list if address unknown
|
||||||
|
* @throws ForeignBlockchainException if there was an error.
|
||||||
|
*/
|
||||||
|
// TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead
|
||||||
|
public List<TransactionOutput> getUnspentOutputs(String base58Address) throws ForeignBlockchainException {
|
||||||
|
List<UnspentOutput> unspentOutputs = this.blockchain.getUnspentOutputs(addressToScriptPubKey(base58Address), false);
|
||||||
|
|
||||||
|
List<TransactionOutput> unspentTransactionOutputs = new ArrayList<>();
|
||||||
|
for (UnspentOutput unspentOutput : unspentOutputs) {
|
||||||
|
List<TransactionOutput> transactionOutputs = this.getOutputs(unspentOutput.hash);
|
||||||
|
|
||||||
|
unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index));
|
||||||
|
}
|
||||||
|
|
||||||
|
return unspentTransactionOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list of outputs pertaining to passed transaction hash.
|
||||||
|
* <p>
|
||||||
|
* @return list of outputs, or empty list if transaction unknown
|
||||||
|
* @throws ForeignBlockchainException if there was an error.
|
||||||
|
*/
|
||||||
|
// TODO: don't return bitcoinj-based objects like TransactionOutput, use BitcoinyTransaction.Output instead
|
||||||
|
public List<TransactionOutput> getOutputs(byte[] txHash) throws ForeignBlockchainException {
|
||||||
|
byte[] rawTransactionBytes = this.blockchain.getRawTransaction(txHash);
|
||||||
|
|
||||||
|
Context.propagate(bitcoinjContext);
|
||||||
|
Transaction transaction = new Transaction(this.params, rawTransactionBytes);
|
||||||
|
return transaction.getOutputs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list of transaction hashes pertaining to passed address.
|
||||||
|
* <p>
|
||||||
|
* @return list of unspent outputs, or empty list if script unknown
|
||||||
|
* @throws ForeignBlockchainException if there was an error.
|
||||||
|
*/
|
||||||
|
public List<TransactionHash> getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException {
|
||||||
|
return this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list of raw, confirmed transactions involving given address.
|
||||||
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException if there was an error
|
||||||
|
*/
|
||||||
|
public List<byte[]> getAddressTransactions(String base58Address) throws ForeignBlockchainException {
|
||||||
|
List<TransactionHash> transactionHashes = this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), false);
|
||||||
|
|
||||||
|
List<byte[]> rawTransactions = new ArrayList<>();
|
||||||
|
for (TransactionHash transactionInfo : transactionHashes) {
|
||||||
|
byte[] rawTransaction = this.blockchain.getRawTransaction(HashCode.fromString(transactionInfo.txHash).asBytes());
|
||||||
|
rawTransactions.add(rawTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns transaction info for passed transaction hash.
|
||||||
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException.NotFoundException if transaction unknown
|
||||||
|
* @throws ForeignBlockchainException if error occurs
|
||||||
|
*/
|
||||||
|
public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException {
|
||||||
|
return this.blockchain.getTransaction(txHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Broadcasts raw transaction to network.
|
||||||
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException if error occurs
|
||||||
|
*/
|
||||||
|
public void broadcastTransaction(Transaction transaction) throws ForeignBlockchainException {
|
||||||
|
this.blockchain.broadcastTransaction(transaction.bitcoinSerialize());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt>.
|
||||||
|
*
|
||||||
|
* @param xprv58 BIP32 private key
|
||||||
|
* @param recipient P2PKH address
|
||||||
|
* @param amount unscaled amount
|
||||||
|
* @param feePerByte unscaled fee per byte, or null to use default fees
|
||||||
|
* @return transaction, or null if insufficient funds
|
||||||
|
*/
|
||||||
|
public Transaction buildSpend(String xprv58, String recipient, long amount, Long feePerByte) {
|
||||||
|
Context.propagate(bitcoinjContext);
|
||||||
|
|
||||||
|
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||||
|
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
|
||||||
|
|
||||||
|
Address destination = Address.fromString(this.params, recipient);
|
||||||
|
SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount));
|
||||||
|
|
||||||
|
if (feePerByte != null)
|
||||||
|
sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024
|
||||||
|
else
|
||||||
|
// Allow override of default for TestNet3, etc.
|
||||||
|
sendRequest.feePerKb = this.getFeePerKb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
wallet.completeTx(sendRequest);
|
||||||
|
return sendRequest.tx;
|
||||||
|
} catch (InsufficientMoneyException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt> using default fees.
|
||||||
|
*
|
||||||
|
* @param xprv58 BIP32 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) {
|
||||||
|
return buildSpend(xprv58, recipient, amount, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns unspent Bitcoin balance given 'm' BIP32 key.
|
||||||
|
*
|
||||||
|
* @param key58 BIP32/HD extended Bitcoin private/public key
|
||||||
|
* @return unspent BTC balance, or null if unable to determine balance
|
||||||
|
*/
|
||||||
|
public Long getWalletBalance(String key58) {
|
||||||
|
Context.propagate(bitcoinjContext);
|
||||||
|
|
||||||
|
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||||
|
wallet.setUTXOProvider(new WalletAwareUTXOProvider(this, wallet));
|
||||||
|
|
||||||
|
Coin balance = wallet.getBalance();
|
||||||
|
if (balance == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return balance.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<BitcoinyTransaction> getWalletTransactions(String key58) throws ForeignBlockchainException {
|
||||||
|
Context.propagate(bitcoinjContext);
|
||||||
|
|
||||||
|
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||||
|
DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
|
||||||
|
|
||||||
|
keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||||
|
keyChain.maybeLookAhead();
|
||||||
|
|
||||||
|
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
||||||
|
|
||||||
|
Set<BitcoinyTransaction> walletTransactions = new HashSet<>();
|
||||||
|
|
||||||
|
int ki = 0;
|
||||||
|
do {
|
||||||
|
boolean areAllKeysUnused = true;
|
||||||
|
|
||||||
|
for (; ki < keys.size(); ++ki) {
|
||||||
|
DeterministicKey dKey = keys.get(ki);
|
||||||
|
|
||||||
|
// Check for transactions
|
||||||
|
Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
|
||||||
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
|
|
||||||
|
// Ask for transaction history - if it's empty then key has never been used
|
||||||
|
List<TransactionHash> historicTransactionHashes = this.blockchain.getAddressTransactions(script, false);
|
||||||
|
|
||||||
|
if (!historicTransactionHashes.isEmpty()) {
|
||||||
|
areAllKeysUnused = false;
|
||||||
|
|
||||||
|
for (TransactionHash transactionHash : historicTransactionHashes)
|
||||||
|
walletTransactions.add(this.getTransaction(transactionHash.txHash));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areAllKeysUnused)
|
||||||
|
// No transactions for this batch of keys so assume we're done searching.
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Generate some more keys
|
||||||
|
keys.addAll(generateMoreKeys(keyChain));
|
||||||
|
|
||||||
|
// Process new keys
|
||||||
|
} while (true);
|
||||||
|
|
||||||
|
return walletTransactions.stream().collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns first unused receive address given 'm' BIP32 key.
|
||||||
|
*
|
||||||
|
* @param key58 BIP32/HD extended Bitcoin private/public key
|
||||||
|
* @return P2PKH address
|
||||||
|
* @throws ForeignBlockchainException if something went wrong
|
||||||
|
*/
|
||||||
|
public String getUnusedReceiveAddress(String key58) throws ForeignBlockchainException {
|
||||||
|
Context.propagate(bitcoinjContext);
|
||||||
|
|
||||||
|
Wallet wallet = walletFromDeterministicKey58(key58);
|
||||||
|
DeterministicKeyChain keyChain = wallet.getActiveKeyChain();
|
||||||
|
|
||||||
|
keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||||
|
keyChain.maybeLookAhead();
|
||||||
|
|
||||||
|
final int keyChainPathSize = keyChain.getAccountPath().size();
|
||||||
|
List<DeterministicKey> keys = new ArrayList<>(keyChain.getLeafKeys());
|
||||||
|
|
||||||
|
int ki = 0;
|
||||||
|
do {
|
||||||
|
for (; ki < keys.size(); ++ki) {
|
||||||
|
DeterministicKey dKey = keys.get(ki);
|
||||||
|
List<ChildNumber> dKeyPath = dKey.getPath();
|
||||||
|
|
||||||
|
// If keyChain is based on 'm', then make sure dKey is m/0/ki - i.e. a 'receive' address, not 'change' (m/1/ki)
|
||||||
|
if (dKeyPath.size() != keyChainPathSize + 2 || dKeyPath.get(dKeyPath.size() - 2) != ChildNumber.ZERO)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Check unspent
|
||||||
|
Address address = Address.fromKey(this.params, dKey, ScriptType.P2PKH);
|
||||||
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
|
|
||||||
|
List<UnspentOutput> unspentOutputs = this.blockchain.getUnspentOutputs(script, false);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If there are no unspent outputs then either:
|
||||||
|
* a) all the outputs have been spent
|
||||||
|
* b) address has never been used
|
||||||
|
*
|
||||||
|
* For case (a) we want to remember not to check this address (key) again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (unspentOutputs.isEmpty()) {
|
||||||
|
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
||||||
|
if (this.spentKeys.contains(dKey)) {
|
||||||
|
wallet.getActiveKeyChain().markKeyAsUsed(dKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for transaction history - if it's empty then key has never been used
|
||||||
|
List<TransactionHash> historicTransactionHashes = this.blockchain.getAddressTransactions(script, false);
|
||||||
|
|
||||||
|
if (!historicTransactionHashes.isEmpty()) {
|
||||||
|
// Fully spent key - case (a)
|
||||||
|
this.spentKeys.add(dKey);
|
||||||
|
wallet.getActiveKeyChain().markKeyAsUsed(dKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key never been used - case (b)
|
||||||
|
return address.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key has unspent outputs, hence used, so no good to us
|
||||||
|
this.spentKeys.remove(dKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate some more keys
|
||||||
|
keys.addAll(generateMoreKeys(keyChain));
|
||||||
|
|
||||||
|
// Process new keys
|
||||||
|
} while (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTXOProvider support
|
||||||
|
|
||||||
|
static class WalletAwareUTXOProvider implements UTXOProvider {
|
||||||
|
private final Bitcoiny bitcoiny;
|
||||||
|
private final Wallet wallet;
|
||||||
|
|
||||||
|
private final DeterministicKeyChain keyChain;
|
||||||
|
|
||||||
|
public WalletAwareUTXOProvider(Bitcoiny bitcoiny, Wallet wallet) {
|
||||||
|
this.bitcoiny = bitcoiny;
|
||||||
|
this.wallet = wallet;
|
||||||
|
this.keyChain = this.wallet.getActiveKeyChain();
|
||||||
|
|
||||||
|
// Set up wallet's key chain
|
||||||
|
this.keyChain.setLookaheadSize(Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||||
|
this.keyChain.maybeLookAhead();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<UTXO> getOpenTransactionOutputs(List<ECKey> keys) throws UTXOProviderException {
|
||||||
|
List<UTXO> allUnspentOutputs = new ArrayList<>();
|
||||||
|
final boolean coinbase = false;
|
||||||
|
|
||||||
|
int ki = 0;
|
||||||
|
do {
|
||||||
|
boolean areAllKeysUnspent = true;
|
||||||
|
|
||||||
|
for (; ki < keys.size(); ++ki) {
|
||||||
|
ECKey key = keys.get(ki);
|
||||||
|
|
||||||
|
Address address = Address.fromKey(this.bitcoiny.params, key, ScriptType.P2PKH);
|
||||||
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
|
|
||||||
|
List<UnspentOutput> unspentOutputs;
|
||||||
|
try {
|
||||||
|
unspentOutputs = this.bitcoiny.blockchain.getUnspentOutputs(script, false);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw new UTXOProviderException(String.format("Unable to fetch unspent outputs for %s", address));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If there are no unspent outputs then either:
|
||||||
|
* a) all the outputs have been spent
|
||||||
|
* b) address has never been used
|
||||||
|
*
|
||||||
|
* For case (a) we want to remember not to check this address (key) again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (unspentOutputs.isEmpty()) {
|
||||||
|
// If this is a known key that has been spent before, then we can skip asking for transaction history
|
||||||
|
if (this.bitcoiny.spentKeys.contains(key)) {
|
||||||
|
this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
||||||
|
areAllKeysUnspent = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ask for transaction history - if it's empty then key has never been used
|
||||||
|
List<TransactionHash> historicTransactionHashes;
|
||||||
|
try {
|
||||||
|
historicTransactionHashes = this.bitcoiny.blockchain.getAddressTransactions(script, false);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw new UTXOProviderException(String.format("Unable to fetch transaction history for %s", address));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!historicTransactionHashes.isEmpty()) {
|
||||||
|
// Fully spent key - case (a)
|
||||||
|
this.bitcoiny.spentKeys.add(key);
|
||||||
|
this.wallet.getActiveKeyChain().markKeyAsUsed((DeterministicKey) key);
|
||||||
|
areAllKeysUnspent = false;
|
||||||
|
} else {
|
||||||
|
// Key never been used - case (b)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, then there's definitely at least one unspent key
|
||||||
|
this.bitcoiny.spentKeys.remove(key);
|
||||||
|
|
||||||
|
for (UnspentOutput unspentOutput : unspentOutputs) {
|
||||||
|
List<TransactionOutput> transactionOutputs;
|
||||||
|
try {
|
||||||
|
transactionOutputs = this.bitcoiny.getOutputs(unspentOutput.hash);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
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 (areAllKeysUnspent)
|
||||||
|
// No transactions for this batch of keys so assume we're done searching.
|
||||||
|
return allUnspentOutputs;
|
||||||
|
|
||||||
|
// Generate some more keys
|
||||||
|
keys.addAll(Bitcoiny.generateMoreKeys(this.keyChain));
|
||||||
|
|
||||||
|
// Process new keys
|
||||||
|
} while (true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getChainHeadHeight() throws UTXOProviderException {
|
||||||
|
try {
|
||||||
|
return this.bitcoiny.blockchain.getCurrentHeight();
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
throw new UTXOProviderException("Unable to determine Bitcoiny chain height");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return this.bitcoiny.params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility methods for others
|
||||||
|
|
||||||
|
public static List<SimpleForeignTransaction> simplifyWalletTransactions(List<BitcoinyTransaction> transactions) {
|
||||||
|
// Sort by oldest timestamp first
|
||||||
|
transactions.sort(Comparator.comparingInt(t -> t.timestamp));
|
||||||
|
|
||||||
|
// Manual 2nd-level sort same-timestamp transactions so that a transaction's input comes first
|
||||||
|
int fromIndex = 0;
|
||||||
|
do {
|
||||||
|
int timestamp = transactions.get(fromIndex).timestamp;
|
||||||
|
|
||||||
|
int toIndex;
|
||||||
|
for (toIndex = fromIndex + 1; toIndex < transactions.size(); ++toIndex)
|
||||||
|
if (transactions.get(toIndex).timestamp != timestamp)
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Process same-timestamp sub-list
|
||||||
|
List<BitcoinyTransaction> subList = transactions.subList(fromIndex, toIndex);
|
||||||
|
|
||||||
|
// Only if necessary
|
||||||
|
if (subList.size() > 1) {
|
||||||
|
// Quick index lookup
|
||||||
|
Map<String, Integer> indexByTxHash = subList.stream().collect(Collectors.toMap(t -> t.txHash, t -> t.timestamp));
|
||||||
|
|
||||||
|
int restartIndex = 0;
|
||||||
|
boolean isSorted;
|
||||||
|
do {
|
||||||
|
isSorted = true;
|
||||||
|
|
||||||
|
for (int ourIndex = restartIndex; ourIndex < subList.size(); ++ourIndex) {
|
||||||
|
BitcoinyTransaction ourTx = subList.get(ourIndex);
|
||||||
|
|
||||||
|
for (BitcoinyTransaction.Input input : ourTx.inputs) {
|
||||||
|
Integer inputIndex = indexByTxHash.get(input.outputTxHash);
|
||||||
|
|
||||||
|
if (inputIndex != null && inputIndex > ourIndex) {
|
||||||
|
// Input tx is currently after current tx, so swap
|
||||||
|
BitcoinyTransaction tmpTx = subList.get(inputIndex);
|
||||||
|
subList.set(inputIndex, ourTx);
|
||||||
|
subList.set(ourIndex, tmpTx);
|
||||||
|
|
||||||
|
// Update index lookup too
|
||||||
|
indexByTxHash.put(ourTx.txHash, inputIndex);
|
||||||
|
indexByTxHash.put(tmpTx.txHash, ourIndex);
|
||||||
|
|
||||||
|
if (isSorted)
|
||||||
|
restartIndex = Math.max(restartIndex, ourIndex);
|
||||||
|
|
||||||
|
isSorted = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (!isSorted);
|
||||||
|
}
|
||||||
|
|
||||||
|
fromIndex = toIndex;
|
||||||
|
} while (fromIndex < transactions.size());
|
||||||
|
|
||||||
|
// Simplify
|
||||||
|
List<SimpleForeignTransaction> simpleTransactions = new ArrayList<>();
|
||||||
|
|
||||||
|
// Quick lookup of txs in our wallet
|
||||||
|
Set<String> walletTxHashes = transactions.stream().map(t -> t.txHash).collect(Collectors.toSet());
|
||||||
|
|
||||||
|
for (BitcoinyTransaction transaction : transactions) {
|
||||||
|
SimpleForeignTransaction.Builder builder = new SimpleForeignTransaction.Builder();
|
||||||
|
builder.txHash(transaction.txHash);
|
||||||
|
builder.timestamp(transaction.timestamp);
|
||||||
|
|
||||||
|
builder.isSentNotReceived(false);
|
||||||
|
|
||||||
|
for (BitcoinyTransaction.Input input : transaction.inputs) {
|
||||||
|
// TODO: add input via builder
|
||||||
|
|
||||||
|
if (walletTxHashes.contains(input.outputTxHash))
|
||||||
|
builder.isSentNotReceived(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (BitcoinyTransaction.Output output : transaction.outputs)
|
||||||
|
builder.output(output.addresses, output.value);
|
||||||
|
|
||||||
|
simpleTransactions.add(builder.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return simpleTransactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility methods for us
|
||||||
|
|
||||||
|
protected static List<DeterministicKey> generateMoreKeys(DeterministicKeyChain keyChain) {
|
||||||
|
int existingLeafKeyCount = keyChain.getLeafKeys().size();
|
||||||
|
|
||||||
|
// Increase lookahead size...
|
||||||
|
keyChain.setLookaheadSize(keyChain.getLookaheadSize() + Bitcoiny.WALLET_KEY_LOOKAHEAD_INCREMENT);
|
||||||
|
// ...and lookahead threshold (minimum number of keys to generate)...
|
||||||
|
keyChain.setLookaheadThreshold(0);
|
||||||
|
// ...so that this call will generate more keys
|
||||||
|
keyChain.maybeLookAhead();
|
||||||
|
|
||||||
|
// This returns *all* keys
|
||||||
|
List<DeterministicKey> allLeafKeys = keyChain.getLeafKeys();
|
||||||
|
|
||||||
|
// Only return newly generated keys
|
||||||
|
return allLeafKeys.subList(existingLeafKeyCount, allLeafKeys.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
protected byte[] addressToScriptPubKey(String base58Address) {
|
||||||
|
Context.propagate(this.bitcoinjContext);
|
||||||
|
Address address = Address.fromString(this.params, base58Address);
|
||||||
|
return ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected Wallet walletFromDeterministicKey58(String key58) {
|
||||||
|
DeterministicKey dKey = DeterministicKey.deserializeB58(null, key58, this.params);
|
||||||
|
|
||||||
|
if (dKey.hasPrivKey())
|
||||||
|
return Wallet.fromSpendingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||||
|
else
|
||||||
|
return Wallet.fromWatchingKeyB58(this.params, key58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public abstract class BitcoinyBlockchainProvider {
|
||||||
|
|
||||||
|
public static final boolean INCLUDE_UNCONFIRMED = true;
|
||||||
|
public static final boolean EXCLUDE_UNCONFIRMED = false;
|
||||||
|
|
||||||
|
/** Returns ID unique to bitcoiny network (e.g. "Litecoin-TEST3") */
|
||||||
|
public abstract String getNetId();
|
||||||
|
|
||||||
|
/** Returns current blockchain height. */
|
||||||
|
public abstract int getCurrentHeight() throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns a list of raw block headers, starting at <tt>startHeight</tt> (inclusive), up to <tt>count</tt> max. */
|
||||||
|
public abstract List<byte[]> getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns balance of address represented by <tt>scriptPubKey</tt>. */
|
||||||
|
public abstract long getConfirmedBalance(byte[] scriptPubKey) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns raw, serialized, transaction bytes given <tt>txHash</tt>. */
|
||||||
|
public abstract byte[] getRawTransaction(String txHash) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns raw, serialized, transaction bytes given <tt>txHash</tt>. */
|
||||||
|
public abstract byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns unpacked transaction given <tt>txHash</tt>. */
|
||||||
|
public abstract BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns list of transaction hashes (and heights) for address represented by <tt>scriptPubKey</tt>, optionally including unconfirmed transactions. */
|
||||||
|
public abstract List<TransactionHash> getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Returns list of unspent transaction outputs for address represented by <tt>scriptPubKey</tt>, optionally including unconfirmed transactions. */
|
||||||
|
public abstract List<UnspentOutput> getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
/** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */
|
||||||
|
public abstract void broadcastTransaction(byte[] rawTransaction) throws ForeignBlockchainException;
|
||||||
|
|
||||||
|
}
|
@ -4,6 +4,7 @@ import java.util.ArrayList;
|
|||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
@ -29,7 +30,7 @@ import org.qortal.utils.BitTwiddling;
|
|||||||
import com.google.common.hash.HashCode;
|
import com.google.common.hash.HashCode;
|
||||||
import com.google.common.primitives.Bytes;
|
import com.google.common.primitives.Bytes;
|
||||||
|
|
||||||
public class BTCP2SH {
|
public class BitcoinyHTLC {
|
||||||
|
|
||||||
public enum Status {
|
public enum Status {
|
||||||
UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED
|
UNFUNDED, FUNDING_IN_PROGRESS, FUNDED, REDEEM_IN_PROGRESS, REDEEMED, REFUND_IN_PROGRESS, REFUNDED
|
||||||
@ -38,6 +39,34 @@ public class BTCP2SH {
|
|||||||
public static final int SECRET_LENGTH = 32;
|
public static final int SECRET_LENGTH = 32;
|
||||||
public static final int MIN_LOCKTIME = 1500000000;
|
public static final int MIN_LOCKTIME = 1500000000;
|
||||||
|
|
||||||
|
public static final long NO_LOCKTIME_NO_RBF_SEQUENCE = 0xFFFFFFFFL;
|
||||||
|
public static final long LOCKTIME_NO_RBF_SEQUENCE = NO_LOCKTIME_NO_RBF_SEQUENCE - 1;
|
||||||
|
|
||||||
|
// Assuming node's trade-bot has no more than 100 entries?
|
||||||
|
private static final int MAX_CACHE_ENTRIES = 100;
|
||||||
|
|
||||||
|
// Max time-to-live for cache entries (milliseconds)
|
||||||
|
private static final long CACHE_TIMEOUT = 30_000L;
|
||||||
|
|
||||||
|
@SuppressWarnings("serial")
|
||||||
|
private static final Map<String, byte[]> SECRET_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) {
|
||||||
|
// This method is called just after a new entry has been added
|
||||||
|
@Override
|
||||||
|
public boolean removeEldestEntry(Map.Entry<String, byte[]> eldest) {
|
||||||
|
return size() > MAX_CACHE_ENTRIES;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0];
|
||||||
|
|
||||||
|
@SuppressWarnings("serial")
|
||||||
|
private static final Map<String, Status> STATUS_CACHE = new LinkedHashMap<>(MAX_CACHE_ENTRIES + 1, 0.75F, true) {
|
||||||
|
// This method is called just after a new entry has been added
|
||||||
|
@Override
|
||||||
|
public boolean removeEldestEntry(Map.Entry<String, Status> eldest) {
|
||||||
|
return size() > MAX_CACHE_ENTRIES;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* OP_TUCK (to copy public key to before signature)
|
* OP_TUCK (to copy public key to before signature)
|
||||||
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
|
* OP_CHECKSIGVERIFY (sig & pubkey must verify or script fails)
|
||||||
@ -62,24 +91,24 @@ public class BTCP2SH {
|
|||||||
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
|
private static final byte[] redeemScript5 = HashCode.fromString("8768").asBytes(); // OP_EQUAL OP_ENDIF
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns Bitcoin redeemScript used for cross-chain trading.
|
* Returns redeemScript used for cross-chain trading.
|
||||||
* <p>
|
* <p>
|
||||||
* See comments in {@link BTCP2SH} for more details.
|
* See comments in {@link BitcoinyHTLC} for more details.
|
||||||
*
|
*
|
||||||
* @param refunderPubKeyHash 20-byte HASH160 of P2SH funder's public key, for refunding purposes
|
* @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 lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund
|
||||||
* @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key
|
* @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
|
* @param hashOfSecret 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) {
|
public static byte[] buildScript(byte[] refunderPubKeyHash, int lockTime, byte[] redeemerPubKeyHash, byte[] hashOfSecret) {
|
||||||
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
|
return Bytes.concat(redeemScript1, refunderPubKeyHash, redeemScript2, BitTwiddling.toLEByteArray((int) (lockTime & 0xffffffffL)),
|
||||||
redeemScript3, redeemerPubKeyHash, redeemScript4, secretHash, redeemScript5);
|
redeemScript3, redeemerPubKeyHash, redeemScript4, hashOfSecret, redeemScript5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a custom transaction to spend P2SH.
|
* Builds a custom transaction to spend HTLC P2SH.
|
||||||
*
|
*
|
||||||
|
* @param params blockchain network parameters
|
||||||
* @param amount output amount, should be total of input amounts, less miner fees
|
* @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 spendKey key for signing transaction, and also where funds are 'sent' (output)
|
||||||
* @param fundingOutput output from transaction that funded P2SH address
|
* @param fundingOutput output from transaction that funded P2SH address
|
||||||
@ -87,12 +116,11 @@ public class BTCP2SH {
|
|||||||
* @param lockTime (optional) transaction nLockTime, used in refund scenario
|
* @param lockTime (optional) transaction nLockTime, used in refund scenario
|
||||||
* @param scriptSigBuilder function for building scriptSig using transaction input signature
|
* @param scriptSigBuilder function for building scriptSig using transaction input signature
|
||||||
* @param outputPublicKeyHash PKH used to create P2PKH output
|
* @param outputPublicKeyHash PKH used to create P2PKH output
|
||||||
* @return Signed Bitcoin transaction for spending P2SH
|
* @return Signed transaction for spending P2SH
|
||||||
*/
|
*/
|
||||||
public static Transaction buildP2shTransaction(Coin amount, ECKey spendKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes,
|
public static Transaction buildP2shTransaction(NetworkParameters params, Coin amount, ECKey spendKey,
|
||||||
|
List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes,
|
||||||
Long lockTime, Function<byte[], Script> scriptSigBuilder, byte[] outputPublicKeyHash) {
|
Long lockTime, Function<byte[], Script> scriptSigBuilder, byte[] outputPublicKeyHash) {
|
||||||
NetworkParameters params = BTC.getInstance().getNetworkParameters();
|
|
||||||
|
|
||||||
Transaction transaction = new Transaction(params);
|
Transaction transaction = new Transaction(params);
|
||||||
transaction.setVersion(2);
|
transaction.setVersion(2);
|
||||||
|
|
||||||
@ -105,9 +133,9 @@ public class BTCP2SH {
|
|||||||
// Input (without scriptSig prior to signing)
|
// Input (without scriptSig prior to signing)
|
||||||
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
|
TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor());
|
||||||
if (lockTime != null)
|
if (lockTime != null)
|
||||||
input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
|
input.setSequenceNumber(LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 1, so lockTime can be used but not RBF
|
||||||
else
|
else
|
||||||
input.setSequenceNumber(BTC.NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
|
input.setSequenceNumber(NO_LOCKTIME_NO_RBF_SEQUENCE); // Use max-value, so no lockTime and no RBF
|
||||||
transaction.addInput(input);
|
transaction.addInput(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,17 +162,19 @@ public class BTCP2SH {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns signed Bitcoin transaction claiming refund from P2SH address.
|
* Returns signed transaction claiming refund from HTLC P2SH.
|
||||||
*
|
*
|
||||||
|
* @param params blockchain network parameters
|
||||||
* @param refundAmount refund amount, should be total of input amounts, less miner fees
|
* @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 refundKey key for signing transaction
|
||||||
* @param fundingOutput output from transaction that funded P2SH address
|
* @param fundingOutputs outputs from transaction that funded P2SH address
|
||||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||||
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
|
* @param lockTime transaction nLockTime - must be at least locktime used in redeemScript
|
||||||
* @param receivingAccountInfo Bitcoin PKH used for output
|
* @param receivingAccountInfo public-key-hash used for P2PKH output
|
||||||
* @return Signed Bitcoin transaction for refunding P2SH
|
* @return Signed transaction for refunding P2SH
|
||||||
*/
|
*/
|
||||||
public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) {
|
public static Transaction buildRefundTransaction(NetworkParameters params, Coin refundAmount, ECKey refundKey,
|
||||||
|
List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) {
|
||||||
Function<byte[], Script> refundSigScriptBuilder = (txSigBytes) -> {
|
Function<byte[], Script> refundSigScriptBuilder = (txSigBytes) -> {
|
||||||
// Build scriptSig with...
|
// Build scriptSig with...
|
||||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
||||||
@ -163,21 +193,23 @@ public class BTCP2SH {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Send funds back to funding address
|
// Send funds back to funding address
|
||||||
return buildP2shTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo);
|
return buildP2shTransaction(params, refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundSigScriptBuilder, receivingAccountInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns signed Bitcoin transaction redeeming funds from P2SH address.
|
* Returns signed transaction redeeming funds from P2SH address.
|
||||||
*
|
*
|
||||||
|
* @param params blockchain network parameters
|
||||||
* @param redeemAmount redeem amount, should be total of input amounts, less miner fees
|
* @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 redeemKey key for signing transaction
|
||||||
* @param fundingOutput output from transaction that funded P2SH address
|
* @param fundingOutputs outputs from transaction that funded P2SH address
|
||||||
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
* @param redeemScriptBytes the redeemScript itself, in byte[] form
|
||||||
* @param secret actual 32-byte secret used when building redeemScript
|
* @param secret actual 32-byte secret used when building redeemScript
|
||||||
* @param receivingAccountInfo Bitcoin PKH used for output
|
* @param receivingAccountInfo Bitcoin PKH used for output
|
||||||
* @return Signed Bitcoin transaction for redeeming P2SH
|
* @return Signed transaction for redeeming P2SH
|
||||||
*/
|
*/
|
||||||
public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
|
public static Transaction buildRedeemTransaction(NetworkParameters params, Coin redeemAmount, ECKey redeemKey,
|
||||||
|
List<TransactionOutput> fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) {
|
||||||
Function<byte[], Script> redeemSigScriptBuilder = (txSigBytes) -> {
|
Function<byte[], Script> redeemSigScriptBuilder = (txSigBytes) -> {
|
||||||
// Build scriptSig with...
|
// Build scriptSig with...
|
||||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
||||||
@ -198,17 +230,28 @@ public class BTCP2SH {
|
|||||||
return scriptBuilder.build();
|
return scriptBuilder.build();
|
||||||
};
|
};
|
||||||
|
|
||||||
return buildP2shTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
|
return buildP2shTransaction(params, redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, null, redeemSigScriptBuilder, receivingAccountInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns 'secret', if any, given list of raw bitcoin transactions. */
|
/**
|
||||||
public static byte[] findP2shSecret(String p2shAddress, List<byte[]> rawTransactions) {
|
* Returns 'secret', if any, given HTLC's P2SH address.
|
||||||
NetworkParameters params = BTC.getInstance().getNetworkParameters();
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException
|
||||||
|
*/
|
||||||
|
public static byte[] findHtlcSecret(Bitcoiny bitcoiny, String p2shAddress) throws ForeignBlockchainException {
|
||||||
|
NetworkParameters params = bitcoiny.getNetworkParameters();
|
||||||
|
String compoundKey = String.format("%s-%s-%d", params.getId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT);
|
||||||
|
|
||||||
|
byte[] secret = SECRET_CACHE.getOrDefault(compoundKey, NO_SECRET_CACHE_ENTRY);
|
||||||
|
if (secret != NO_SECRET_CACHE_ENTRY)
|
||||||
|
return secret;
|
||||||
|
|
||||||
|
List<byte[]> rawTransactions = bitcoiny.getAddressTransactions(p2shAddress);
|
||||||
|
|
||||||
for (byte[] rawTransaction : rawTransactions) {
|
for (byte[] rawTransaction : rawTransactions) {
|
||||||
Transaction transaction = new Transaction(params, rawTransaction);
|
Transaction transaction = new Transaction(params, rawTransaction);
|
||||||
|
|
||||||
// Cycle through inputs, looking for one that spends our P2SH
|
// Cycle through inputs, looking for one that spends our HTLC
|
||||||
for (TransactionInput input : transaction.getInputs()) {
|
for (TransactionInput input : transaction.getInputs()) {
|
||||||
Script scriptSig = input.getScriptSig();
|
Script scriptSig = input.getScriptSig();
|
||||||
List<ScriptChunk> scriptChunks = scriptSig.getChunks();
|
List<ScriptChunk> scriptChunks = scriptSig.getChunks();
|
||||||
@ -230,92 +273,115 @@ public class BTCP2SH {
|
|||||||
Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||||
|
|
||||||
if (!inputAddress.toString().equals(p2shAddress))
|
if (!inputAddress.toString().equals(p2shAddress))
|
||||||
// Input isn't spending our P2SH
|
// Input isn't spending our HTLC
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
byte[] secret = scriptChunks.get(0).data;
|
secret = scriptChunks.get(0).data;
|
||||||
if (secret.length != BTCP2SH.SECRET_LENGTH)
|
if (secret.length != BitcoinyHTLC.SECRET_LENGTH)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// Cache secret for a while
|
||||||
|
SECRET_CACHE.put(compoundKey, secret);
|
||||||
|
|
||||||
return secret;
|
return secret;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache negative result
|
||||||
|
SECRET_CACHE.put(compoundKey, null);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns P2SH status, given P2SH address and expected redeem/refund amount, or throws BitcoinException if error occurs. */
|
/**
|
||||||
public static Status determineP2shStatus(String p2shAddress, long minimumAmount) throws BitcoinException {
|
* Returns HTLC status, given P2SH address and expected redeem/refund amount
|
||||||
final BTC btc = BTC.getInstance();
|
* <p>
|
||||||
|
* @throws ForeignBlockchainException if error occurs
|
||||||
|
*/
|
||||||
|
public static Status determineHtlcStatus(BitcoinyBlockchainProvider blockchain, String p2shAddress, long minimumAmount) throws ForeignBlockchainException {
|
||||||
|
String compoundKey = String.format("%s-%s-%d", blockchain.getNetId(), p2shAddress, System.currentTimeMillis() / CACHE_TIMEOUT);
|
||||||
|
|
||||||
List<TransactionHash> transactionHashes = btc.getAddressTransactions(p2shAddress, BTC.INCLUDE_UNCONFIRMED);
|
Status cachedStatus = STATUS_CACHE.getOrDefault(compoundKey, null);
|
||||||
|
if (cachedStatus != null)
|
||||||
|
return cachedStatus;
|
||||||
|
|
||||||
|
byte[] ourScriptPubKey = addressToScriptPubKey(p2shAddress);
|
||||||
|
List<TransactionHash> transactionHashes = blockchain.getAddressTransactions(ourScriptPubKey, BitcoinyBlockchainProvider.INCLUDE_UNCONFIRMED);
|
||||||
|
|
||||||
// Sort by confirmed first, followed by ascending height
|
// Sort by confirmed first, followed by ascending height
|
||||||
transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight));
|
transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight));
|
||||||
|
|
||||||
// Transaction cache
|
// Transaction cache
|
||||||
Map<String, BitcoinTransaction> transactionsByHash = new HashMap<>();
|
Map<String, BitcoinyTransaction> transactionsByHash = new HashMap<>();
|
||||||
// HASH160(redeem script) for this p2shAddress
|
// HASH160(redeem script) for this p2shAddress
|
||||||
byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress);
|
byte[] ourRedeemScriptHash = addressToRedeemScriptHash(p2shAddress);
|
||||||
|
|
||||||
// Check for spends first, caching full transaction info as we progress just in case we don't return in this loop
|
// Check for spends first, caching full transaction info as we progress just in case we don't return in this loop
|
||||||
for (TransactionHash transactionInfo : transactionHashes) {
|
for (TransactionHash transactionInfo : transactionHashes) {
|
||||||
BitcoinTransaction bitcoinTransaction = btc.getTransaction(transactionInfo.txHash);
|
BitcoinyTransaction bitcoinyTransaction = blockchain.getTransaction(transactionInfo.txHash);
|
||||||
|
|
||||||
// Cache for possible later reuse
|
// Cache for possible later reuse
|
||||||
transactionsByHash.put(transactionInfo.txHash, bitcoinTransaction);
|
transactionsByHash.put(transactionInfo.txHash, bitcoinyTransaction);
|
||||||
|
|
||||||
// Acceptable funding is one transaction output, so we're expecting only one input
|
// Acceptable funding is one transaction output, so we're expecting only one input
|
||||||
if (bitcoinTransaction.inputs.size() != 1)
|
if (bitcoinyTransaction.inputs.size() != 1)
|
||||||
// Wrong number of inputs
|
// Wrong number of inputs
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
String scriptSig = bitcoinTransaction.inputs.get(0).scriptSig;
|
String scriptSig = bitcoinyTransaction.inputs.get(0).scriptSig;
|
||||||
|
|
||||||
List<byte[]> scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes());
|
List<byte[]> scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes());
|
||||||
if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4)
|
if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4)
|
||||||
// Not spending one of these P2SH
|
// Not valid chunks for our form of HTLC
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// Last chunk is redeem script
|
// Last chunk is redeem script
|
||||||
byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1);
|
byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1);
|
||||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||||
if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash))
|
if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash))
|
||||||
// Not spending our specific P2SH
|
// Not spending our specific HTLC redeem script
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
// If we have 4 chunks, then secret is present
|
if (scriptSigChunks.size() == 4)
|
||||||
return scriptSigChunks.size() == 4
|
// If we have 4 chunks, then secret is present, hence redeem
|
||||||
? (transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED)
|
cachedStatus = transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED;
|
||||||
: (transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED);
|
else
|
||||||
|
cachedStatus = transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED;
|
||||||
|
|
||||||
|
STATUS_CACHE.put(compoundKey, cachedStatus);
|
||||||
|
return cachedStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
String ourScriptPubKey = HashCode.fromBytes(addressToScriptPubKey(p2shAddress)).toString();
|
String ourScriptPubKeyHex = HashCode.fromBytes(ourScriptPubKey).toString();
|
||||||
|
|
||||||
// Check for funding
|
// Check for funding
|
||||||
for (TransactionHash transactionInfo : transactionHashes) {
|
for (TransactionHash transactionInfo : transactionHashes) {
|
||||||
BitcoinTransaction bitcoinTransaction = transactionsByHash.get(transactionInfo.txHash);
|
BitcoinyTransaction bitcoinyTransaction = transactionsByHash.get(transactionInfo.txHash);
|
||||||
if (bitcoinTransaction == null)
|
if (bitcoinyTransaction == null)
|
||||||
// Should be present in map!
|
// Should be present in map!
|
||||||
throw new BitcoinException("Cached Bitcoin transaction now missing?");
|
throw new ForeignBlockchainException("Cached Bitcoin transaction now missing?");
|
||||||
|
|
||||||
// Check outputs for our specific P2SH
|
// Check outputs for our specific P2SH
|
||||||
for (BitcoinTransaction.Output output : bitcoinTransaction.outputs) {
|
for (BitcoinyTransaction.Output output : bitcoinyTransaction.outputs) {
|
||||||
// Check amount
|
// Check amount
|
||||||
if (output.value < minimumAmount)
|
if (output.value < minimumAmount)
|
||||||
// Output amount too small (not taking fees into account)
|
// Output amount too small (not taking fees into account)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
String scriptPubKey = output.scriptPubKey;
|
String scriptPubKeyHex = output.scriptPubKey;
|
||||||
if (!scriptPubKey.equals(ourScriptPubKey))
|
if (!scriptPubKeyHex.equals(ourScriptPubKeyHex))
|
||||||
// Not funding our specific P2SH
|
// Not funding our specific P2SH
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
return transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED;
|
cachedStatus = transactionInfo.height == 0 ? Status.FUNDING_IN_PROGRESS : Status.FUNDED;
|
||||||
|
STATUS_CACHE.put(compoundKey, cachedStatus);
|
||||||
|
return cachedStatus;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Status.UNFUNDED;
|
cachedStatus = Status.UNFUNDED;
|
||||||
|
STATUS_CACHE.put(compoundKey, cachedStatus);
|
||||||
|
return cachedStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<byte[]> extractScriptSigChunks(byte[] scriptSigBytes) {
|
private static List<byte[]> extractScriptSigChunks(byte[] scriptSigBytes) {
|
@ -3,20 +3,43 @@ package org.qortal.crosschain;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class BitcoinTransaction {
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
import javax.xml.bind.annotation.XmlTransient;
|
||||||
|
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
public class BitcoinyTransaction {
|
||||||
|
|
||||||
public final String txHash;
|
public final String txHash;
|
||||||
|
|
||||||
|
@XmlTransient
|
||||||
public final int size;
|
public final int size;
|
||||||
|
|
||||||
|
@XmlTransient
|
||||||
public final int locktime;
|
public final int locktime;
|
||||||
|
|
||||||
// Not present if transaction is unconfirmed
|
// Not present if transaction is unconfirmed
|
||||||
public final Integer timestamp;
|
public final Integer timestamp;
|
||||||
|
|
||||||
public static class Input {
|
public static class Input {
|
||||||
|
@XmlTransient
|
||||||
public final String scriptSig;
|
public final String scriptSig;
|
||||||
|
|
||||||
|
@XmlTransient
|
||||||
public final int sequence;
|
public final int sequence;
|
||||||
|
|
||||||
public final String outputTxHash;
|
public final String outputTxHash;
|
||||||
|
|
||||||
public final int outputVout;
|
public final int outputVout;
|
||||||
|
|
||||||
|
// For JAXB
|
||||||
|
protected Input() {
|
||||||
|
this.scriptSig = null;
|
||||||
|
this.sequence = 0;
|
||||||
|
this.outputTxHash = null;
|
||||||
|
this.outputVout = 0;
|
||||||
|
}
|
||||||
|
|
||||||
public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) {
|
public Input(String scriptSig, int sequence, String outputTxHash, int outputVout) {
|
||||||
this.scriptSig = scriptSig;
|
this.scriptSig = scriptSig;
|
||||||
this.sequence = sequence;
|
this.sequence = sequence;
|
||||||
@ -29,15 +52,34 @@ public class BitcoinTransaction {
|
|||||||
this.outputTxHash, this.outputVout, this.sequence, this.scriptSig);
|
this.outputTxHash, this.outputVout, this.sequence, this.scriptSig);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@XmlTransient
|
||||||
public final List<Input> inputs;
|
public final List<Input> inputs;
|
||||||
|
|
||||||
public static class Output {
|
public static class Output {
|
||||||
|
@XmlTransient
|
||||||
public final String scriptPubKey;
|
public final String scriptPubKey;
|
||||||
|
|
||||||
public final long value;
|
public final long value;
|
||||||
|
|
||||||
|
public final List<String> addresses;
|
||||||
|
|
||||||
|
// For JAXB
|
||||||
|
protected Output() {
|
||||||
|
this.scriptPubKey = null;
|
||||||
|
this.value = 0;
|
||||||
|
this.addresses = null;
|
||||||
|
}
|
||||||
|
|
||||||
public Output(String scriptPubKey, long value) {
|
public Output(String scriptPubKey, long value) {
|
||||||
this.scriptPubKey = scriptPubKey;
|
this.scriptPubKey = scriptPubKey;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
this.addresses = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Output(String scriptPubKey, long value, List<String> addresses) {
|
||||||
|
this.scriptPubKey = scriptPubKey;
|
||||||
|
this.value = value;
|
||||||
|
this.addresses = addresses;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String toString() {
|
public String toString() {
|
||||||
@ -46,7 +88,20 @@ public class BitcoinTransaction {
|
|||||||
}
|
}
|
||||||
public final List<Output> outputs;
|
public final List<Output> outputs;
|
||||||
|
|
||||||
public BitcoinTransaction(String txHash, int size, int locktime, Integer timestamp,
|
public final long totalAmount;
|
||||||
|
|
||||||
|
// For JAXB
|
||||||
|
protected BitcoinyTransaction() {
|
||||||
|
this.txHash = null;
|
||||||
|
this.size = 0;
|
||||||
|
this.locktime = 0;
|
||||||
|
this.timestamp = 0;
|
||||||
|
this.inputs = null;
|
||||||
|
this.outputs = null;
|
||||||
|
this.totalAmount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BitcoinyTransaction(String txHash, int size, int locktime, Integer timestamp,
|
||||||
List<Input> inputs, List<Output> outputs) {
|
List<Input> inputs, List<Output> outputs) {
|
||||||
this.txHash = txHash;
|
this.txHash = txHash;
|
||||||
this.size = size;
|
this.size = size;
|
||||||
@ -54,6 +109,8 @@ public class BitcoinTransaction {
|
|||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
this.inputs = inputs;
|
this.inputs = inputs;
|
||||||
this.outputs = outputs;
|
this.outputs = outputs;
|
||||||
|
|
||||||
|
this.totalAmount = outputs.stream().map(output -> output.value).reduce(0L, Long::sum);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String toString() {
|
public String toString() {
|
||||||
@ -67,4 +124,23 @@ public class BitcoinTransaction {
|
|||||||
this.inputs.stream().map(Input::toString).collect(Collectors.joining(",\n\t\t")),
|
this.inputs.stream().map(Input::toString).collect(Collectors.joining(",\n\t\t")),
|
||||||
this.outputs.stream().map(Output::toString).collect(Collectors.joining(",\n\t\t")));
|
this.outputs.stream().map(Output::toString).collect(Collectors.joining(",\n\t\t")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean equals(Object other) {
|
||||||
|
if (other == this)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!(other instanceof BitcoinyTransaction))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
BitcoinyTransaction otherTransaction = (BitcoinyTransaction) other;
|
||||||
|
|
||||||
|
return this.txHash.equals(otherTransaction.txHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int hashCode() {
|
||||||
|
return this.txHash.hashCode();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -1,13 +1,17 @@
|
|||||||
package org.qortal.crosschain;
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.math.BigDecimal;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.net.SocketAddress;
|
import java.net.SocketAddress;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.Collection;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.NoSuchElementException;
|
import java.util.NoSuchElementException;
|
||||||
@ -30,33 +34,25 @@ import org.qortal.crypto.TrustlessSSLSocketFactory;
|
|||||||
import com.google.common.hash.HashCode;
|
import com.google.common.hash.HashCode;
|
||||||
import com.google.common.primitives.Bytes;
|
import com.google.common.primitives.Bytes;
|
||||||
|
|
||||||
/** ElectrumX network support for querying Bitcoin-related info like block headers, transaction outputs, etc. */
|
/** ElectrumX network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */
|
||||||
public class ElectrumX {
|
public class ElectrumX extends BitcoinyBlockchainProvider {
|
||||||
|
|
||||||
private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class);
|
private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class);
|
||||||
private static final Random RANDOM = new Random();
|
private static final Random RANDOM = new Random();
|
||||||
|
|
||||||
private static final double MIN_PROTOCOL_VERSION = 1.2;
|
private static final double MIN_PROTOCOL_VERSION = 1.2;
|
||||||
|
|
||||||
private static final int DEFAULT_TCP_PORT = 50001;
|
|
||||||
private static final int DEFAULT_SSL_PORT = 50002;
|
|
||||||
|
|
||||||
private static final int BLOCK_HEADER_LENGTH = 80;
|
private static final int BLOCK_HEADER_LENGTH = 80;
|
||||||
|
|
||||||
private static final String MAIN_GENESIS_HASH = "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f";
|
|
||||||
private static final String TEST3_GENESIS_HASH = "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943";
|
|
||||||
// We won't know REGTEST (i.e. local) genesis block hash
|
|
||||||
|
|
||||||
// "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})"
|
// "message": "daemon error: DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})"
|
||||||
private static final Pattern DAEMON_ERROR_REGEX = Pattern.compile("DaemonError\\(\\{.*'code': ?(-?[0-9]+).*\\}\\)\\z"); // Capture 'code' inside curly-brace content
|
private static final Pattern DAEMON_ERROR_REGEX = Pattern.compile("DaemonError\\(\\{.*'code': ?(-?[0-9]+).*\\}\\)\\z"); // Capture 'code' inside curly-brace content
|
||||||
|
|
||||||
// Key: Bitcoin network (e.g. "MAIN", "TEST3", "REGTEST"), value: ElectrumX instance
|
/** Error message sent by some ElectrumX servers when they don't support returning verbose transactions. */
|
||||||
private static final Map<String, ElectrumX> instances = new HashMap<>();
|
private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported";
|
||||||
|
|
||||||
private static class Server {
|
public static class Server {
|
||||||
String hostname;
|
String hostname;
|
||||||
|
|
||||||
enum ConnectionType { TCP, SSL }
|
public enum ConnectionType { TCP, SSL }
|
||||||
ConnectionType connectionType;
|
ConnectionType connectionType;
|
||||||
|
|
||||||
int port;
|
int port;
|
||||||
@ -94,108 +90,61 @@ public class ElectrumX {
|
|||||||
}
|
}
|
||||||
private Set<Server> servers = new HashSet<>();
|
private Set<Server> servers = new HashSet<>();
|
||||||
private List<Server> remainingServers = new ArrayList<>();
|
private List<Server> remainingServers = new ArrayList<>();
|
||||||
|
private Set<Server> uselessServers = Collections.synchronizedSet(new HashSet<>());
|
||||||
|
|
||||||
private String expectedGenesisHash;
|
private final String netId;
|
||||||
|
private final String expectedGenesisHash;
|
||||||
|
private final Map<Server.ConnectionType, Integer> defaultPorts = new EnumMap<>(Server.ConnectionType.class);
|
||||||
|
|
||||||
|
private final Object serverLock = new Object();
|
||||||
private Server currentServer;
|
private Server currentServer;
|
||||||
private Socket socket;
|
private Socket socket;
|
||||||
private Scanner scanner;
|
private Scanner scanner;
|
||||||
private int nextId = 1;
|
private int nextId = 1;
|
||||||
|
|
||||||
|
private static final int TX_CACHE_SIZE = 200;
|
||||||
|
@SuppressWarnings("serial")
|
||||||
|
private final Map<String, BitcoinyTransaction> transactionCache = Collections.synchronizedMap(new LinkedHashMap<>(TX_CACHE_SIZE + 1, 0.75F, true) {
|
||||||
|
// This method is called just after a new entry has been added
|
||||||
|
@Override
|
||||||
|
public boolean removeEldestEntry(Map.Entry<String, BitcoinyTransaction> eldest) {
|
||||||
|
return size() > TX_CACHE_SIZE;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
|
|
||||||
private ElectrumX(String bitcoinNetwork) {
|
public ElectrumX(String netId, String genesisHash, Collection<Server> initialServerList, Map<Server.ConnectionType, Integer> defaultPorts) {
|
||||||
switch (bitcoinNetwork) {
|
this.netId = netId;
|
||||||
case "MAIN":
|
this.expectedGenesisHash = genesisHash;
|
||||||
this.expectedGenesisHash = MAIN_GENESIS_HASH;
|
this.servers.addAll(initialServerList);
|
||||||
|
this.defaultPorts.putAll(defaultPorts);
|
||||||
this.servers.addAll(Arrays.asList(
|
|
||||||
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
|
||||||
new Server("enode.duckdns.org", Server.ConnectionType.SSL, 50002),
|
|
||||||
new Server("electrumx.ml", Server.ConnectionType.SSL, 50002),
|
|
||||||
new Server("electrum.bitkoins.nl", Server.ConnectionType.SSL, 50512),
|
|
||||||
new Server("btc.electroncash.dk", Server.ConnectionType.SSL, 60002),
|
|
||||||
new Server("electrumx.electricnewyear.net", Server.ConnectionType.SSL, 50002),
|
|
||||||
new Server("dxm.no-ip.biz", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("kirsche.emzy.de", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("2AZZARITA.hopto.org", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("xtrum.com", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrum.srvmin.network", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrumx.alexridevski.net", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("bitcoin.lukechilds.co", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrum.poiuty.com", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("horsey.cryptocowboys.net", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrum.emzy.de", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrum-server.ninja", Server.ConnectionType.TCP, 50081),
|
|
||||||
new Server("bitcoin.electrumx.multicoin.co", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("esx.geekhosters.com", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("bitcoin.grey.pw", Server.ConnectionType.TCP, 50003),
|
|
||||||
new Server("exs.ignorelist.com", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrum.coinext.com.br", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("skbxmit.coinjoined.com", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("alviss.coinjoined.com", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrum2.privateservers.network", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("electrumx.schulzemic.net", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("bitcoins.sk", Server.ConnectionType.TCP, 56001),
|
|
||||||
new Server("node.mendonca.xyz", Server.ConnectionType.TCP, 50001),
|
|
||||||
new Server("bitcoin.aranguren.org", Server.ConnectionType.TCP, 50001)));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "TEST3":
|
|
||||||
this.expectedGenesisHash = TEST3_GENESIS_HASH;
|
|
||||||
|
|
||||||
this.servers.addAll(Arrays.asList(
|
|
||||||
new Server("electrum.blockstream.info", Server.ConnectionType.TCP, 60001),
|
|
||||||
new Server("electrum.blockstream.info", Server.ConnectionType.SSL, 60002),
|
|
||||||
new Server("electrumx-test.1209k.com", Server.ConnectionType.SSL, 50002),
|
|
||||||
new Server("testnet.qtornado.com", Server.ConnectionType.TCP, 51001),
|
|
||||||
new Server("testnet.qtornado.com", Server.ConnectionType.SSL, 51002),
|
|
||||||
new Server("testnet.aranguren.org", Server.ConnectionType.TCP, 51001),
|
|
||||||
new Server("testnet.aranguren.org", Server.ConnectionType.SSL, 51002),
|
|
||||||
new Server("testnet.hsmiths.com", Server.ConnectionType.SSL, 53012)));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "REGTEST":
|
|
||||||
this.expectedGenesisHash = null;
|
|
||||||
|
|
||||||
this.servers.addAll(Arrays.asList(
|
|
||||||
new Server("localhost", Server.ConnectionType.TCP, DEFAULT_TCP_PORT),
|
|
||||||
new Server("localhost", Server.ConnectionType.SSL, DEFAULT_SSL_PORT)));
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new IllegalArgumentException(String.format("Bitcoin network '%s' unknown", bitcoinNetwork));
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGER.debug(() -> String.format("Starting ElectrumX support for %s Bitcoin network", bitcoinNetwork));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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));
|
|
||||||
|
|
||||||
return instances.get(bitcoinNetwork);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Methods for use by other classes
|
// Methods for use by other classes
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getNetId() {
|
||||||
|
return this.netId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns current blockchain height.
|
* Returns current blockchain height.
|
||||||
* <p>
|
* <p>
|
||||||
* @throws BitcoinException if error occurs
|
* @throws ForeignBlockchainException if error occurs
|
||||||
*/
|
*/
|
||||||
public int getCurrentHeight() throws BitcoinException {
|
@Override
|
||||||
|
public int getCurrentHeight() throws ForeignBlockchainException {
|
||||||
Object blockObj = this.rpc("blockchain.headers.subscribe");
|
Object blockObj = this.rpc("blockchain.headers.subscribe");
|
||||||
if (!(blockObj instanceof JSONObject))
|
if (!(blockObj instanceof JSONObject))
|
||||||
throw new BitcoinException.NetworkException("Unexpected output from ElectrumX blockchain.headers.subscribe RPC");
|
throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.headers.subscribe RPC");
|
||||||
|
|
||||||
JSONObject blockJson = (JSONObject) blockObj;
|
JSONObject blockJson = (JSONObject) blockObj;
|
||||||
|
|
||||||
Object heightObj = blockJson.get("height");
|
Object heightObj = blockJson.get("height");
|
||||||
|
|
||||||
if (!(heightObj instanceof Long))
|
if (!(heightObj instanceof Long))
|
||||||
throw new BitcoinException.NetworkException("Missing/invalid 'height' in JSON from ElectrumX blockchain.headers.subscribe RPC");
|
throw new ForeignBlockchainException.NetworkException("Missing/invalid 'height' in JSON from ElectrumX blockchain.headers.subscribe RPC");
|
||||||
|
|
||||||
return ((Long) heightObj).intValue();
|
return ((Long) heightObj).intValue();
|
||||||
}
|
}
|
||||||
@ -203,12 +152,13 @@ public class ElectrumX {
|
|||||||
/**
|
/**
|
||||||
* Returns list of raw block headers, starting from <tt>startHeight</tt> inclusive.
|
* Returns list of raw block headers, starting from <tt>startHeight</tt> inclusive.
|
||||||
* <p>
|
* <p>
|
||||||
* @throws BitcoinException if error occurs
|
* @throws ForeignBlockchainException if error occurs
|
||||||
*/
|
*/
|
||||||
public List<byte[]> getBlockHeaders(int startHeight, long count) throws BitcoinException {
|
@Override
|
||||||
|
public List<byte[]> getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException {
|
||||||
Object blockObj = this.rpc("blockchain.block.headers", startHeight, count);
|
Object blockObj = this.rpc("blockchain.block.headers", startHeight, count);
|
||||||
if (!(blockObj instanceof JSONObject))
|
if (!(blockObj instanceof JSONObject))
|
||||||
throw new BitcoinException.NetworkException("Unexpected output from ElectrumX blockchain.block.headers RPC");
|
throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.block.headers RPC");
|
||||||
|
|
||||||
JSONObject blockJson = (JSONObject) blockObj;
|
JSONObject blockJson = (JSONObject) blockObj;
|
||||||
|
|
||||||
@ -216,14 +166,14 @@ public class ElectrumX {
|
|||||||
Object hexObj = blockJson.get("hex");
|
Object hexObj = blockJson.get("hex");
|
||||||
|
|
||||||
if (!(countObj instanceof Long) || !(hexObj instanceof String))
|
if (!(countObj instanceof Long) || !(hexObj instanceof String))
|
||||||
throw new BitcoinException.NetworkException("Missing/invalid 'count' or 'hex' entries in JSON from ElectrumX blockchain.block.headers RPC");
|
throw new ForeignBlockchainException.NetworkException("Missing/invalid 'count' or 'hex' entries in JSON from ElectrumX blockchain.block.headers RPC");
|
||||||
|
|
||||||
Long returnedCount = (Long) countObj;
|
Long returnedCount = (Long) countObj;
|
||||||
String hex = (String) hexObj;
|
String hex = (String) hexObj;
|
||||||
|
|
||||||
byte[] raw = HashCode.fromString(hex).asBytes();
|
byte[] raw = HashCode.fromString(hex).asBytes();
|
||||||
if (raw.length != returnedCount * BLOCK_HEADER_LENGTH)
|
if (raw.length != returnedCount * BLOCK_HEADER_LENGTH)
|
||||||
throw new BitcoinException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC");
|
throw new ForeignBlockchainException.NetworkException("Unexpected raw header length in JSON from ElectrumX blockchain.block.headers RPC");
|
||||||
|
|
||||||
List<byte[]> rawBlockHeaders = new ArrayList<>(returnedCount.intValue());
|
List<byte[]> rawBlockHeaders = new ArrayList<>(returnedCount.intValue());
|
||||||
for (int i = 0; i < returnedCount; ++i)
|
for (int i = 0; i < returnedCount; ++i)
|
||||||
@ -236,22 +186,23 @@ public class ElectrumX {
|
|||||||
* Returns confirmed balance, based on passed payment script.
|
* Returns confirmed balance, based on passed payment script.
|
||||||
* <p>
|
* <p>
|
||||||
* @return confirmed balance, or zero if script unknown
|
* @return confirmed balance, or zero if script unknown
|
||||||
* @throws BitcoinException if there was an error
|
* @throws ForeignBlockchainException if there was an error
|
||||||
*/
|
*/
|
||||||
public long getConfirmedBalance(byte[] script) throws BitcoinException {
|
@Override
|
||||||
|
public long getConfirmedBalance(byte[] script) throws ForeignBlockchainException {
|
||||||
byte[] scriptHash = Crypto.digest(script);
|
byte[] scriptHash = Crypto.digest(script);
|
||||||
Bytes.reverse(scriptHash);
|
Bytes.reverse(scriptHash);
|
||||||
|
|
||||||
Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
|
Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString());
|
||||||
if (!(balanceObj instanceof JSONObject))
|
if (!(balanceObj instanceof JSONObject))
|
||||||
throw new BitcoinException.NetworkException("Unexpected output from ElectrumX blockchain.scripthash.get_balance RPC");
|
throw new ForeignBlockchainException.NetworkException("Unexpected output from ElectrumX blockchain.scripthash.get_balance RPC");
|
||||||
|
|
||||||
JSONObject balanceJson = (JSONObject) balanceObj;
|
JSONObject balanceJson = (JSONObject) balanceObj;
|
||||||
|
|
||||||
Object confirmedBalanceObj = balanceJson.get("confirmed");
|
Object confirmedBalanceObj = balanceJson.get("confirmed");
|
||||||
|
|
||||||
if (!(confirmedBalanceObj instanceof Long))
|
if (!(confirmedBalanceObj instanceof Long))
|
||||||
throw new BitcoinException.NetworkException("Missing confirmed balance from ElectrumX blockchain.scripthash.get_balance RPC");
|
throw new ForeignBlockchainException.NetworkException("Missing confirmed balance from ElectrumX blockchain.scripthash.get_balance RPC");
|
||||||
|
|
||||||
return (Long) balanceJson.get("confirmed");
|
return (Long) balanceJson.get("confirmed");
|
||||||
}
|
}
|
||||||
@ -260,15 +211,16 @@ public class ElectrumX {
|
|||||||
* Returns list of unspent outputs pertaining to passed payment script.
|
* Returns list of unspent outputs pertaining to passed payment script.
|
||||||
* <p>
|
* <p>
|
||||||
* @return list of unspent outputs, or empty list if script unknown
|
* @return list of unspent outputs, or empty list if script unknown
|
||||||
* @throws BitcoinException if there was an error.
|
* @throws ForeignBlockchainException if there was an error.
|
||||||
*/
|
*/
|
||||||
public List<UnspentOutput> getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws BitcoinException {
|
@Override
|
||||||
|
public List<UnspentOutput> getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException {
|
||||||
byte[] scriptHash = Crypto.digest(script);
|
byte[] scriptHash = Crypto.digest(script);
|
||||||
Bytes.reverse(scriptHash);
|
Bytes.reverse(scriptHash);
|
||||||
|
|
||||||
Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
|
Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString());
|
||||||
if (!(unspentJson instanceof JSONArray))
|
if (!(unspentJson instanceof JSONArray))
|
||||||
throw new BitcoinException("Expected array output from ElectrumX blockchain.scripthash.listunspent RPC");
|
throw new ForeignBlockchainException("Expected array output from ElectrumX blockchain.scripthash.listunspent RPC");
|
||||||
|
|
||||||
List<UnspentOutput> unspentOutputs = new ArrayList<>();
|
List<UnspentOutput> unspentOutputs = new ArrayList<>();
|
||||||
for (Object rawUnspent : (JSONArray) unspentJson) {
|
for (Object rawUnspent : (JSONArray) unspentJson) {
|
||||||
@ -292,57 +244,93 @@ public class ElectrumX {
|
|||||||
/**
|
/**
|
||||||
* Returns raw transaction for passed transaction hash.
|
* Returns raw transaction for passed transaction hash.
|
||||||
* <p>
|
* <p>
|
||||||
* @throws BitcoinException.NotFoundException if transaction not found
|
* NOTE: Do not mutate returned byte[]!
|
||||||
* @throws BitcoinException if error occurs
|
*
|
||||||
|
* @throws ForeignBlockchainException.NotFoundException if transaction not found
|
||||||
|
* @throws ForeignBlockchainException if error occurs
|
||||||
*/
|
*/
|
||||||
public byte[] getRawTransaction(byte[] txHash) throws BitcoinException {
|
@Override
|
||||||
|
public byte[] getRawTransaction(String txHash) throws ForeignBlockchainException {
|
||||||
Object rawTransactionHex;
|
Object rawTransactionHex;
|
||||||
try {
|
try {
|
||||||
rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString());
|
rawTransactionHex = this.rpc("blockchain.transaction.get", txHash, false);
|
||||||
} catch (BitcoinException.NetworkException e) {
|
} catch (ForeignBlockchainException.NetworkException e) {
|
||||||
// DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})
|
// DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})
|
||||||
if (Integer.valueOf(-5).equals(e.getDaemonErrorCode()))
|
if (Integer.valueOf(-5).equals(e.getDaemonErrorCode()))
|
||||||
throw new BitcoinException.NotFoundException(e.getMessage());
|
throw new ForeignBlockchainException.NotFoundException(e.getMessage());
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(rawTransactionHex instanceof String))
|
if (!(rawTransactionHex instanceof String))
|
||||||
throw new BitcoinException.NetworkException("Expected hex string as raw transaction from ElectrumX blockchain.transaction.get RPC");
|
throw new ForeignBlockchainException.NetworkException("Expected hex string as raw transaction from ElectrumX blockchain.transaction.get RPC");
|
||||||
|
|
||||||
return HashCode.fromString((String) rawTransactionHex).asBytes();
|
return HashCode.fromString((String) rawTransactionHex).asBytes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns raw transaction for passed transaction hash.
|
||||||
|
* <p>
|
||||||
|
* NOTE: Do not mutate returned byte[]!
|
||||||
|
*
|
||||||
|
* @throws ForeignBlockchainException.NotFoundException if transaction not found
|
||||||
|
* @throws ForeignBlockchainException if error occurs
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException {
|
||||||
|
return getRawTransaction(HashCode.fromBytes(txHash).toString());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns transaction info for passed transaction hash.
|
* Returns transaction info for passed transaction hash.
|
||||||
* <p>
|
* <p>
|
||||||
* @throws BitcoinException.NotFoundException if transaction not found
|
* @throws ForeignBlockchainException.NotFoundException if transaction not found
|
||||||
* @throws BitcoinException if error occurs
|
* @throws ForeignBlockchainException if error occurs
|
||||||
*/
|
*/
|
||||||
public BitcoinTransaction getTransaction(String txHash) throws BitcoinException {
|
@Override
|
||||||
Object transactionObj;
|
public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException {
|
||||||
try {
|
// Check cache first
|
||||||
transactionObj = this.rpc("blockchain.transaction.get", txHash, true);
|
BitcoinyTransaction transaction = transactionCache.get(txHash);
|
||||||
} catch (BitcoinException.NetworkException e) {
|
if (transaction != null)
|
||||||
// DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})
|
return transaction;
|
||||||
if (Integer.valueOf(-5).equals(e.getDaemonErrorCode()))
|
|
||||||
throw new BitcoinException.NotFoundException(e.getMessage());
|
|
||||||
|
|
||||||
throw e;
|
Object transactionObj = null;
|
||||||
}
|
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
transactionObj = this.rpc("blockchain.transaction.get", txHash, true);
|
||||||
|
} catch (ForeignBlockchainException.NetworkException e) {
|
||||||
|
// DaemonError({'code': -5, 'message': 'No such mempool or blockchain transaction. Use gettransaction for wallet transactions.'})
|
||||||
|
if (Integer.valueOf(-5).equals(e.getDaemonErrorCode()))
|
||||||
|
throw new ForeignBlockchainException.NotFoundException(e.getMessage());
|
||||||
|
|
||||||
|
// Some servers also return non-standard responses like this:
|
||||||
|
// {"error":"verbose transactions are currently unsupported","id":3,"jsonrpc":"2.0"}
|
||||||
|
// We should probably not use this server any more
|
||||||
|
if (e.getServer() != null && e.getMessage() != null && e.getMessage().contains(VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE)) {
|
||||||
|
Server uselessServer = (Server) e.getServer();
|
||||||
|
LOGGER.trace(() -> String.format("Server %s doesn't support verbose transactions - barring use of that server", uselessServer));
|
||||||
|
this.uselessServers.add(uselessServer);
|
||||||
|
this.closeServer(uselessServer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} while (transactionObj == null);
|
||||||
|
|
||||||
if (!(transactionObj instanceof JSONObject))
|
if (!(transactionObj instanceof JSONObject))
|
||||||
throw new BitcoinException.NetworkException("Expected JSONObject as response from ElectrumX blockchain.transaction.get RPC");
|
throw new ForeignBlockchainException.NetworkException("Expected JSONObject as response from ElectrumX blockchain.transaction.get RPC");
|
||||||
|
|
||||||
JSONObject transactionJson = (JSONObject) transactionObj;
|
JSONObject transactionJson = (JSONObject) transactionObj;
|
||||||
|
|
||||||
Object inputsObj = transactionJson.get("vin");
|
Object inputsObj = transactionJson.get("vin");
|
||||||
if (!(inputsObj instanceof JSONArray))
|
if (!(inputsObj instanceof JSONArray))
|
||||||
throw new BitcoinException.NetworkException("Expected JSONArray for 'vin' from ElectrumX blockchain.transaction.get RPC");
|
throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vin' from ElectrumX blockchain.transaction.get RPC");
|
||||||
|
|
||||||
Object outputsObj = transactionJson.get("vout");
|
Object outputsObj = transactionJson.get("vout");
|
||||||
if (!(outputsObj instanceof JSONArray))
|
if (!(outputsObj instanceof JSONArray))
|
||||||
throw new BitcoinException.NetworkException("Expected JSONArray for 'vout' from ElectrumX blockchain.transaction.get RPC");
|
throw new ForeignBlockchainException.NetworkException("Expected JSONArray for 'vout' from ElectrumX blockchain.transaction.get RPC");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
int size = ((Long) transactionJson.get("size")).intValue();
|
int size = ((Long) transactionJson.get("size")).intValue();
|
||||||
@ -354,7 +342,7 @@ public class ElectrumX {
|
|||||||
? ((Long) timeObj).intValue()
|
? ((Long) timeObj).intValue()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
List<BitcoinTransaction.Input> inputs = new ArrayList<>();
|
List<BitcoinyTransaction.Input> inputs = new ArrayList<>();
|
||||||
for (Object inputObj : (JSONArray) inputsObj) {
|
for (Object inputObj : (JSONArray) inputsObj) {
|
||||||
JSONObject inputJson = (JSONObject) inputObj;
|
JSONObject inputJson = (JSONObject) inputObj;
|
||||||
|
|
||||||
@ -363,40 +351,55 @@ public class ElectrumX {
|
|||||||
String outputTxHash = (String) inputJson.get("txid");
|
String outputTxHash = (String) inputJson.get("txid");
|
||||||
int outputVout = ((Long) inputJson.get("vout")).intValue();
|
int outputVout = ((Long) inputJson.get("vout")).intValue();
|
||||||
|
|
||||||
inputs.add(new BitcoinTransaction.Input(scriptSig, sequence, outputTxHash, outputVout));
|
inputs.add(new BitcoinyTransaction.Input(scriptSig, sequence, outputTxHash, outputVout));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<BitcoinTransaction.Output> outputs = new ArrayList<>();
|
List<BitcoinyTransaction.Output> outputs = new ArrayList<>();
|
||||||
for (Object outputObj : (JSONArray) outputsObj) {
|
for (Object outputObj : (JSONArray) outputsObj) {
|
||||||
JSONObject outputJson = (JSONObject) outputObj;
|
JSONObject outputJson = (JSONObject) outputObj;
|
||||||
|
|
||||||
String scriptPubKey = (String) ((JSONObject) outputJson.get("scriptPubKey")).get("hex");
|
String scriptPubKey = (String) ((JSONObject) outputJson.get("scriptPubKey")).get("hex");
|
||||||
long value = (long) (((Double) outputJson.get("value")) * 1e8);
|
long value = BigDecimal.valueOf((Double) outputJson.get("value")).setScale(8).unscaledValue().longValue();
|
||||||
|
|
||||||
outputs.add(new BitcoinTransaction.Output(scriptPubKey, value));
|
// address too, if present
|
||||||
|
List<String> addresses = null;
|
||||||
|
Object addressesObj = ((JSONObject) outputJson.get("scriptPubKey")).get("addresses");
|
||||||
|
if (addressesObj instanceof JSONArray) {
|
||||||
|
addresses = new ArrayList<>();
|
||||||
|
for (Object addressObj : (JSONArray) addressesObj)
|
||||||
|
addresses.add((String) addressObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputs.add(new BitcoinyTransaction.Output(scriptPubKey, value, addresses));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BitcoinTransaction(txHash, size, locktime, timestamp, inputs, outputs);
|
transaction = new BitcoinyTransaction(txHash, size, locktime, timestamp, inputs, outputs);
|
||||||
|
|
||||||
|
// Save into cache
|
||||||
|
transactionCache.put(txHash, transaction);
|
||||||
|
|
||||||
|
return transaction;
|
||||||
} catch (NullPointerException | ClassCastException e) {
|
} catch (NullPointerException | ClassCastException e) {
|
||||||
// Unexpected / invalid response from ElectrumX server
|
// Unexpected / invalid response from ElectrumX server
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BitcoinException.NetworkException("Unexpected JSON format from ElectrumX blockchain.transaction.get RPC");
|
throw new ForeignBlockchainException.NetworkException("Unexpected JSON format from ElectrumX blockchain.transaction.get RPC");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns list of transactions, relating to passed payment script.
|
* Returns list of transactions, relating to passed payment script.
|
||||||
* <p>
|
* <p>
|
||||||
* @return list of related transactions, or empty list if script unknown
|
* @return list of related transactions, or empty list if script unknown
|
||||||
* @throws BitcoinException if error occurs
|
* @throws ForeignBlockchainException if error occurs
|
||||||
*/
|
*/
|
||||||
public List<TransactionHash> getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws BitcoinException {
|
@Override
|
||||||
|
public List<TransactionHash> getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException {
|
||||||
byte[] scriptHash = Crypto.digest(script);
|
byte[] scriptHash = Crypto.digest(script);
|
||||||
Bytes.reverse(scriptHash);
|
Bytes.reverse(scriptHash);
|
||||||
|
|
||||||
Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
|
Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString());
|
||||||
if (!(transactionsJson instanceof JSONArray))
|
if (!(transactionsJson instanceof JSONArray))
|
||||||
throw new BitcoinException.NetworkException("Expected array output from ElectrumX blockchain.scripthash.get_history RPC");
|
throw new ForeignBlockchainException.NetworkException("Expected array output from ElectrumX blockchain.scripthash.get_history RPC");
|
||||||
|
|
||||||
List<TransactionHash> transactionHashes = new ArrayList<>();
|
List<TransactionHash> transactionHashes = new ArrayList<>();
|
||||||
|
|
||||||
@ -417,16 +420,17 @@ public class ElectrumX {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Broadcasts raw transaction to Bitcoin network.
|
* Broadcasts raw transaction to network.
|
||||||
* <p>
|
* <p>
|
||||||
* @throws BitcoinException if error occurs
|
* @throws ForeignBlockchainException if error occurs
|
||||||
*/
|
*/
|
||||||
public void broadcastTransaction(byte[] transactionBytes) throws BitcoinException {
|
@Override
|
||||||
|
public void broadcastTransaction(byte[] transactionBytes) throws ForeignBlockchainException {
|
||||||
Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString());
|
Object rawBroadcastResult = this.rpc("blockchain.transaction.broadcast", HashCode.fromBytes(transactionBytes).toString());
|
||||||
|
|
||||||
// We're expecting a simple string that is the transaction hash
|
// We're expecting a simple string that is the transaction hash
|
||||||
if (!(rawBroadcastResult instanceof String))
|
if (!(rawBroadcastResult instanceof String))
|
||||||
throw new BitcoinException.NetworkException("Unexpected response from ElectrumX blockchain.transaction.broadcast RPC");
|
throw new ForeignBlockchainException.NetworkException("Unexpected response from ElectrumX blockchain.transaction.broadcast RPC");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Class-private utility methods
|
// Class-private utility methods
|
||||||
@ -434,10 +438,10 @@ public class ElectrumX {
|
|||||||
/**
|
/**
|
||||||
* Query current server for its list of peer servers, and return those we can parse.
|
* Query current server for its list of peer servers, and return those we can parse.
|
||||||
* <p>
|
* <p>
|
||||||
* @throws BitcoinException
|
* @throws ForeignBlockchainException
|
||||||
* @throws ClassCastException to be handled by caller
|
* @throws ClassCastException to be handled by caller
|
||||||
*/
|
*/
|
||||||
private Set<Server> serverPeersSubscribe() throws BitcoinException {
|
private Set<Server> serverPeersSubscribe() throws ForeignBlockchainException {
|
||||||
Set<Server> newServers = new HashSet<>();
|
Set<Server> newServers = new HashSet<>();
|
||||||
|
|
||||||
Object peers = this.connectedRpc("server.peers.subscribe");
|
Object peers = this.connectedRpc("server.peers.subscribe");
|
||||||
@ -454,17 +458,17 @@ public class ElectrumX {
|
|||||||
for (Object rawFeature : features) {
|
for (Object rawFeature : features) {
|
||||||
String feature = (String) rawFeature;
|
String feature = (String) rawFeature;
|
||||||
Server.ConnectionType connectionType = null;
|
Server.ConnectionType connectionType = null;
|
||||||
int port = -1;
|
Integer port = null;
|
||||||
|
|
||||||
switch (feature.charAt(0)) {
|
switch (feature.charAt(0)) {
|
||||||
case 's':
|
case 's':
|
||||||
connectionType = Server.ConnectionType.SSL;
|
connectionType = Server.ConnectionType.SSL;
|
||||||
port = DEFAULT_SSL_PORT;
|
port = this.defaultPorts.get(connectionType);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 't':
|
case 't':
|
||||||
connectionType = Server.ConnectionType.TCP;
|
connectionType = Server.ConnectionType.TCP;
|
||||||
port = DEFAULT_TCP_PORT;
|
port = this.defaultPorts.get(connectionType);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
@ -472,7 +476,7 @@ public class ElectrumX {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connectionType == null)
|
if (connectionType == null || port == null)
|
||||||
// We couldn't extract any peer connection info?
|
// We couldn't extract any peer connection info?
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@ -497,32 +501,29 @@ public class ElectrumX {
|
|||||||
* Performs RPC call, with automatic reconnection to different server if needed.
|
* Performs RPC call, with automatic reconnection to different server if needed.
|
||||||
* <p>
|
* <p>
|
||||||
* @return "result" object from within JSON output
|
* @return "result" object from within JSON output
|
||||||
* @throws BitcoinException if server returns error or something goes wrong
|
* @throws ForeignBlockchainException if server returns error or something goes wrong
|
||||||
*/
|
*/
|
||||||
private synchronized Object rpc(String method, Object...params) throws BitcoinException {
|
private Object rpc(String method, Object...params) throws ForeignBlockchainException {
|
||||||
if (this.remainingServers.isEmpty())
|
synchronized (this.serverLock) {
|
||||||
this.remainingServers.addAll(this.servers);
|
if (this.remainingServers.isEmpty())
|
||||||
|
this.remainingServers.addAll(this.servers);
|
||||||
|
|
||||||
while (haveConnection()) {
|
while (haveConnection()) {
|
||||||
Object response = connectedRpc(method, params);
|
Object response = connectedRpc(method, params);
|
||||||
if (response != null)
|
if (response != null)
|
||||||
return response;
|
return response;
|
||||||
|
|
||||||
this.currentServer = null;
|
// Didn't work, try another server...
|
||||||
try {
|
this.closeServer();
|
||||||
this.socket.close();
|
|
||||||
} catch (IOException e) {
|
|
||||||
/* ignore */
|
|
||||||
}
|
}
|
||||||
this.scanner = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Failed to perform RPC - maybe lack of servers?
|
// Failed to perform RPC - maybe lack of servers?
|
||||||
throw new BitcoinException.NetworkException("Failed to perform Bitcoin RPC");
|
throw new ForeignBlockchainException.NetworkException(String.format("Failed to perform ElectrumX RPC %s", method));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true if we have, or create, a connection to an ElectrumX server. */
|
/** Returns true if we have, or create, a connection to an ElectrumX server. */
|
||||||
private boolean haveConnection() throws BitcoinException {
|
private boolean haveConnection() throws ForeignBlockchainException {
|
||||||
if (this.currentServer != null)
|
if (this.currentServer != null)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@ -566,17 +567,9 @@ public class ElectrumX {
|
|||||||
LOGGER.debug(() -> String.format("Connected to %s", server));
|
LOGGER.debug(() -> String.format("Connected to %s", server));
|
||||||
this.currentServer = server;
|
this.currentServer = server;
|
||||||
return true;
|
return true;
|
||||||
} catch (IOException | BitcoinException | ClassCastException | NullPointerException e) {
|
} catch (IOException | ForeignBlockchainException | ClassCastException | NullPointerException e) {
|
||||||
// Try another server...
|
// Didn't work, try another server...
|
||||||
if (this.socket != null && !this.socket.isClosed())
|
closeServer();
|
||||||
try {
|
|
||||||
this.socket.close();
|
|
||||||
} catch (IOException e1) {
|
|
||||||
// We did try...
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socket = null;
|
|
||||||
this.scanner = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -589,10 +582,10 @@ public class ElectrumX {
|
|||||||
* @param method
|
* @param method
|
||||||
* @param params
|
* @param params
|
||||||
* @return response Object, or null if server fails to respond
|
* @return response Object, or null if server fails to respond
|
||||||
* @throws BitcoinException if server returns error
|
* @throws ForeignBlockchainException if server returns error
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
private Object connectedRpc(String method, Object...params) throws BitcoinException {
|
private Object connectedRpc(String method, Object...params) throws ForeignBlockchainException {
|
||||||
JSONObject requestJson = new JSONObject();
|
JSONObject requestJson = new JSONObject();
|
||||||
requestJson.put("id", this.nextId++);
|
requestJson.put("id", this.nextId++);
|
||||||
requestJson.put("method", method);
|
requestJson.put("method", method);
|
||||||
@ -630,15 +623,18 @@ public class ElectrumX {
|
|||||||
|
|
||||||
Object errorObj = responseJson.get("error");
|
Object errorObj = responseJson.get("error");
|
||||||
if (errorObj != null) {
|
if (errorObj != null) {
|
||||||
|
if (errorObj instanceof String)
|
||||||
|
throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error message from ElectrumX RPC %s: %s", method, (String) errorObj), this.currentServer);
|
||||||
|
|
||||||
if (!(errorObj instanceof JSONObject))
|
if (!(errorObj instanceof JSONObject))
|
||||||
throw new BitcoinException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method));
|
throw new ForeignBlockchainException.NetworkException(String.format("Unexpected error response from ElectrumX RPC %s", method), this.currentServer);
|
||||||
|
|
||||||
JSONObject errorJson = (JSONObject) errorObj;
|
JSONObject errorJson = (JSONObject) errorObj;
|
||||||
|
|
||||||
Object messageObj = errorJson.get("message");
|
Object messageObj = errorJson.get("message");
|
||||||
|
|
||||||
if (!(messageObj instanceof String))
|
if (!(messageObj instanceof String))
|
||||||
throw new BitcoinException.NetworkException(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method));
|
throw new ForeignBlockchainException.NetworkException(String.format("Missing/invalid message in error response from ElectrumX RPC %s", method), this.currentServer);
|
||||||
|
|
||||||
String message = (String) messageObj;
|
String message = (String) messageObj;
|
||||||
|
|
||||||
@ -649,15 +645,44 @@ public class ElectrumX {
|
|||||||
if (messageMatcher.find())
|
if (messageMatcher.find())
|
||||||
try {
|
try {
|
||||||
int daemonErrorCode = Integer.parseInt(messageMatcher.group(1));
|
int daemonErrorCode = Integer.parseInt(messageMatcher.group(1));
|
||||||
throw new BitcoinException.NetworkException(daemonErrorCode, message);
|
throw new ForeignBlockchainException.NetworkException(daemonErrorCode, message, this.currentServer);
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
// We couldn't parse the error code integer? Fall-through to generic exception...
|
// We couldn't parse the error code integer? Fall-through to generic exception...
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new BitcoinException.NetworkException(message);
|
throw new ForeignBlockchainException.NetworkException(message, this.currentServer);
|
||||||
}
|
}
|
||||||
|
|
||||||
return responseJson.get("result");
|
return responseJson.get("result");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes connection to <tt>server</tt> if it is currently connected server.
|
||||||
|
* @param server
|
||||||
|
*/
|
||||||
|
private void closeServer(Server server) {
|
||||||
|
synchronized (this.serverLock) {
|
||||||
|
if (this.currentServer == null || !this.currentServer.equals(server))
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.socket != null && !this.socket.isClosed())
|
||||||
|
try {
|
||||||
|
this.socket.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
// We did try...
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket = null;
|
||||||
|
this.scanner = null;
|
||||||
|
this.currentServer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes connection to currently connected server (if any). */
|
||||||
|
private void closeServer() {
|
||||||
|
synchronized (this.serverLock) {
|
||||||
|
this.closeServer(this.currentServer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
public interface ForeignBlockchain {
|
||||||
|
|
||||||
|
public boolean isValidAddress(String address);
|
||||||
|
|
||||||
|
public boolean isValidWalletKey(String walletKey);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
@SuppressWarnings("serial")
|
||||||
|
public class ForeignBlockchainException extends Exception {
|
||||||
|
|
||||||
|
public ForeignBlockchainException() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ForeignBlockchainException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NetworkException extends ForeignBlockchainException {
|
||||||
|
private final Integer daemonErrorCode;
|
||||||
|
private final transient Object server;
|
||||||
|
|
||||||
|
public NetworkException() {
|
||||||
|
super();
|
||||||
|
this.daemonErrorCode = null;
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.daemonErrorCode = null;
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkException(int errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.daemonErrorCode = errorCode;
|
||||||
|
this.server = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkException(String message, Object server) {
|
||||||
|
super(message);
|
||||||
|
this.daemonErrorCode = null;
|
||||||
|
this.server = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public NetworkException(int errorCode, String message, Object server) {
|
||||||
|
super(message);
|
||||||
|
this.daemonErrorCode = errorCode;
|
||||||
|
this.server = server;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDaemonErrorCode() {
|
||||||
|
return this.daemonErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Object getServer() {
|
||||||
|
return this.server;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class NotFoundException extends ForeignBlockchainException {
|
||||||
|
public NotFoundException() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotFoundException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class InsufficientFundsException extends ForeignBlockchainException {
|
||||||
|
public InsufficientFundsException() {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public InsufficientFundsException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
175
src/main/java/org/qortal/crosschain/Litecoin.java
Normal file
175
src/main/java/org/qortal/crosschain/Litecoin.java
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.EnumMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Coin;
|
||||||
|
import org.bitcoinj.core.Context;
|
||||||
|
import org.bitcoinj.core.NetworkParameters;
|
||||||
|
import org.libdohj.params.LitecoinMainNetParams;
|
||||||
|
import org.libdohj.params.LitecoinRegTestParams;
|
||||||
|
import org.libdohj.params.LitecoinTestNet3Params;
|
||||||
|
import org.qortal.crosschain.ElectrumX.Server;
|
||||||
|
import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
|
||||||
|
public class Litecoin extends Bitcoiny {
|
||||||
|
|
||||||
|
public static final String CURRENCY_CODE = "LTC";
|
||||||
|
|
||||||
|
private static final Coin DEFAULT_FEE_PER_KB = Coin.valueOf(10000); // 0.0001 LTC per 1000 bytes
|
||||||
|
|
||||||
|
// Temporary values until a dynamic fee system is written.
|
||||||
|
private static final long MAINNET_FEE = 1000L;
|
||||||
|
private static final long NON_MAINNET_FEE = 1000L; // enough for TESTNET3 and should be OK for REGTEST
|
||||||
|
|
||||||
|
private static final Map<ElectrumX.Server.ConnectionType, Integer> DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class);
|
||||||
|
static {
|
||||||
|
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
|
||||||
|
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum LitecoinNet {
|
||||||
|
MAIN {
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return LitecoinMainNetParams.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<ElectrumX.Server> getServers() {
|
||||||
|
return Arrays.asList(
|
||||||
|
// Servers chosen on NO BASIS WHATSOEVER from various sources!
|
||||||
|
new Server("electrum-ltc.someguy123.net", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("backup.electrum-ltc.org", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("backup.electrum-ltc.org", Server.ConnectionType.SSL, 443),
|
||||||
|
new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("ltc.rentonisk.com", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("ltc.rentonisk.com", Server.ConnectionType.SSL, 50002),
|
||||||
|
new Server("electrum-ltc.petrkr.net", Server.ConnectionType.SSL, 60002),
|
||||||
|
new Server("ltc.litepay.ch", Server.ConnectionType.SSL, 50022));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getGenesisHash() {
|
||||||
|
return "12a765e31ffd4059bada1e25190f6e98c99d9714d334efa41a195a7e7e04bfe2";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) {
|
||||||
|
// TODO: This will need to be replaced with something better in the near future!
|
||||||
|
return MAINNET_FEE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TEST3 {
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return LitecoinTestNet3Params.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<ElectrumX.Server> getServers() {
|
||||||
|
return Arrays.asList(
|
||||||
|
new Server("electrum-ltc.bysh.me", Server.ConnectionType.TCP, 51001),
|
||||||
|
new Server("electrum-ltc.bysh.me", Server.ConnectionType.SSL, 51002),
|
||||||
|
new Server("electrum.ltc.xurious.com", Server.ConnectionType.TCP, 51001),
|
||||||
|
new Server("electrum.ltc.xurious.com", Server.ConnectionType.SSL, 51002));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getGenesisHash() {
|
||||||
|
return "4966625a4b2851d9fdee139e56211a0d88575f59ed816ff5e6a63deb4e3e29a0";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) {
|
||||||
|
return NON_MAINNET_FEE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
REGTEST {
|
||||||
|
@Override
|
||||||
|
public NetworkParameters getParams() {
|
||||||
|
return LitecoinRegTestParams.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<ElectrumX.Server> getServers() {
|
||||||
|
return Arrays.asList(
|
||||||
|
new Server("localhost", Server.ConnectionType.TCP, 50001),
|
||||||
|
new Server("localhost", Server.ConnectionType.SSL, 50002));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getGenesisHash() {
|
||||||
|
// This is unique to each regtest instance
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) {
|
||||||
|
return NON_MAINNET_FEE;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public abstract NetworkParameters getParams();
|
||||||
|
public abstract Collection<ElectrumX.Server> getServers();
|
||||||
|
public abstract String getGenesisHash();
|
||||||
|
public abstract long getP2shFee(Long timestamp) throws ForeignBlockchainException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Litecoin instance;
|
||||||
|
|
||||||
|
private final LitecoinNet litecoinNet;
|
||||||
|
|
||||||
|
// Constructors and instance
|
||||||
|
|
||||||
|
private Litecoin(LitecoinNet litecoinNet, BitcoinyBlockchainProvider blockchain, Context bitcoinjContext, String currencyCode) {
|
||||||
|
super(blockchain, bitcoinjContext, currencyCode);
|
||||||
|
this.litecoinNet = litecoinNet;
|
||||||
|
|
||||||
|
LOGGER.info(() -> String.format("Starting Litecoin support using %s", this.litecoinNet.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized Litecoin getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
LitecoinNet litecoinNet = Settings.getInstance().getLitecoinNet();
|
||||||
|
|
||||||
|
BitcoinyBlockchainProvider electrumX = new ElectrumX("Litecoin-" + litecoinNet.name(), litecoinNet.getGenesisHash(), litecoinNet.getServers(), DEFAULT_ELECTRUMX_PORTS);
|
||||||
|
Context bitcoinjContext = new Context(litecoinNet.getParams());
|
||||||
|
|
||||||
|
instance = new Litecoin(litecoinNet, electrumX, bitcoinjContext, CURRENCY_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters & setters
|
||||||
|
|
||||||
|
public static synchronized void resetForTesting() {
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual useful methods for use by other classes
|
||||||
|
|
||||||
|
/** Default Litecoin fee is lower than Bitcoin: only 10sats/byte. */
|
||||||
|
@Override
|
||||||
|
public Coin getFeePerKb() {
|
||||||
|
return DEFAULT_FEE_PER_KB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns estimated LTC fee, in sats per 1000bytes, optionally for historic timestamp.
|
||||||
|
*
|
||||||
|
* @param timestamp optional milliseconds since epoch, or null for 'now'
|
||||||
|
* @return sats per 1000bytes, or throws ForeignBlockchainException if something went wrong
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public long getP2shFee(Long timestamp) throws ForeignBlockchainException {
|
||||||
|
return this.litecoinNet.getP2shFee(timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
853
src/main/java/org/qortal/crosschain/LitecoinACCTv1.java
Normal file
853
src/main/java/org/qortal/crosschain/LitecoinACCTv1.java
Normal file
@ -0,0 +1,853 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import org.ciyam.at.FunctionCode;
|
||||||
|
import org.ciyam.at.MachineState;
|
||||||
|
import org.ciyam.at.OpCode;
|
||||||
|
import org.ciyam.at.Timestamp;
|
||||||
|
import org.qortal.account.Account;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
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.crosschain.CrossChainTradeData;
|
||||||
|
import org.qortal.data.transaction.MessageTransactionData;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-chain trade AT
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* <ul>
|
||||||
|
* <li>Bob generates Litecoin & Qortal 'trade' keys
|
||||||
|
* <ul>
|
||||||
|
* <li>private key required to sign P2SH redeem tx</li>
|
||||||
|
* <li>private key could be used to create 'secret' (e.g. double-SHA256)</li>
|
||||||
|
* <li>encrypted private key could be stored in Qortal AT for access by Bob from any node</li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* <li>Bob deploys Qortal AT
|
||||||
|
* <ul>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* <li>Alice finds Qortal AT and wants to trade
|
||||||
|
* <ul>
|
||||||
|
* <li>Alice generates Litecoin & Qortal 'trade' keys</li>
|
||||||
|
* <li>Alice funds Litecoin P2SH-A</li>
|
||||||
|
* <li>Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing:
|
||||||
|
* <ul>
|
||||||
|
* <li>hash-of-secret-A</li>
|
||||||
|
* <li>her 'trade' Litecoin PKH</li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* <li>Bob receives "offer" MESSAGE
|
||||||
|
* <ul>
|
||||||
|
* <li>Checks Alice's P2SH-A</li>
|
||||||
|
* <li>Sends 'trade' MESSAGE to Qortal AT from his trade address, containing:
|
||||||
|
* <ul>
|
||||||
|
* <li>Alice's trade Qortal address</li>
|
||||||
|
* <li>Alice's trade Litecoin PKH</li>
|
||||||
|
* <li>hash-of-secret-A</li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* <li>Alice checks Qortal AT to confirm it's locked to her
|
||||||
|
* <ul>
|
||||||
|
* <li>Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing:
|
||||||
|
* <ul>
|
||||||
|
* <li>secret-A</li>
|
||||||
|
* <li>Qortal receiving address of her chosing</li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* <li>AT's QORT funds are sent to Qortal receiving address</li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* <li>Bob checks AT, extracts secret-A
|
||||||
|
* <ul>
|
||||||
|
* <li>Bob redeems P2SH-A using his Litecoin trade key and secret-A</li>
|
||||||
|
* <li>P2SH-A LTC funds end up at Litecoin address determined by redeem transaction output(s)</li>
|
||||||
|
* </ul>
|
||||||
|
* </li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public class LitecoinACCTv1 implements ACCT {
|
||||||
|
|
||||||
|
public static final String NAME = LitecoinACCTv1.class.getSimpleName();
|
||||||
|
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0fb15ad9ad1867dfbcafa51155481aa15d984ff9506f2b428eca4e2a2feac2b3").asBytes(); // SHA256 of AT code bytes
|
||||||
|
|
||||||
|
public static final int SECRET_LENGTH = 32;
|
||||||
|
|
||||||
|
/** <b>Value</b> 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 = 61;
|
||||||
|
/** <b>Byte</b> 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[] partnerLitecoinPKH;
|
||||||
|
public byte[] hashOfSecretA;
|
||||||
|
public long lockTimeA;
|
||||||
|
}
|
||||||
|
public static final int OFFER_MESSAGE_LENGTH = 20 /*partnerLitecoinPKH*/ + 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 Litecoin PKH (padded from 20 to 24)*/
|
||||||
|
+ 8 /*AT trade timeout (minutes)*/
|
||||||
|
+ 24 /*hash of secret-A (padded from 20 to 24)*/
|
||||||
|
+ 8 /*lockTimeA*/;
|
||||||
|
public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret-A*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/;
|
||||||
|
public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
|
||||||
|
|
||||||
|
private static LitecoinACCTv1 instance;
|
||||||
|
|
||||||
|
private LitecoinACCTv1() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized LitecoinACCTv1 getInstance() {
|
||||||
|
if (instance == null)
|
||||||
|
instance = new LitecoinACCTv1();
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getCodeBytesHash() {
|
||||||
|
return CODE_BYTES_HASH;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getModeByteOffset() {
|
||||||
|
return MODE_BYTE_OFFSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ForeignBlockchain getBlockchain() {
|
||||||
|
return Litecoin.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns Qortal AT creation bytes for cross-chain trading AT.
|
||||||
|
* <p>
|
||||||
|
* <tt>tradeTimeout</tt> (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
|
||||||
|
* @param litecoinPublicKeyHash 20-byte HASH160 of creator's trade Litecoin public key
|
||||||
|
* @param qortAmount how much QORT to pay trade partner if they send correct 32-byte secrets to AT
|
||||||
|
* @param litecoinAmount how much LTC the AT creator is expecting to trade
|
||||||
|
* @param tradeTimeout suggested timeout for entire trade
|
||||||
|
*/
|
||||||
|
public static byte[] buildQortalAT(String creatorTradeAddress, byte[] litecoinPublicKeyHash, long qortAmount, long litecoinAmount, int tradeTimeout) {
|
||||||
|
if (litecoinPublicKeyHash.length != 20)
|
||||||
|
throw new IllegalArgumentException("Litecoin public key hash should be 20 bytes");
|
||||||
|
|
||||||
|
// Labels for data segment addresses
|
||||||
|
int addrCounter = 0;
|
||||||
|
|
||||||
|
// Constants (with corresponding dataByteBuffer.put*() calls below)
|
||||||
|
|
||||||
|
final int addrCreatorTradeAddress1 = addrCounter++;
|
||||||
|
final int addrCreatorTradeAddress2 = addrCounter++;
|
||||||
|
final int addrCreatorTradeAddress3 = addrCounter++;
|
||||||
|
final int addrCreatorTradeAddress4 = addrCounter++;
|
||||||
|
|
||||||
|
final int addrLitecoinPublicKeyHash = addrCounter;
|
||||||
|
addrCounter += 4;
|
||||||
|
|
||||||
|
final int addrQortAmount = addrCounter++;
|
||||||
|
final int addrLitecoinAmount = addrCounter++;
|
||||||
|
final int addrTradeTimeout = addrCounter++;
|
||||||
|
|
||||||
|
final int addrMessageTxnType = addrCounter++;
|
||||||
|
final int addrExpectedTradeMessageLength = addrCounter++;
|
||||||
|
final int addrExpectedRedeemMessageLength = addrCounter++;
|
||||||
|
|
||||||
|
final int addrCreatorAddressPointer = addrCounter++;
|
||||||
|
final int addrQortalPartnerAddressPointer = addrCounter++;
|
||||||
|
final int addrMessageSenderPointer = addrCounter++;
|
||||||
|
|
||||||
|
final int addrTradeMessagePartnerLitecoinPKHOffset = addrCounter++;
|
||||||
|
final int addrPartnerLitecoinPKHPointer = addrCounter++;
|
||||||
|
final int addrTradeMessageHashOfSecretAOffset = addrCounter++;
|
||||||
|
final int addrHashOfSecretAPointer = addrCounter++;
|
||||||
|
|
||||||
|
final int addrRedeemMessageReceivingAddressOffset = addrCounter++;
|
||||||
|
|
||||||
|
final int addrMessageDataPointer = addrCounter++;
|
||||||
|
final int addrMessageDataLength = addrCounter++;
|
||||||
|
|
||||||
|
final int addrPartnerReceivingAddressPointer = addrCounter++;
|
||||||
|
|
||||||
|
final int addrEndOfConstants = addrCounter;
|
||||||
|
|
||||||
|
// Variables
|
||||||
|
|
||||||
|
final int addrCreatorAddress1 = addrCounter++;
|
||||||
|
final int addrCreatorAddress2 = addrCounter++;
|
||||||
|
final int addrCreatorAddress3 = addrCounter++;
|
||||||
|
final int addrCreatorAddress4 = addrCounter++;
|
||||||
|
|
||||||
|
final int addrQortalPartnerAddress1 = addrCounter++;
|
||||||
|
final int addrQortalPartnerAddress2 = addrCounter++;
|
||||||
|
final int addrQortalPartnerAddress3 = addrCounter++;
|
||||||
|
final int addrQortalPartnerAddress4 = addrCounter++;
|
||||||
|
|
||||||
|
final int addrLockTimeA = addrCounter++;
|
||||||
|
final int addrRefundTimeout = addrCounter++;
|
||||||
|
final int addrRefundTimestamp = addrCounter++;
|
||||||
|
final int addrLastTxnTimestamp = addrCounter++;
|
||||||
|
final int addrBlockTimestamp = addrCounter++;
|
||||||
|
final int addrTxnType = addrCounter++;
|
||||||
|
final int addrResult = addrCounter++;
|
||||||
|
|
||||||
|
final int addrMessageSender1 = addrCounter++;
|
||||||
|
final int addrMessageSender2 = addrCounter++;
|
||||||
|
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 addrPartnerLitecoinPKH = addrCounter;
|
||||||
|
addrCounter += 4;
|
||||||
|
|
||||||
|
final int addrPartnerReceivingAddress = addrCounter;
|
||||||
|
addrCounter += 4;
|
||||||
|
|
||||||
|
final int addrMode = addrCounter++;
|
||||||
|
assert addrMode == MODE_VALUE_OFFSET : String.format("addrMode %d does not match MODE_VALUE_OFFSET %d", addrMode, MODE_VALUE_OFFSET);
|
||||||
|
|
||||||
|
// Data segment
|
||||||
|
ByteBuffer dataByteBuffer = ByteBuffer.allocate(addrCounter * MachineState.VALUE_SIZE);
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
|
||||||
|
// Litecoin public key hash
|
||||||
|
assert dataByteBuffer.position() == addrLitecoinPublicKeyHash * MachineState.VALUE_SIZE : "addrLitecoinPublicKeyHash incorrect";
|
||||||
|
dataByteBuffer.put(Bytes.ensureCapacity(litecoinPublicKeyHash, 32, 0));
|
||||||
|
|
||||||
|
// Redeem Qort amount
|
||||||
|
assert dataByteBuffer.position() == addrQortAmount * MachineState.VALUE_SIZE : "addrQortAmount incorrect";
|
||||||
|
dataByteBuffer.putLong(qortAmount);
|
||||||
|
|
||||||
|
// Expected Litecoin amount
|
||||||
|
assert dataByteBuffer.position() == addrLitecoinAmount * MachineState.VALUE_SIZE : "addrLitecoinAmount incorrect";
|
||||||
|
dataByteBuffer.putLong(litecoinAmount);
|
||||||
|
|
||||||
|
// 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() == addrMessageTxnType * MachineState.VALUE_SIZE : "addrMessageTxnType incorrect";
|
||||||
|
dataByteBuffer.putLong(API.ATTransactionType.MESSAGE.value);
|
||||||
|
|
||||||
|
// Expected length of 'trade' MESSAGE data from AT creator
|
||||||
|
assert dataByteBuffer.position() == addrExpectedTradeMessageLength * MachineState.VALUE_SIZE : "addrExpectedTradeMessageLength incorrect";
|
||||||
|
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";
|
||||||
|
dataByteBuffer.putLong(addrCreatorAddress1);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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 'trade' MESSAGE data payload for extracting partner's Litecoin PKH
|
||||||
|
assert dataByteBuffer.position() == addrTradeMessagePartnerLitecoinPKHOffset * MachineState.VALUE_SIZE : "addrTradeMessagePartnerLitecoinPKHOffset incorrect";
|
||||||
|
dataByteBuffer.putLong(32L);
|
||||||
|
|
||||||
|
// Index into data segment of partner's Litecoin PKH, used by GET_B_IND
|
||||||
|
assert dataByteBuffer.position() == addrPartnerLitecoinPKHPointer * MachineState.VALUE_SIZE : "addrPartnerLitecoinPKHPointer incorrect";
|
||||||
|
dataByteBuffer.putLong(addrPartnerLitecoinPKH);
|
||||||
|
|
||||||
|
// 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 to hash of secret A, used by GET_B_IND
|
||||||
|
assert dataByteBuffer.position() == addrHashOfSecretAPointer * MachineState.VALUE_SIZE : "addrHashOfSecretAPointer incorrect";
|
||||||
|
dataByteBuffer.putLong(addrHashOfSecretA);
|
||||||
|
|
||||||
|
// Offset into 'redeem' MESSAGE data payload for extracting Qortal receiving address
|
||||||
|
assert dataByteBuffer.position() == addrRedeemMessageReceivingAddressOffset * MachineState.VALUE_SIZE : "addrRedeemMessageReceivingAddressOffset incorrect";
|
||||||
|
dataByteBuffer.putLong(32L);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
Integer labelRefund = null;
|
||||||
|
|
||||||
|
Integer labelTradeTxnLoop = null;
|
||||||
|
Integer labelCheckTradeTxn = null;
|
||||||
|
Integer labelCheckCancelTxn = null;
|
||||||
|
Integer labelNotTradeNorCancelTxn = null;
|
||||||
|
Integer labelCheckNonRefundTradeTxn = null;
|
||||||
|
Integer labelTradeTxnExtract = null;
|
||||||
|
Integer labelRedeemTxnLoop = null;
|
||||||
|
Integer labelCheckRedeemTxn = null;
|
||||||
|
Integer labelCheckRedeemTxnSender = null;
|
||||||
|
Integer labelPayout = null;
|
||||||
|
|
||||||
|
ByteBuffer codeByteBuffer = ByteBuffer.allocate(768);
|
||||||
|
|
||||||
|
// 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, 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));
|
||||||
|
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 creator's trade address containing trade partner details, or AT owner's address to cancel offer */
|
||||||
|
|
||||||
|
/* Transaction processing loop */
|
||||||
|
labelTradeTxnLoop = codeByteBuffer.position();
|
||||||
|
|
||||||
|
// 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, labelCheckTradeTxn)));
|
||||||
|
// Stop and wait for next block
|
||||||
|
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||||
|
|
||||||
|
/* Check transaction */
|
||||||
|
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, 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(addrTxnType, addrMessageTxnType, calcOffset(codeByteBuffer, labelTradeTxnLoop)));
|
||||||
|
|
||||||
|
/* 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 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));
|
||||||
|
|
||||||
|
/* Checking message sender for possible cancel message */
|
||||||
|
labelCheckCancelTxn = codeByteBuffer.position();
|
||||||
|
|
||||||
|
// 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, AcctMode.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();
|
||||||
|
|
||||||
|
// 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)));
|
||||||
|
// Message length didn't match - go back to finding another 'trade' MESSAGE transaction
|
||||||
|
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelTradeTxnLoop == null ? 0 : labelTradeTxnLoop));
|
||||||
|
|
||||||
|
/* Extracting info from 'trade' MESSAGE transaction */
|
||||||
|
labelTradeTxnExtract = 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));
|
||||||
|
|
||||||
|
// Extract trade partner's Litecoin public key hash (PKH) from message into B
|
||||||
|
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(QortalFunctionCode.PUT_PARTIAL_MESSAGE_FROM_TX_IN_A_INTO_B.value, addrTradeMessagePartnerLitecoinPKHOffset));
|
||||||
|
// Store partner's Litecoin PKH (we only really use values from B1-B3)
|
||||||
|
codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.GET_B_IND, addrPartnerLitecoinPKHPointer));
|
||||||
|
// Extract AT trade timeout (minutes) (from B4)
|
||||||
|
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrRefundTimeout));
|
||||||
|
|
||||||
|
// Grab next 32 bytes
|
||||||
|
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));
|
||||||
|
// Extract lockTime-A (from B4)
|
||||||
|
codeByteBuffer.put(OpCode.EXT_FUN_RET.compile(FunctionCode.GET_B4, addrLockTimeA));
|
||||||
|
|
||||||
|
// 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, AcctMode.TRADING.value));
|
||||||
|
|
||||||
|
// Set restart position to after this opcode
|
||||||
|
codeByteBuffer.put(OpCode.SET_PCS.compile());
|
||||||
|
|
||||||
|
/* 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, 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 */
|
||||||
|
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, 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, labelCheckRedeemTxn)));
|
||||||
|
// Stop and wait for next block
|
||||||
|
codeByteBuffer.put(OpCode.STP_IMD.compile());
|
||||||
|
|
||||||
|
/* Check transaction */
|
||||||
|
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, 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(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));
|
||||||
|
// 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 */
|
||||||
|
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, 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 */
|
||||||
|
|
||||||
|
// 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 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.BNZ_DAT.compile(addrResult, calcOffset(codeByteBuffer, labelPayout)));
|
||||||
|
codeByteBuffer.put(OpCode.JMP_ADR.compile(labelRedeemTxnLoop == null ? 0 : labelRedeemTxnLoop));
|
||||||
|
|
||||||
|
/* Success! Pay arranged amount to receiving address */
|
||||||
|
labelPayout = codeByteBuffer.position();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.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();
|
||||||
|
|
||||||
|
// Set refunded mode
|
||||||
|
codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.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 LTC-QORT ACCT?", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codeByteBuffer.flip();
|
||||||
|
|
||||||
|
byte[] codeBytes = new byte[codeByteBuffer.limit()];
|
||||||
|
codeByteBuffer.get(codeBytes);
|
||||||
|
|
||||||
|
assert Arrays.equals(Crypto.digest(codeBytes), LitecoinACCTv1.CODE_BYTES_HASH)
|
||||||
|
: String.format("BTCACCT.CODE_BYTES_HASH mismatch: expected %s, actual %s", HashCode.fromBytes(CODE_BYTES_HASH), HashCode.fromBytes(Crypto.digest(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
|
||||||
|
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
|
||||||
|
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, ATStateData atStateData) throws DataException {
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atStateData.getATAddress());
|
||||||
|
return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns CrossChainTradeData with useful info extracted from AT.
|
||||||
|
*/
|
||||||
|
public CrossChainTradeData populateTradeData(Repository repository, byte[] creatorPublicKey, long creationTimestamp, ATStateData atStateData) throws DataException {
|
||||||
|
byte[] addressBytes = new byte[25]; // for general use
|
||||||
|
String atAddress = atStateData.getATAddress();
|
||||||
|
|
||||||
|
CrossChainTradeData tradeData = new CrossChainTradeData();
|
||||||
|
|
||||||
|
tradeData.foreignBlockchain = SupportedBlockchain.LITECOIN.name();
|
||||||
|
tradeData.acctName = NAME;
|
||||||
|
|
||||||
|
tradeData.qortalAtAddress = atAddress;
|
||||||
|
tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey);
|
||||||
|
tradeData.creationTimestamp = creationTimestamp;
|
||||||
|
|
||||||
|
Account atAccount = new Account(repository, atAddress);
|
||||||
|
tradeData.qortBalance = atAccount.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
byte[] stateData = atStateData.getStateData();
|
||||||
|
ByteBuffer dataByteBuffer = ByteBuffer.wrap(stateData);
|
||||||
|
dataByteBuffer.position(MachineState.HEADER_LENGTH);
|
||||||
|
|
||||||
|
/* Constants */
|
||||||
|
|
||||||
|
// Skip creator's trade address
|
||||||
|
dataByteBuffer.get(addressBytes);
|
||||||
|
tradeData.qortalCreatorTradeAddress = Base58.encode(addressBytes);
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||||
|
|
||||||
|
// Creator's Litecoin/foreign public key hash
|
||||||
|
tradeData.creatorForeignPKH = new byte[20];
|
||||||
|
dataByteBuffer.get(tradeData.creatorForeignPKH);
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes
|
||||||
|
|
||||||
|
// We don't use secret-B
|
||||||
|
tradeData.hashOfSecretB = null;
|
||||||
|
|
||||||
|
// Redeem payout
|
||||||
|
tradeData.qortAmount = dataByteBuffer.getLong();
|
||||||
|
|
||||||
|
// Expected LTC amount
|
||||||
|
tradeData.expectedForeignAmount = dataByteBuffer.getLong();
|
||||||
|
|
||||||
|
// Trade timeout
|
||||||
|
tradeData.tradeTimeout = (int) dataByteBuffer.getLong();
|
||||||
|
|
||||||
|
// Skip MESSAGE transaction type
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip expected 'trade' message length
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip expected 'redeem' message length
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip pointer to creator's address
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip pointer to partner's Qortal trade address
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip pointer to message sender
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip 'trade' message data offset for partner's Litecoin PKH
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip pointer to partner's Litecoin PKH
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// 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 'redeem' message data offset for partner's Qortal receiving address
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// Skip pointer to message data
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 8 * 4);
|
||||||
|
|
||||||
|
// Partner's trade address (if present)
|
||||||
|
dataByteBuffer.get(addressBytes);
|
||||||
|
String qortalRecipient = Base58.encode(addressBytes);
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length);
|
||||||
|
|
||||||
|
// Potential lockTimeA (if in trade mode)
|
||||||
|
int lockTimeA = (int) dataByteBuffer.getLong();
|
||||||
|
|
||||||
|
// AT refund timeout (probably only useful for debugging)
|
||||||
|
int refundTimeout = (int) dataByteBuffer.getLong();
|
||||||
|
|
||||||
|
// Trade-mode refund timestamp (AT 'timestamp' converted to Qortal block height)
|
||||||
|
long tradeRefundTimestamp = dataByteBuffer.getLong();
|
||||||
|
|
||||||
|
// Skip 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 hash160 of secret A
|
||||||
|
byte[] hashOfSecretA = new byte[20];
|
||||||
|
dataByteBuffer.get(hashOfSecretA);
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - hashOfSecretA.length); // skip to 32 bytes
|
||||||
|
|
||||||
|
// Potential partner's Litecoin PKH
|
||||||
|
byte[] partnerLitecoinPKH = new byte[20];
|
||||||
|
dataByteBuffer.get(partnerLitecoinPKH);
|
||||||
|
dataByteBuffer.position(dataByteBuffer.position() + 32 - partnerLitecoinPKH.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();
|
||||||
|
AcctMode mode = AcctMode.valueOf((int) (modeValue & 0xffL));
|
||||||
|
|
||||||
|
/* End of variables */
|
||||||
|
|
||||||
|
if (mode != null && mode != AcctMode.OFFERING) {
|
||||||
|
tradeData.mode = mode;
|
||||||
|
tradeData.refundTimeout = refundTimeout;
|
||||||
|
tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight;
|
||||||
|
tradeData.qortalPartnerAddress = qortalRecipient;
|
||||||
|
tradeData.hashOfSecretA = hashOfSecretA;
|
||||||
|
tradeData.partnerForeignPKH = partnerLitecoinPKH;
|
||||||
|
tradeData.lockTimeA = lockTimeA;
|
||||||
|
|
||||||
|
if (mode == AcctMode.REDEEMED)
|
||||||
|
tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress);
|
||||||
|
} else {
|
||||||
|
tradeData.mode = AcctMode.OFFERING;
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeData.duplicateDeprecated();
|
||||||
|
|
||||||
|
return tradeData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 'offer' MESSAGE payload for trade partner to send to AT creator's trade address. */
|
||||||
|
public static byte[] buildOfferMessage(byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA) {
|
||||||
|
byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
|
||||||
|
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. */
|
||||||
|
public static OfferMessageData extractOfferMessageData(byte[] messageData) {
|
||||||
|
if (messageData == null || messageData.length != OFFER_MESSAGE_LENGTH)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
OfferMessageData offerMessageData = new OfferMessageData();
|
||||||
|
offerMessageData.partnerLitecoinPKH = Arrays.copyOfRange(messageData, 0, 20);
|
||||||
|
offerMessageData.hashOfSecretA = Arrays.copyOfRange(messageData, 20, 40);
|
||||||
|
offerMessageData.lockTimeA = BitTwiddling.longFromBEBytes(messageData, 40);
|
||||||
|
|
||||||
|
return offerMessageData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 'trade' MESSAGE payload for AT creator to send to AT. */
|
||||||
|
public static byte[] buildTradeMessage(String partnerQortalTradeAddress, byte[] partnerBitcoinPKH, byte[] hashOfSecretA, int lockTimeA, int refundTimeout) {
|
||||||
|
byte[] data = new byte[TRADE_MESSAGE_LENGTH];
|
||||||
|
byte[] partnerQortalAddressBytes = Base58.decode(partnerQortalTradeAddress);
|
||||||
|
byte[] lockTimeABytes = BitTwiddling.toBEByteArray((long) lockTimeA);
|
||||||
|
byte[] refundTimeoutBytes = BitTwiddling.toBEByteArray((long) refundTimeout);
|
||||||
|
|
||||||
|
System.arraycopy(partnerQortalAddressBytes, 0, data, 0, partnerQortalAddressBytes.length);
|
||||||
|
System.arraycopy(partnerBitcoinPKH, 0, data, 32, partnerBitcoinPKH.length);
|
||||||
|
System.arraycopy(refundTimeoutBytes, 0, data, 56, refundTimeoutBytes.length);
|
||||||
|
System.arraycopy(hashOfSecretA, 0, data, 64, hashOfSecretA.length);
|
||||||
|
System.arraycopy(lockTimeABytes, 0, data, 88, lockTimeABytes.length);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 'cancel' MESSAGE payload for AT creator to cancel trade AT. */
|
||||||
|
@Override
|
||||||
|
public byte[] buildCancelMessage(String creatorQortalAddress) {
|
||||||
|
byte[] data = new byte[CANCEL_MESSAGE_LENGTH];
|
||||||
|
byte[] creatorQortalAddressBytes = Base58.decode(creatorQortalAddress);
|
||||||
|
|
||||||
|
System.arraycopy(creatorQortalAddressBytes, 0, data, 0, creatorQortalAddressBytes.length);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns 'redeem' MESSAGE payload for trade partner to send to AT. */
|
||||||
|
public static byte[] buildRedeemMessage(byte[] secretA, String qortalReceivingAddress) {
|
||||||
|
byte[] data = new byte[REDEEM_MESSAGE_LENGTH];
|
||||||
|
byte[] qortalReceivingAddressBytes = Base58.decode(qortalReceivingAddress);
|
||||||
|
|
||||||
|
System.arraycopy(secretA, 0, data, 0, secretA.length);
|
||||||
|
System.arraycopy(qortalReceivingAddressBytes, 0, data, 32, qortalReceivingAddressBytes.length);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns refund timeout (minutes) based on trade partner's 'offer' MESSAGE timestamp and P2SH-A locktime. */
|
||||||
|
public static int calcRefundTimeout(long offerMessageTimestamp, int lockTimeA) {
|
||||||
|
// refund should be triggered halfway between offerMessageTimestamp and lockTimeA
|
||||||
|
return (int) ((lockTimeA - (offerMessageTimestamp / 1000L)) / 2L / 60L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] findSecretA(Repository repository, CrossChainTradeData crossChainTradeData) throws DataException {
|
||||||
|
String atAddress = crossChainTradeData.qortalAtAddress;
|
||||||
|
String redeemerAddress = crossChainTradeData.qortalPartnerAddress;
|
||||||
|
|
||||||
|
// We don't have partner's public key so we check every message to AT
|
||||||
|
List<MessageTransactionData> messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, 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 != REDEEM_MESSAGE_LENGTH)
|
||||||
|
// Wrong payload length
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Check sender
|
||||||
|
if (!Crypto.toAddress(messageTransactionData.getSenderPublicKey()).equals(redeemerAddress))
|
||||||
|
// Wrong sender;
|
||||||
|
continue;
|
||||||
|
|
||||||
|
// Extract secretA
|
||||||
|
byte[] secretA = new byte[32];
|
||||||
|
System.arraycopy(messageData, 0, secretA, 0, secretA.length);
|
||||||
|
|
||||||
|
byte[] hashOfSecretA = Crypto.hash160(secretA);
|
||||||
|
if (!Arrays.equals(hashOfSecretA, crossChainTradeData.hashOfSecretA))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
return secretA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
113
src/main/java/org/qortal/crosschain/SupportedBlockchain.java
Normal file
113
src/main/java/org/qortal/crosschain/SupportedBlockchain.java
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
package org.qortal.crosschain;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.qortal.utils.ByteArray;
|
||||||
|
import org.qortal.utils.Triple;
|
||||||
|
|
||||||
|
public enum SupportedBlockchain {
|
||||||
|
|
||||||
|
BITCOIN(Arrays.asList(
|
||||||
|
Triple.valueOf(BitcoinACCTv1.NAME, BitcoinACCTv1.CODE_BYTES_HASH, BitcoinACCTv1::getInstance)
|
||||||
|
// Could add improved BitcoinACCTv2 here in the future
|
||||||
|
)) {
|
||||||
|
@Override
|
||||||
|
public ForeignBlockchain getInstance() {
|
||||||
|
return Bitcoin.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ACCT getLatestAcct() {
|
||||||
|
return BitcoinACCTv1.getInstance();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
LITECOIN(Arrays.asList(
|
||||||
|
Triple.valueOf(LitecoinACCTv1.NAME, LitecoinACCTv1.CODE_BYTES_HASH, LitecoinACCTv1::getInstance)
|
||||||
|
)) {
|
||||||
|
@Override
|
||||||
|
public ForeignBlockchain getInstance() {
|
||||||
|
return Litecoin.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ACCT getLatestAcct() {
|
||||||
|
return LitecoinACCTv1.getInstance();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private static final Map<ByteArray, Supplier<ACCT>> supportedAcctsByCodeHash = Arrays.stream(SupportedBlockchain.values())
|
||||||
|
.map(supportedBlockchain -> supportedBlockchain.supportedAccts)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.collect(Collectors.toUnmodifiableMap(triple -> new ByteArray(triple.getB()), Triple::getC));
|
||||||
|
|
||||||
|
private static final Map<String, Supplier<ACCT>> supportedAcctsByName = Arrays.stream(SupportedBlockchain.values())
|
||||||
|
.map(supportedBlockchain -> supportedBlockchain.supportedAccts)
|
||||||
|
.flatMap(List::stream)
|
||||||
|
.collect(Collectors.toUnmodifiableMap(Triple::getA, Triple::getC));
|
||||||
|
|
||||||
|
private static final Map<String, SupportedBlockchain> blockchainsByName = Arrays.stream(SupportedBlockchain.values())
|
||||||
|
.collect(Collectors.toUnmodifiableMap(Enum::name, blockchain -> blockchain));
|
||||||
|
|
||||||
|
private final List<Triple<String, byte[], Supplier<ACCT>>> supportedAccts;
|
||||||
|
|
||||||
|
SupportedBlockchain(List<Triple<String, byte[], Supplier<ACCT>>> supportedAccts) {
|
||||||
|
this.supportedAccts = supportedAccts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract ForeignBlockchain getInstance();
|
||||||
|
public abstract ACCT getLatestAcct();
|
||||||
|
|
||||||
|
public static Map<ByteArray, Supplier<ACCT>> getAcctMap() {
|
||||||
|
return supportedAcctsByCodeHash;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SupportedBlockchain fromString(String name) {
|
||||||
|
return blockchainsByName.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<ByteArray, Supplier<ACCT>> getFilteredAcctMap(SupportedBlockchain blockchain) {
|
||||||
|
if (blockchain == null)
|
||||||
|
return getAcctMap();
|
||||||
|
|
||||||
|
return blockchain.supportedAccts.stream()
|
||||||
|
.collect(Collectors.toUnmodifiableMap(triple -> new ByteArray(triple.getB()), Triple::getC));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Map<ByteArray, Supplier<ACCT>> getFilteredAcctMap(String specificBlockchain) {
|
||||||
|
if (specificBlockchain == null)
|
||||||
|
return getAcctMap();
|
||||||
|
|
||||||
|
SupportedBlockchain blockchain = blockchainsByName.get(specificBlockchain);
|
||||||
|
if (blockchain == null)
|
||||||
|
return Collections.emptyMap();
|
||||||
|
|
||||||
|
return getFilteredAcctMap(blockchain);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ACCT getAcctByCodeHash(byte[] codeHash) {
|
||||||
|
ByteArray wrappedCodeHash = new ByteArray(codeHash);
|
||||||
|
|
||||||
|
Supplier<ACCT> acctInstanceSupplier = supportedAcctsByCodeHash.get(wrappedCodeHash);
|
||||||
|
|
||||||
|
if (acctInstanceSupplier == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return acctInstanceSupplier.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ACCT getAcctByName(String acctName) {
|
||||||
|
Supplier<ACCT> acctInstanceSupplier = supportedAcctsByName.get(acctName);
|
||||||
|
|
||||||
|
if (acctInstanceSupplier == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return acctInstanceSupplier.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -4,7 +4,7 @@ import javax.xml.bind.annotation.XmlAccessType;
|
|||||||
import javax.xml.bind.annotation.XmlAccessorType;
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
|
||||||
|
|
||||||
import org.qortal.crosschain.BTCACCT;
|
import org.qortal.crosschain.AcctMode;
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
@ -20,12 +20,16 @@ public class CrossChainTradeData {
|
|||||||
@Schema(description = "AT creator's Qortal address")
|
@Schema(description = "AT creator's Qortal address")
|
||||||
public String qortalCreator;
|
public String qortalCreator;
|
||||||
|
|
||||||
@Schema(description = "AT creator's Qortal trade address")
|
@Schema(description = "AT creator's ephemeral trading key-pair represented as Qortal address")
|
||||||
public String qortalCreatorTradeAddress;
|
public String qortalCreatorTradeAddress;
|
||||||
|
|
||||||
@Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)")
|
@Deprecated
|
||||||
|
@Schema(description = "DEPRECATED: use creatorForeignPKH instead")
|
||||||
public byte[] creatorBitcoinPKH;
|
public byte[] creatorBitcoinPKH;
|
||||||
|
|
||||||
|
@Schema(description = "AT creator's foreign blockchain trade public-key-hash (PKH)")
|
||||||
|
public byte[] creatorForeignPKH;
|
||||||
|
|
||||||
@Schema(description = "Timestamp when AT was created (milliseconds since epoch)")
|
@Schema(description = "Timestamp when AT was created (milliseconds since epoch)")
|
||||||
public long creationTimestamp;
|
public long creationTimestamp;
|
||||||
|
|
||||||
@ -58,28 +62,48 @@ public class CrossChainTradeData {
|
|||||||
@Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)")
|
@Schema(description = "Actual Qortal block height when AT will automatically refund to AT creator (after trade begins)")
|
||||||
public Integer tradeRefundHeight;
|
public Integer tradeRefundHeight;
|
||||||
|
|
||||||
@Schema(description = "Amount, in BTC, that AT creator expects Bitcoin P2SH to pay out (excluding miner fees)")
|
@Deprecated
|
||||||
|
@Schema(description = "DEPRECATED: use expectedForeignAmount instread")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
public long expectedBitcoin;
|
public long expectedBitcoin;
|
||||||
|
|
||||||
public BTCACCT.Mode mode;
|
@Schema(description = "Amount, in foreign blockchain currency, that AT creator expects trade partner to pay out (excluding miner fees)")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
public long expectedForeignAmount;
|
||||||
|
|
||||||
@Schema(description = "Suggested Bitcoin P2SH-A nLockTime based on trade timeout")
|
@Schema(description = "Current AT execution mode")
|
||||||
|
public AcctMode mode;
|
||||||
|
|
||||||
|
@Schema(description = "Suggested P2SH-A nLockTime based on trade timeout")
|
||||||
public Integer lockTimeA;
|
public Integer lockTimeA;
|
||||||
|
|
||||||
@Schema(description = "Suggested Bitcoin P2SH-B nLockTime based on trade timeout")
|
@Schema(description = "Suggested P2SH-B nLockTime based on trade timeout")
|
||||||
public Integer lockTimeB;
|
public Integer lockTimeB;
|
||||||
|
|
||||||
@Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)")
|
@Deprecated
|
||||||
|
@Schema(description = "DEPRECATED: use partnerForeignPKH instead")
|
||||||
public byte[] partnerBitcoinPKH;
|
public byte[] partnerBitcoinPKH;
|
||||||
|
|
||||||
|
@Schema(description = "Trade partner's foreign blockchain public-key-hash (PKH)")
|
||||||
|
public byte[] partnerForeignPKH;
|
||||||
|
|
||||||
@Schema(description = "Trade partner's Qortal receiving address")
|
@Schema(description = "Trade partner's Qortal receiving address")
|
||||||
public String qortalPartnerReceivingAddress;
|
public String qortalPartnerReceivingAddress;
|
||||||
|
|
||||||
|
public String foreignBlockchain;
|
||||||
|
|
||||||
|
public String acctName;
|
||||||
|
|
||||||
// Constructors
|
// Constructors
|
||||||
|
|
||||||
// Necessary for JAXB
|
// Necessary for JAXB
|
||||||
public CrossChainTradeData() {
|
public CrossChainTradeData() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void duplicateDeprecated() {
|
||||||
|
this.creatorBitcoinPKH = this.creatorForeignPKH;
|
||||||
|
this.expectedBitcoin = this.expectedForeignAmount;
|
||||||
|
this.partnerBitcoinPKH = this.partnerForeignPKH;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,5 @@
|
|||||||
package org.qortal.data.crosschain;
|
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.XmlAccessType;
|
||||||
import javax.xml.bind.annotation.XmlAccessorType;
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
import javax.xml.bind.annotation.XmlTransient;
|
import javax.xml.bind.annotation.XmlTransient;
|
||||||
@ -18,22 +13,13 @@ public class TradeBotData {
|
|||||||
|
|
||||||
private byte[] tradePrivateKey;
|
private byte[] tradePrivateKey;
|
||||||
|
|
||||||
public enum State {
|
private String acctName;
|
||||||
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),
|
private String tradeState;
|
||||||
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;
|
// Internal use - not shown via API
|
||||||
private static final Map<Integer, State> map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
|
@XmlTransient
|
||||||
|
@Schema(hidden = true)
|
||||||
State(int value) {
|
private int tradeStateValue;
|
||||||
this.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static State valueOf(int value) {
|
|
||||||
return map.get(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private State tradeState;
|
|
||||||
|
|
||||||
private String creatorAddress;
|
private String creatorAddress;
|
||||||
private String atAddress;
|
private String atAddress;
|
||||||
@ -50,19 +36,25 @@ public class TradeBotData {
|
|||||||
private byte[] secret;
|
private byte[] secret;
|
||||||
private byte[] hashOfSecret;
|
private byte[] hashOfSecret;
|
||||||
|
|
||||||
|
private String foreignBlockchain;
|
||||||
private byte[] tradeForeignPublicKey;
|
private byte[] tradeForeignPublicKey;
|
||||||
private byte[] tradeForeignPublicKeyHash;
|
private byte[] tradeForeignPublicKeyHash;
|
||||||
|
|
||||||
|
@Deprecated
|
||||||
|
@Schema(description = "DEPRECATED: use foreignAmount instead", type = "number")
|
||||||
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
private long bitcoinAmount;
|
private long bitcoinAmount;
|
||||||
|
|
||||||
|
@Schema(description = "amount in foreign blockchain currency", type = "number")
|
||||||
|
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
|
||||||
|
private long foreignAmount;
|
||||||
|
|
||||||
// Never expose this via API
|
// Never expose this via API
|
||||||
@XmlTransient
|
@XmlTransient
|
||||||
@Schema(hidden = true)
|
@Schema(hidden = true)
|
||||||
private String xprv58;
|
private String foreignKey;
|
||||||
|
|
||||||
private byte[] lastTransactionSignature;
|
private byte[] lastTransactionSignature;
|
||||||
|
|
||||||
private Integer lockTimeA;
|
private Integer lockTimeA;
|
||||||
|
|
||||||
// Could be Bitcoin or Qortal...
|
// Could be Bitcoin or Qortal...
|
||||||
@ -72,14 +64,18 @@ public class TradeBotData {
|
|||||||
/* JAXB */
|
/* JAXB */
|
||||||
}
|
}
|
||||||
|
|
||||||
public TradeBotData(byte[] tradePrivateKey, State tradeState, String creatorAddress, String atAddress,
|
public TradeBotData(byte[] tradePrivateKey, String acctName, String tradeState, int tradeStateValue,
|
||||||
|
String creatorAddress, String atAddress,
|
||||||
long timestamp, long qortAmount,
|
long timestamp, long qortAmount,
|
||||||
byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress,
|
byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress,
|
||||||
byte[] secret, byte[] hashOfSecret,
|
byte[] secret, byte[] hashOfSecret,
|
||||||
byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash,
|
String foreignBlockchain, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash,
|
||||||
long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) {
|
long foreignAmount, String foreignKey,
|
||||||
|
byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) {
|
||||||
this.tradePrivateKey = tradePrivateKey;
|
this.tradePrivateKey = tradePrivateKey;
|
||||||
|
this.acctName = acctName;
|
||||||
this.tradeState = tradeState;
|
this.tradeState = tradeState;
|
||||||
|
this.tradeStateValue = tradeStateValue;
|
||||||
this.creatorAddress = creatorAddress;
|
this.creatorAddress = creatorAddress;
|
||||||
this.atAddress = atAddress;
|
this.atAddress = atAddress;
|
||||||
this.timestamp = timestamp;
|
this.timestamp = timestamp;
|
||||||
@ -89,10 +85,13 @@ public class TradeBotData {
|
|||||||
this.tradeNativeAddress = tradeNativeAddress;
|
this.tradeNativeAddress = tradeNativeAddress;
|
||||||
this.secret = secret;
|
this.secret = secret;
|
||||||
this.hashOfSecret = hashOfSecret;
|
this.hashOfSecret = hashOfSecret;
|
||||||
|
this.foreignBlockchain = foreignBlockchain;
|
||||||
this.tradeForeignPublicKey = tradeForeignPublicKey;
|
this.tradeForeignPublicKey = tradeForeignPublicKey;
|
||||||
this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash;
|
this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash;
|
||||||
this.bitcoinAmount = bitcoinAmount;
|
// deprecated copy
|
||||||
this.xprv58 = xprv58;
|
this.bitcoinAmount = foreignAmount;
|
||||||
|
this.foreignAmount = foreignAmount;
|
||||||
|
this.foreignKey = foreignKey;
|
||||||
this.lastTransactionSignature = lastTransactionSignature;
|
this.lastTransactionSignature = lastTransactionSignature;
|
||||||
this.lockTimeA = lockTimeA;
|
this.lockTimeA = lockTimeA;
|
||||||
this.receivingAccountInfo = receivingAccountInfo;
|
this.receivingAccountInfo = receivingAccountInfo;
|
||||||
@ -102,14 +101,26 @@ public class TradeBotData {
|
|||||||
return this.tradePrivateKey;
|
return this.tradePrivateKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public State getState() {
|
public String getAcctName() {
|
||||||
|
return this.acctName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getState() {
|
||||||
return this.tradeState;
|
return this.tradeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setState(State state) {
|
public void setState(String state) {
|
||||||
this.tradeState = state;
|
this.tradeState = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getStateValue() {
|
||||||
|
return this.tradeStateValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStateValue(int stateValue) {
|
||||||
|
this.tradeStateValue = stateValue;
|
||||||
|
}
|
||||||
|
|
||||||
public String getCreatorAddress() {
|
public String getCreatorAddress() {
|
||||||
return this.creatorAddress;
|
return this.creatorAddress;
|
||||||
}
|
}
|
||||||
@ -154,6 +165,10 @@ public class TradeBotData {
|
|||||||
return this.hashOfSecret;
|
return this.hashOfSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getForeignBlockchain() {
|
||||||
|
return this.foreignBlockchain;
|
||||||
|
}
|
||||||
|
|
||||||
public byte[] getTradeForeignPublicKey() {
|
public byte[] getTradeForeignPublicKey() {
|
||||||
return this.tradeForeignPublicKey;
|
return this.tradeForeignPublicKey;
|
||||||
}
|
}
|
||||||
@ -162,12 +177,12 @@ public class TradeBotData {
|
|||||||
return this.tradeForeignPublicKeyHash;
|
return this.tradeForeignPublicKeyHash;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getBitcoinAmount() {
|
public long getForeignAmount() {
|
||||||
return this.bitcoinAmount;
|
return this.foreignAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getXprv58() {
|
public String getForeignKey() {
|
||||||
return this.xprv58;
|
return this.foreignKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
public byte[] getLastTransactionSignature() {
|
public byte[] getLastTransactionSignature() {
|
||||||
@ -192,7 +207,7 @@ public class TradeBotData {
|
|||||||
|
|
||||||
// Mostly for debugging
|
// Mostly for debugging
|
||||||
public String toString() {
|
public String toString() {
|
||||||
return String.format("%s: %s", this.atAddress, this.tradeState.name());
|
return String.format("%s: %s (%d)", this.atAddress, this.tradeState, this.tradeStateValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,73 @@
|
|||||||
|
package org.qortal.data.transaction;
|
||||||
|
|
||||||
|
import javax.xml.bind.Unmarshaller;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessType;
|
||||||
|
import javax.xml.bind.annotation.XmlAccessorType;
|
||||||
|
|
||||||
|
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||||
|
import org.qortal.transaction.Transaction.TransactionType;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
|
||||||
|
|
||||||
|
// All properties to be converted to JSON via JAXB
|
||||||
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
@Schema(allOf = { TransactionData.class })
|
||||||
|
public class PresenceTransactionData extends TransactionData {
|
||||||
|
|
||||||
|
// Properties
|
||||||
|
@Schema(description = "sender's public key", example = "2tiMr5LTpaWCgbRvkPK8TFd7k63DyHJMMFFsz9uBf1ZP")
|
||||||
|
private byte[] senderPublicKey;
|
||||||
|
|
||||||
|
@Schema(accessMode = AccessMode.READ_ONLY)
|
||||||
|
private int nonce;
|
||||||
|
|
||||||
|
private PresenceType presenceType;
|
||||||
|
|
||||||
|
@Schema(description = "timestamp signature", example = "2yGEbwRFyhPZZckKA")
|
||||||
|
private byte[] timestampSignature;
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
// For JAXB
|
||||||
|
protected PresenceTransactionData() {
|
||||||
|
super(TransactionType.PRESENCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void afterUnmarshal(Unmarshaller u, Object parent) {
|
||||||
|
this.creatorPublicKey = this.senderPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceTransactionData(BaseTransactionData baseTransactionData,
|
||||||
|
int nonce, PresenceType presenceType, byte[] timestampSignature) {
|
||||||
|
super(TransactionType.PRESENCE, baseTransactionData);
|
||||||
|
|
||||||
|
this.senderPublicKey = baseTransactionData.creatorPublicKey;
|
||||||
|
this.nonce = nonce;
|
||||||
|
this.presenceType = presenceType;
|
||||||
|
this.timestampSignature = timestampSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters/Setters
|
||||||
|
|
||||||
|
public byte[] getSenderPublicKey() {
|
||||||
|
return this.senderPublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNonce() {
|
||||||
|
return this.nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNonce(int nonce) {
|
||||||
|
this.nonce = nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
public PresenceType getPresenceType() {
|
||||||
|
return this.presenceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] getTimestampSignature() {
|
||||||
|
return this.timestampSignature;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -40,7 +40,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode;
|
|||||||
GroupApprovalTransactionData.class, SetGroupTransactionData.class,
|
GroupApprovalTransactionData.class, SetGroupTransactionData.class,
|
||||||
UpdateAssetTransactionData.class,
|
UpdateAssetTransactionData.class,
|
||||||
AccountFlagsTransactionData.class, RewardShareTransactionData.class,
|
AccountFlagsTransactionData.class, RewardShareTransactionData.class,
|
||||||
AccountLevelTransactionData.class, ChatTransactionData.class
|
AccountLevelTransactionData.class, ChatTransactionData.class, PresenceTransactionData.class
|
||||||
})
|
})
|
||||||
//All properties to be converted to JSON via JAXB
|
//All properties to be converted to JSON via JAXB
|
||||||
@XmlAccessorType(XmlAccessType.FIELD)
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
|
@ -3,9 +3,14 @@ package org.qortal.event;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
public enum EventBus {
|
public enum EventBus {
|
||||||
INSTANCE;
|
INSTANCE;
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger(EventBus.class);
|
||||||
|
|
||||||
private static final List<Listener> LISTENERS = new ArrayList<>();
|
private static final List<Listener> LISTENERS = new ArrayList<>();
|
||||||
|
|
||||||
public void addListener(Listener newListener) {
|
public void addListener(Listener newListener) {
|
||||||
@ -22,18 +27,25 @@ public enum EventBus {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* <b>WARNING:</b> before calling this method,
|
* <b>WARNING:</b> before calling this method,
|
||||||
* make sure repository holds no locks, e.g. by calling
|
* make sure current thread's repository session
|
||||||
|
* holds no locks, e.g. by calling
|
||||||
|
* <tt>repository.saveChanges()</tt> or
|
||||||
* <tt>repository.discardChanges()</tt>.
|
* <tt>repository.discardChanges()</tt>.
|
||||||
* <p>
|
* <p>
|
||||||
* This is because event listeners might open a new
|
* This is because event listeners might open a new
|
||||||
* repository session which will deadlock HSQLDB
|
* repository session which will deadlock HSQLDB
|
||||||
* if it tries to CHECKPOINT.
|
* if it tries to CHECKPOINT.
|
||||||
* <p>
|
* <p>
|
||||||
* The HSQLDB deadlock occurs because the caller's
|
* The HSQLDB deadlock path is:
|
||||||
* repository session blocks the CHECKPOINT until
|
* <ul>
|
||||||
* their transaction is closed, yet event listeners
|
* <li>write-log <tt>blockchain.log</tt> has grown past CHECKPOINT threshold (50MB)</li>
|
||||||
* new sessions are blocked until CHECKPOINT is
|
* <li>alternatively, another thread has explicitly requested CHECKPOINT</li>
|
||||||
* completed, hence deadlock.
|
* <li>HSQLDB won't begin CHECKPOINT until all pending (SQL) transactions are committed or rolled back</li>
|
||||||
|
* <li>Same thread calls <tt>EventBus.INSTANCE.notify()</tt> <i>before</i> (SQL) transaction closed</li>
|
||||||
|
* <li>EventBus listener (same thread) requests a new repository session via <tt>RepositoryManager.getRepository()</tt></li>
|
||||||
|
* <li>New repository sessions are blocked pending completion of CHECKPOINT</li>
|
||||||
|
* <li>Caller is blocked so never has a chance to close (SQL) transaction - hence deadlock</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
public void notify(Event event) {
|
public void notify(Event event) {
|
||||||
List<Listener> clonedListeners;
|
List<Listener> clonedListeners;
|
||||||
@ -43,6 +55,11 @@ public enum EventBus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (Listener listener : clonedListeners)
|
for (Listener listener : clonedListeners)
|
||||||
listener.listen(event);
|
try {
|
||||||
|
listener.listen(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// We don't want one listener to break other listeners, or caller
|
||||||
|
LOGGER.warn(() -> String.format("Caught %s from a listener processing %s", e.getClass().getSimpleName(), event.getClass().getSimpleName()), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
package org.qortal.repository;
|
package org.qortal.repository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import org.qortal.data.at.ATData;
|
import org.qortal.data.at.ATData;
|
||||||
import org.qortal.data.at.ATStateData;
|
import org.qortal.data.at.ATStateData;
|
||||||
|
import org.qortal.utils.ByteArray;
|
||||||
|
|
||||||
public interface ATRepository {
|
public interface ATRepository {
|
||||||
|
|
||||||
@ -24,6 +26,9 @@ public interface ATRepository {
|
|||||||
/** Returns list of ATs with matching code hash, optionally executable only. */
|
/** Returns list of ATs with matching code hash, optionally executable only. */
|
||||||
public List<ATData> getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException;
|
public List<ATData> getATsByFunctionality(byte[] codeHash, Boolean isExecutable, Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||||
|
|
||||||
|
/** Returns list of all ATs matching one of passed code hashes, optionally executable only. */
|
||||||
|
public List<ATData> getAllATsByFunctionality(Set<ByteArray> codeHashes, Boolean isExecutable) throws DataException;
|
||||||
|
|
||||||
/** Returns creation block height given AT's address or null if not found */
|
/** Returns creation block height given AT's address or null if not found */
|
||||||
public Integer getATCreationBlockHeight(String atAddress) throws DataException;
|
public Integer getATCreationBlockHeight(String atAddress) throws DataException;
|
||||||
|
|
||||||
@ -75,6 +80,26 @@ public interface ATRepository {
|
|||||||
Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight,
|
Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight,
|
||||||
Integer limit, Integer offset, Boolean reverse) throws DataException;
|
Integer limit, Integer offset, Boolean reverse) throws DataException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns final ATStateData for ATs matching codeHash (required)
|
||||||
|
* and specific data segment value (optional), returning at least
|
||||||
|
* <tt>minimumCount</tt> entries over a span of at least
|
||||||
|
* <tt>minimumPeriod</tt> ms, given enough entries in repository.
|
||||||
|
* <p>
|
||||||
|
* If searching for specific data segment value, both <tt>dataByteOffset</tt>
|
||||||
|
* and <tt>expectedValue</tt> need to be non-null.
|
||||||
|
* <p>
|
||||||
|
* Note that <tt>dataByteOffset</tt> starts from 0 and will typically be
|
||||||
|
* a multiple of <tt>MachineState.VALUE_SIZE</tt>, which is usually 8:
|
||||||
|
* width of a long.
|
||||||
|
* <p>
|
||||||
|
* Although <tt>expectedValue</tt>, if provided, is natively an unsigned long,
|
||||||
|
* the data segment comparison is done via unsigned hex string.
|
||||||
|
*/
|
||||||
|
public List<ATStateData> getMatchingFinalATStatesQuorum(byte[] codeHash, Boolean isFinished,
|
||||||
|
Integer dataByteOffset, Long expectedValue,
|
||||||
|
int minimumCount, long minimumPeriod) throws DataException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all ATStateData for a given block height.
|
* Returns all ATStateData for a given block height.
|
||||||
* <p>
|
* <p>
|
||||||
|
@ -8,6 +8,9 @@ public interface CrossChainRepository {
|
|||||||
|
|
||||||
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException;
|
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException;
|
||||||
|
|
||||||
|
/** Returns true if there is an existing trade-bot entry relating to given AT address, excluding trade-bot entries with given states. */
|
||||||
|
public boolean existsTradeWithAtExcludingStates(String atAddress, List<String> excludeStates) throws DataException;
|
||||||
|
|
||||||
public List<TradeBotData> getAllTradeBotData() throws DataException;
|
public List<TradeBotData> getAllTradeBotData() throws DataException;
|
||||||
|
|
||||||
public void save(TradeBotData tradeBotData) throws DataException;
|
public void save(TradeBotData tradeBotData) throws DataException;
|
||||||
|
@ -239,6 +239,18 @@ public interface TransactionRepository {
|
|||||||
return getUnconfirmedTransactions(null, null, null);
|
return getUnconfirmedTransactions(null, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list of unconfirmed transactions with specified type and/or creator.
|
||||||
|
* <p>
|
||||||
|
* At least one of <tt>txType</tt> or <tt>creatorPublicKey</tt> must be non-null.
|
||||||
|
*
|
||||||
|
* @param txType optional
|
||||||
|
* @param creatorPublicKey optional
|
||||||
|
* @return list of transactions, or empty if none.
|
||||||
|
* @throws DataException
|
||||||
|
*/
|
||||||
|
public List<TransactionData> getUnconfirmedTransactions(TransactionType txType, byte[] creatorPublicKey) throws DataException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove transaction from unconfirmed transactions pile.
|
* Remove transaction from unconfirmed transactions pile.
|
||||||
*
|
*
|
||||||
|
@ -4,6 +4,7 @@ import java.sql.ResultSet;
|
|||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
@ -11,6 +12,7 @@ import org.qortal.data.at.ATData;
|
|||||||
import org.qortal.data.at.ATStateData;
|
import org.qortal.data.at.ATStateData;
|
||||||
import org.qortal.repository.ATRepository;
|
import org.qortal.repository.ATRepository;
|
||||||
import org.qortal.repository.DataException;
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.utils.ByteArray;
|
||||||
|
|
||||||
import com.google.common.primitives.Longs;
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
@ -201,6 +203,80 @@ public class HSQLDBATRepository implements ATRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ATData> getAllATsByFunctionality(Set<ByteArray> codeHashes, Boolean isExecutable) throws DataException {
|
||||||
|
StringBuilder sql = new StringBuilder(512);
|
||||||
|
List<Object> bindParams = new ArrayList<>();
|
||||||
|
|
||||||
|
sql.append("SELECT AT_address, creator, created_when, version, asset_id, code_bytes, ")
|
||||||
|
.append("is_sleeping, sleep_until_height, is_finished, had_fatal_error, ")
|
||||||
|
.append("is_frozen, frozen_balance, code_hash ")
|
||||||
|
.append("FROM ");
|
||||||
|
|
||||||
|
// (VALUES (?), (?), ...) AS ATCodeHashes (code_hash)
|
||||||
|
sql.append("(VALUES ");
|
||||||
|
|
||||||
|
boolean isFirst = true;
|
||||||
|
for (ByteArray codeHash : codeHashes) {
|
||||||
|
if (!isFirst)
|
||||||
|
sql.append(", ");
|
||||||
|
else
|
||||||
|
isFirst = false;
|
||||||
|
|
||||||
|
sql.append("(CAST(? AS VARBINARY(256)))");
|
||||||
|
bindParams.add(codeHash.value);
|
||||||
|
}
|
||||||
|
sql.append(") AS ATCodeHashes (code_hash) ");
|
||||||
|
|
||||||
|
sql.append("JOIN ATs ON ATs.code_hash = ATCodeHashes.code_hash ");
|
||||||
|
|
||||||
|
if (isExecutable != null) {
|
||||||
|
sql.append("AND is_finished != ? ");
|
||||||
|
bindParams.add(isExecutable);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ATData> matchingATs = new ArrayList<>();
|
||||||
|
|
||||||
|
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||||
|
if (resultSet == null)
|
||||||
|
return matchingATs;
|
||||||
|
|
||||||
|
do {
|
||||||
|
String atAddress = resultSet.getString(1);
|
||||||
|
byte[] creatorPublicKey = resultSet.getBytes(2);
|
||||||
|
long created = resultSet.getLong(3);
|
||||||
|
int version = resultSet.getInt(4);
|
||||||
|
long assetId = resultSet.getLong(5);
|
||||||
|
byte[] codeBytes = resultSet.getBytes(6); // Actually BLOB
|
||||||
|
boolean isSleeping = resultSet.getBoolean(7);
|
||||||
|
|
||||||
|
Integer sleepUntilHeight = resultSet.getInt(8);
|
||||||
|
if (sleepUntilHeight == 0 && resultSet.wasNull())
|
||||||
|
sleepUntilHeight = null;
|
||||||
|
|
||||||
|
boolean isFinished = resultSet.getBoolean(9);
|
||||||
|
|
||||||
|
boolean hadFatalError = resultSet.getBoolean(10);
|
||||||
|
boolean isFrozen = resultSet.getBoolean(11);
|
||||||
|
|
||||||
|
Long frozenBalance = resultSet.getLong(12);
|
||||||
|
if (frozenBalance == 0 && resultSet.wasNull())
|
||||||
|
frozenBalance = null;
|
||||||
|
|
||||||
|
byte[] codeHash = resultSet.getBytes(13);
|
||||||
|
|
||||||
|
ATData atData = new ATData(atAddress, creatorPublicKey, created, version, assetId, codeBytes, codeHash,
|
||||||
|
isSleeping, sleepUntilHeight, isFinished, hadFatalError, isFrozen, frozenBalance);
|
||||||
|
|
||||||
|
matchingATs.add(atData);
|
||||||
|
} while (resultSet.next());
|
||||||
|
|
||||||
|
return matchingATs;
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new DataException("Unable to fetch matching ATs from repository", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Integer getATCreationBlockHeight(String atAddress) throws DataException {
|
public Integer getATCreationBlockHeight(String atAddress) throws DataException {
|
||||||
String sql = "SELECT height "
|
String sql = "SELECT height "
|
||||||
@ -375,6 +451,92 @@ public class HSQLDBATRepository implements ATRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<ATStateData> getMatchingFinalATStatesQuorum(byte[] codeHash, Boolean isFinished,
|
||||||
|
Integer dataByteOffset, Long expectedValue,
|
||||||
|
int minimumCount, long minimumPeriod) throws DataException {
|
||||||
|
// We need most recent entry first so we can use its timestamp to slice further results
|
||||||
|
List<ATStateData> mostRecentStates = this.getMatchingFinalATStates(codeHash, isFinished,
|
||||||
|
dataByteOffset, expectedValue, null,
|
||||||
|
1, 0, true);
|
||||||
|
|
||||||
|
if (mostRecentStates == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (mostRecentStates.isEmpty())
|
||||||
|
return mostRecentStates;
|
||||||
|
|
||||||
|
ATStateData mostRecentState = mostRecentStates.get(0);
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(1024);
|
||||||
|
List<Object> bindParams = new ArrayList<>();
|
||||||
|
|
||||||
|
sql.append("SELECT AT_address, height, state_data, state_hash, fees, is_initial "
|
||||||
|
+ "FROM ATs "
|
||||||
|
+ "CROSS JOIN LATERAL("
|
||||||
|
+ "SELECT height, state_data, state_hash, fees, is_initial "
|
||||||
|
+ "FROM ATStates "
|
||||||
|
+ "JOIN ATStatesData USING (AT_address, height) "
|
||||||
|
+ "WHERE ATStates.AT_address = ATs.AT_address ");
|
||||||
|
|
||||||
|
// Order by AT_address and height to use compound primary key as index
|
||||||
|
// Both must be the same direction (DESC) also
|
||||||
|
sql.append("ORDER BY ATStates.AT_address DESC, ATStates.height DESC "
|
||||||
|
+ "LIMIT 1 "
|
||||||
|
+ ") AS FinalATStates "
|
||||||
|
+ "WHERE code_hash = ? ");
|
||||||
|
bindParams.add(codeHash);
|
||||||
|
|
||||||
|
if (isFinished != null) {
|
||||||
|
sql.append("AND is_finished = ? ");
|
||||||
|
bindParams.add(isFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataByteOffset != null && expectedValue != null) {
|
||||||
|
sql.append("AND SUBSTRING(state_data FROM ? FOR 8) = ? ");
|
||||||
|
|
||||||
|
// We convert our long on Java-side to control endian
|
||||||
|
byte[] rawExpectedValue = Longs.toByteArray(expectedValue);
|
||||||
|
|
||||||
|
// SQL binary data offsets start at 1
|
||||||
|
bindParams.add(dataByteOffset + 1);
|
||||||
|
bindParams.add(rawExpectedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slice so that we meet both minimumCount and minimumPeriod
|
||||||
|
int minimumHeight = mostRecentState.getHeight() - (int) (minimumPeriod / 60 * 1000L); // XXX assumes 60 second blocks
|
||||||
|
|
||||||
|
sql.append("AND (FinalATStates.height >= ? OR ROWNUM() < ?) ");
|
||||||
|
bindParams.add(minimumHeight);
|
||||||
|
bindParams.add(minimumCount);
|
||||||
|
|
||||||
|
sql.append("ORDER BY FinalATStates.height DESC");
|
||||||
|
|
||||||
|
List<ATStateData> atStates = new ArrayList<>();
|
||||||
|
|
||||||
|
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||||
|
if (resultSet == null)
|
||||||
|
return atStates;
|
||||||
|
|
||||||
|
do {
|
||||||
|
String atAddress = resultSet.getString(1);
|
||||||
|
int height = resultSet.getInt(2);
|
||||||
|
byte[] stateData = resultSet.getBytes(3); // Actually BLOB
|
||||||
|
byte[] stateHash = resultSet.getBytes(4);
|
||||||
|
long fees = resultSet.getLong(5);
|
||||||
|
boolean isInitial = resultSet.getBoolean(6);
|
||||||
|
|
||||||
|
ATStateData atStateData = new ATStateData(atAddress, height, 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
|
@Override
|
||||||
public List<ATStateData> getBlockATStatesAtHeight(int height) throws DataException {
|
public List<ATStateData> getBlockATStatesAtHeight(int height) throws DataException {
|
||||||
String sql = "SELECT AT_address, state_hash, fees, is_initial "
|
String sql = "SELECT AT_address, state_hash, fees, is_initial "
|
||||||
|
@ -3,6 +3,7 @@ package org.qortal.repository.hsqldb;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import org.qortal.data.crosschain.TradeBotData;
|
import org.qortal.data.crosschain.TradeBotData;
|
||||||
@ -19,12 +20,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException {
|
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException {
|
||||||
String sql = "SELECT trade_state, creator_address, at_address, "
|
String sql = "SELECT acct_name, trade_state, trade_state_value, "
|
||||||
|
+ "creator_address, at_address, "
|
||||||
+ "updated_when, qort_amount, "
|
+ "updated_when, qort_amount, "
|
||||||
+ "trade_native_public_key, trade_native_public_key_hash, "
|
+ "trade_native_public_key, trade_native_public_key_hash, "
|
||||||
+ "trade_native_address, secret, hash_of_secret, "
|
+ "trade_native_address, secret, hash_of_secret, "
|
||||||
+ "trade_foreign_public_key, trade_foreign_public_key_hash, "
|
+ "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, "
|
||||||
+ "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info "
|
+ "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info "
|
||||||
+ "FROM TradeBotStates "
|
+ "FROM TradeBotStates "
|
||||||
+ "WHERE trade_private_key = ?";
|
+ "WHERE trade_private_key = ?";
|
||||||
|
|
||||||
@ -32,49 +34,80 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
|
|||||||
if (resultSet == null)
|
if (resultSet == null)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
int tradeStateValue = resultSet.getInt(1);
|
String acctName = resultSet.getString(1);
|
||||||
TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue);
|
String tradeState = resultSet.getString(2);
|
||||||
if (tradeState == null)
|
int tradeStateValue = resultSet.getInt(3);
|
||||||
throw new DataException("Illegal trade-bot trade-state fetched from repository");
|
String creatorAddress = resultSet.getString(4);
|
||||||
|
String atAddress = resultSet.getString(5);
|
||||||
String creatorAddress = resultSet.getString(2);
|
long timestamp = resultSet.getLong(6);
|
||||||
String atAddress = resultSet.getString(3);
|
long qortAmount = resultSet.getLong(7);
|
||||||
long timestamp = resultSet.getLong(4);
|
byte[] tradeNativePublicKey = resultSet.getBytes(8);
|
||||||
long qortAmount = resultSet.getLong(5);
|
byte[] tradeNativePublicKeyHash = resultSet.getBytes(9);
|
||||||
byte[] tradeNativePublicKey = resultSet.getBytes(6);
|
String tradeNativeAddress = resultSet.getString(10);
|
||||||
byte[] tradeNativePublicKeyHash = resultSet.getBytes(7);
|
byte[] secret = resultSet.getBytes(11);
|
||||||
String tradeNativeAddress = resultSet.getString(8);
|
byte[] hashOfSecret = resultSet.getBytes(12);
|
||||||
byte[] secret = resultSet.getBytes(9);
|
String foreignBlockchain = resultSet.getString(13);
|
||||||
byte[] hashOfSecret = resultSet.getBytes(10);
|
byte[] tradeForeignPublicKey = resultSet.getBytes(14);
|
||||||
byte[] tradeForeignPublicKey = resultSet.getBytes(11);
|
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(15);
|
||||||
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(12);
|
long foreignAmount = resultSet.getLong(16);
|
||||||
long bitcoinAmount = resultSet.getLong(13);
|
String foreignKey = resultSet.getString(17);
|
||||||
String xprv58 = resultSet.getString(14);
|
byte[] lastTransactionSignature = resultSet.getBytes(18);
|
||||||
byte[] lastTransactionSignature = resultSet.getBytes(15);
|
Integer lockTimeA = resultSet.getInt(19);
|
||||||
Integer lockTimeA = resultSet.getInt(16);
|
|
||||||
if (lockTimeA == 0 && resultSet.wasNull())
|
if (lockTimeA == 0 && resultSet.wasNull())
|
||||||
lockTimeA = null;
|
lockTimeA = null;
|
||||||
byte[] receivingAccountInfo = resultSet.getBytes(17);
|
byte[] receivingAccountInfo = resultSet.getBytes(20);
|
||||||
|
|
||||||
return new TradeBotData(tradePrivateKey, tradeState,
|
return new TradeBotData(tradePrivateKey, acctName,
|
||||||
|
tradeState, tradeStateValue,
|
||||||
creatorAddress, atAddress, timestamp, qortAmount,
|
creatorAddress, atAddress, timestamp, qortAmount,
|
||||||
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
||||||
secret, hashOfSecret,
|
secret, hashOfSecret,
|
||||||
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||||
bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo);
|
foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
throw new DataException("Unable to fetch trade-bot trading state from repository", e);
|
throw new DataException("Unable to fetch trade-bot trading state from repository", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsTradeWithAtExcludingStates(String atAddress, List<String> excludeStates) throws DataException {
|
||||||
|
if (excludeStates == null)
|
||||||
|
excludeStates = Collections.emptyList();
|
||||||
|
|
||||||
|
StringBuilder whereClause = new StringBuilder(256);
|
||||||
|
whereClause.append("at_address = ?");
|
||||||
|
|
||||||
|
Object[] bindParams = new Object[1 + excludeStates.size()];
|
||||||
|
bindParams[0] = atAddress;
|
||||||
|
|
||||||
|
if (!excludeStates.isEmpty()) {
|
||||||
|
whereClause.append(" AND trade_state NOT IN (?");
|
||||||
|
bindParams[1] = excludeStates.get(0);
|
||||||
|
|
||||||
|
for (int i = 1; i < excludeStates.size(); ++i) {
|
||||||
|
whereClause.append(", ?");
|
||||||
|
bindParams[1 + i] = excludeStates.get(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
whereClause.append(")");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return this.repository.exists("TradeBotStates", whereClause.toString(), bindParams);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new DataException("Unable to check for trade-bot state in repository", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<TradeBotData> getAllTradeBotData() throws DataException {
|
public List<TradeBotData> getAllTradeBotData() throws DataException {
|
||||||
String sql = "SELECT trade_private_key, trade_state, creator_address, at_address, "
|
String sql = "SELECT trade_private_key, acct_name, trade_state, trade_state_value, "
|
||||||
|
+ "creator_address, at_address, "
|
||||||
+ "updated_when, qort_amount, "
|
+ "updated_when, qort_amount, "
|
||||||
+ "trade_native_public_key, trade_native_public_key_hash, "
|
+ "trade_native_public_key, trade_native_public_key_hash, "
|
||||||
+ "trade_native_address, secret, hash_of_secret, "
|
+ "trade_native_address, secret, hash_of_secret, "
|
||||||
+ "trade_foreign_public_key, trade_foreign_public_key_hash, "
|
+ "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, "
|
||||||
+ "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info "
|
+ "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info "
|
||||||
+ "FROM TradeBotStates";
|
+ "FROM TradeBotStates";
|
||||||
|
|
||||||
List<TradeBotData> allTradeBotData = new ArrayList<>();
|
List<TradeBotData> allTradeBotData = new ArrayList<>();
|
||||||
@ -85,36 +118,36 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
byte[] tradePrivateKey = resultSet.getBytes(1);
|
byte[] tradePrivateKey = resultSet.getBytes(1);
|
||||||
int tradeStateValue = resultSet.getInt(2);
|
String acctName = resultSet.getString(2);
|
||||||
TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue);
|
String tradeState = resultSet.getString(3);
|
||||||
if (tradeState == null)
|
int tradeStateValue = resultSet.getInt(4);
|
||||||
throw new DataException("Illegal trade-bot trade-state fetched from repository");
|
String creatorAddress = resultSet.getString(5);
|
||||||
|
String atAddress = resultSet.getString(6);
|
||||||
String creatorAddress = resultSet.getString(3);
|
long timestamp = resultSet.getLong(7);
|
||||||
String atAddress = resultSet.getString(4);
|
long qortAmount = resultSet.getLong(8);
|
||||||
long timestamp = resultSet.getLong(5);
|
byte[] tradeNativePublicKey = resultSet.getBytes(9);
|
||||||
long qortAmount = resultSet.getLong(6);
|
byte[] tradeNativePublicKeyHash = resultSet.getBytes(10);
|
||||||
byte[] tradeNativePublicKey = resultSet.getBytes(7);
|
String tradeNativeAddress = resultSet.getString(11);
|
||||||
byte[] tradeNativePublicKeyHash = resultSet.getBytes(8);
|
byte[] secret = resultSet.getBytes(12);
|
||||||
String tradeNativeAddress = resultSet.getString(9);
|
byte[] hashOfSecret = resultSet.getBytes(13);
|
||||||
byte[] secret = resultSet.getBytes(10);
|
String foreignBlockchain = resultSet.getString(14);
|
||||||
byte[] hashOfSecret = resultSet.getBytes(11);
|
byte[] tradeForeignPublicKey = resultSet.getBytes(15);
|
||||||
byte[] tradeForeignPublicKey = resultSet.getBytes(12);
|
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(16);
|
||||||
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(13);
|
long foreignAmount = resultSet.getLong(17);
|
||||||
long bitcoinAmount = resultSet.getLong(14);
|
String foreignKey = resultSet.getString(18);
|
||||||
String xprv58 = resultSet.getString(15);
|
byte[] lastTransactionSignature = resultSet.getBytes(19);
|
||||||
byte[] lastTransactionSignature = resultSet.getBytes(16);
|
Integer lockTimeA = resultSet.getInt(20);
|
||||||
Integer lockTimeA = resultSet.getInt(17);
|
|
||||||
if (lockTimeA == 0 && resultSet.wasNull())
|
if (lockTimeA == 0 && resultSet.wasNull())
|
||||||
lockTimeA = null;
|
lockTimeA = null;
|
||||||
byte[] receivingAccountInfo = resultSet.getBytes(18);
|
byte[] receivingAccountInfo = resultSet.getBytes(21);
|
||||||
|
|
||||||
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState,
|
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, acctName,
|
||||||
|
tradeState, tradeStateValue,
|
||||||
creatorAddress, atAddress, timestamp, qortAmount,
|
creatorAddress, atAddress, timestamp, qortAmount,
|
||||||
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
|
||||||
secret, hashOfSecret,
|
secret, hashOfSecret,
|
||||||
tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash,
|
||||||
bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo);
|
foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo);
|
||||||
allTradeBotData.add(tradeBotData);
|
allTradeBotData.add(tradeBotData);
|
||||||
} while (resultSet.next());
|
} while (resultSet.next());
|
||||||
|
|
||||||
@ -129,7 +162,9 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
|
|||||||
HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates");
|
HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates");
|
||||||
|
|
||||||
saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey())
|
saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey())
|
||||||
.bind("trade_state", tradeBotData.getState().value)
|
.bind("acct_name", tradeBotData.getAcctName())
|
||||||
|
.bind("trade_state", tradeBotData.getState())
|
||||||
|
.bind("trade_state_value", tradeBotData.getStateValue())
|
||||||
.bind("creator_address", tradeBotData.getCreatorAddress())
|
.bind("creator_address", tradeBotData.getCreatorAddress())
|
||||||
.bind("at_address", tradeBotData.getAtAddress())
|
.bind("at_address", tradeBotData.getAtAddress())
|
||||||
.bind("updated_when", tradeBotData.getTimestamp())
|
.bind("updated_when", tradeBotData.getTimestamp())
|
||||||
@ -137,11 +172,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
|
|||||||
.bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey())
|
.bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey())
|
||||||
.bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash())
|
.bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash())
|
||||||
.bind("trade_native_address", tradeBotData.getTradeNativeAddress())
|
.bind("trade_native_address", tradeBotData.getTradeNativeAddress())
|
||||||
.bind("secret", tradeBotData.getSecret()).bind("hash_of_secret", tradeBotData.getHashOfSecret())
|
.bind("secret", tradeBotData.getSecret())
|
||||||
|
.bind("hash_of_secret", tradeBotData.getHashOfSecret())
|
||||||
|
.bind("foreign_blockchain", tradeBotData.getForeignBlockchain())
|
||||||
.bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey())
|
.bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey())
|
||||||
.bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash())
|
.bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash())
|
||||||
.bind("bitcoin_amount", tradeBotData.getBitcoinAmount())
|
.bind("foreign_amount", tradeBotData.getForeignAmount())
|
||||||
.bind("xprv58", tradeBotData.getXprv58())
|
.bind("foreign_key", tradeBotData.getForeignKey())
|
||||||
.bind("last_transaction_signature", tradeBotData.getLastTransactionSignature())
|
.bind("last_transaction_signature", tradeBotData.getLastTransactionSignature())
|
||||||
.bind("locktime_a", tradeBotData.getLockTimeA())
|
.bind("locktime_a", tradeBotData.getLockTimeA())
|
||||||
.bind("receiving_account_info", tradeBotData.getReceivingAccountInfo());
|
.bind("receiving_account_info", tradeBotData.getReceivingAccountInfo());
|
||||||
|
@ -4,9 +4,12 @@ import java.sql.Connection;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
import org.apache.logging.log4j.LogManager;
|
import org.apache.logging.log4j.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot;
|
||||||
|
|
||||||
public class HSQLDBDatabaseUpdates {
|
public class HSQLDBDatabaseUpdates {
|
||||||
|
|
||||||
@ -618,6 +621,7 @@ public class HSQLDBDatabaseUpdates {
|
|||||||
|
|
||||||
case 20:
|
case 20:
|
||||||
// Trade bot
|
// Trade bot
|
||||||
|
// See case 25 below for changes
|
||||||
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 TINYINT NOT NULL, "
|
||||||
+ "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, "
|
+ "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, "
|
||||||
+ "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, "
|
+ "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, "
|
||||||
@ -779,6 +783,45 @@ public class HSQLDBDatabaseUpdates {
|
|||||||
+ "height INT NOT NULL, PRIMARY KEY (height, AT_address))");
|
+ "height INT NOT NULL, PRIMARY KEY (height, AT_address))");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 32:
|
||||||
|
// Multiple blockchains, ACCTs and trade-bots
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN acct_name VARCHAR(40) BEFORE trade_state");
|
||||||
|
stmt.execute("UPDATE TradeBotStates SET acct_name = 'BitcoinACCTv1' WHERE acct_name IS NULL");
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN acct_name SET NOT NULL");
|
||||||
|
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN trade_state RENAME TO trade_state_value");
|
||||||
|
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN trade_state VARCHAR(40) BEFORE trade_state_value");
|
||||||
|
// Any existing values will be BitcoinACCTv1
|
||||||
|
StringBuilder updateTradeBotStatesSql = new StringBuilder(1024);
|
||||||
|
updateTradeBotStatesSql.append("UPDATE TradeBotStates SET (trade_state) = (")
|
||||||
|
.append("SELECT state_name FROM (VALUES ")
|
||||||
|
.append(
|
||||||
|
Arrays.stream(BitcoinACCTv1TradeBot.State.values())
|
||||||
|
.map(state -> String.format("(%d, '%s')", state.value, state.name()))
|
||||||
|
.collect(Collectors.joining(", ")))
|
||||||
|
.append(") AS BitcoinACCTv1States (state_value, state_name) ")
|
||||||
|
.append("WHERE state_value = trade_state_value)");
|
||||||
|
stmt.execute(updateTradeBotStatesSql.toString());
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN trade_state SET NOT NULL");
|
||||||
|
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ADD COLUMN foreign_blockchain VARCHAR(40) BEFORE trade_foreign_public_key");
|
||||||
|
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN bitcoin_amount RENAME TO foreign_amount");
|
||||||
|
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN xprv58 RENAME TO foreign_key");
|
||||||
|
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN secret SET NULL");
|
||||||
|
stmt.execute("ALTER TABLE TradeBotStates ALTER COLUMN hash_of_secret SET NULL");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 33:
|
||||||
|
// PRESENCE transactions
|
||||||
|
stmt.execute("CREATE TABLE IF NOT EXISTS PresenceTransactions ("
|
||||||
|
+ "signature Signature, nonce INT NOT NULL, presence_type INT NOT NULL, "
|
||||||
|
+ "timestamp_signature Signature NOT NULL, " + TRANSACTION_KEYS + ")");
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// nothing to do
|
// nothing to do
|
||||||
return false;
|
return false;
|
||||||
|
@ -16,6 +16,7 @@ import java.sql.Savepoint;
|
|||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
import java.util.ArrayDeque;
|
import java.util.ArrayDeque;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.Deque;
|
import java.util.Deque;
|
||||||
@ -825,15 +826,18 @@ public class HSQLDBRepository implements Repository {
|
|||||||
* <p>
|
* <p>
|
||||||
* (Convenience method for HSQLDB repository subclasses).
|
* (Convenience method for HSQLDB repository subclasses).
|
||||||
*/
|
*/
|
||||||
/* package */ static void temporaryValuesTableSql(StringBuilder stringBuilder, List<? extends Object> values, String tableName, String columnName) {
|
/* package */ static void temporaryValuesTableSql(StringBuilder stringBuilder, Collection<?> values, String tableName, String columnName) {
|
||||||
stringBuilder.append("(VALUES ");
|
stringBuilder.append("(VALUES ");
|
||||||
|
|
||||||
for (int i = 0; i < values.size(); ++i) {
|
boolean first = true;
|
||||||
if (i != 0)
|
for (Object value : values) {
|
||||||
|
if (first)
|
||||||
|
first = false;
|
||||||
|
else
|
||||||
stringBuilder.append(", ");
|
stringBuilder.append(", ");
|
||||||
|
|
||||||
stringBuilder.append("(");
|
stringBuilder.append("(");
|
||||||
stringBuilder.append(values.get(i));
|
stringBuilder.append(value);
|
||||||
stringBuilder.append(")");
|
stringBuilder.append(")");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,57 @@
|
|||||||
|
package org.qortal.repository.hsqldb.transaction;
|
||||||
|
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
|
||||||
|
import org.qortal.data.transaction.BaseTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.TransactionData;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.hsqldb.HSQLDBRepository;
|
||||||
|
import org.qortal.repository.hsqldb.HSQLDBSaver;
|
||||||
|
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||||
|
|
||||||
|
public class HSQLDBPresenceTransactionRepository extends HSQLDBTransactionRepository {
|
||||||
|
|
||||||
|
public HSQLDBPresenceTransactionRepository(HSQLDBRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionData fromBase(BaseTransactionData baseTransactionData) throws DataException {
|
||||||
|
String sql = "SELECT nonce, presence_type, timestamp_signature FROM PresenceTransactions WHERE signature = ?";
|
||||||
|
|
||||||
|
try (ResultSet resultSet = this.repository.checkedExecute(sql, baseTransactionData.getSignature())) {
|
||||||
|
if (resultSet == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
int nonce = resultSet.getInt(1);
|
||||||
|
int presenceTypeValue = resultSet.getInt(2);
|
||||||
|
PresenceType presenceType = PresenceType.valueOf(presenceTypeValue);
|
||||||
|
|
||||||
|
byte[] timestampSignature = resultSet.getBytes(3);
|
||||||
|
|
||||||
|
return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new DataException("Unable to fetch presence transaction from repository", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void save(TransactionData transactionData) throws DataException {
|
||||||
|
PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData;
|
||||||
|
|
||||||
|
HSQLDBSaver saveHelper = new HSQLDBSaver("PresenceTransactions");
|
||||||
|
|
||||||
|
saveHelper.bind("signature", presenceTransactionData.getSignature())
|
||||||
|
.bind("nonce", presenceTransactionData.getNonce())
|
||||||
|
.bind("presence_type", presenceTransactionData.getPresenceType().value)
|
||||||
|
.bind("timestamp_signature", presenceTransactionData.getTimestampSignature());
|
||||||
|
|
||||||
|
try {
|
||||||
|
saveHelper.execute(this.repository);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
throw new DataException("Unable to save chat transaction into repository", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1124,6 +1124,63 @@ public class HSQLDBTransactionRepository implements TransactionRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<TransactionData> getUnconfirmedTransactions(TransactionType txType, byte[] creatorPublicKey) throws DataException {
|
||||||
|
if (txType == null && creatorPublicKey == null)
|
||||||
|
throw new IllegalArgumentException("At least one of txType or creatorPublicKey must be non-null");
|
||||||
|
|
||||||
|
StringBuilder sql = new StringBuilder(1024);
|
||||||
|
sql.append("SELECT signature FROM UnconfirmedTransactions ");
|
||||||
|
sql.append("JOIN Transactions USING (signature) ");
|
||||||
|
sql.append("WHERE ");
|
||||||
|
|
||||||
|
List<String> whereClauses = new ArrayList<>();
|
||||||
|
List<Object> bindParams = new ArrayList<>();
|
||||||
|
|
||||||
|
if (txType != null) {
|
||||||
|
whereClauses.add("type = ?");
|
||||||
|
bindParams.add(Integer.valueOf(txType.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creatorPublicKey != null) {
|
||||||
|
whereClauses.add("creator = ?");
|
||||||
|
bindParams.add(creatorPublicKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
final int whereClausesSize = whereClauses.size();
|
||||||
|
for (int wci = 0; wci < whereClausesSize; ++wci) {
|
||||||
|
if (wci != 0)
|
||||||
|
sql.append(" AND ");
|
||||||
|
|
||||||
|
sql.append(whereClauses.get(wci));
|
||||||
|
}
|
||||||
|
|
||||||
|
sql.append("ORDER BY created_when, signature");
|
||||||
|
|
||||||
|
List<TransactionData> transactions = new ArrayList<>();
|
||||||
|
|
||||||
|
try (ResultSet resultSet = this.repository.checkedExecute(sql.toString(), bindParams.toArray())) {
|
||||||
|
if (resultSet == null)
|
||||||
|
return transactions;
|
||||||
|
|
||||||
|
do {
|
||||||
|
byte[] signature = resultSet.getBytes(1);
|
||||||
|
|
||||||
|
TransactionData transactionData = this.fromSignature(signature);
|
||||||
|
|
||||||
|
if (transactionData == null)
|
||||||
|
// Something inconsistent with the repository
|
||||||
|
throw new DataException(String.format("Unable to fetch unconfirmed transaction %s from repository?", Base58.encode(signature)));
|
||||||
|
|
||||||
|
transactions.add(transactionData);
|
||||||
|
} while (resultSet.next());
|
||||||
|
|
||||||
|
return transactions;
|
||||||
|
} catch (SQLException | DataException e) {
|
||||||
|
throw new DataException("Unable to fetch unconfirmed transactions from repository", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void confirmTransaction(byte[] signature) throws DataException {
|
public void confirmTransaction(byte[] signature) throws DataException {
|
||||||
try {
|
try {
|
||||||
|
@ -21,7 +21,8 @@ import org.eclipse.persistence.exceptions.XMLMarshalException;
|
|||||||
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
import org.eclipse.persistence.jaxb.JAXBContextFactory;
|
||||||
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
|
||||||
import org.qortal.block.BlockChain;
|
import org.qortal.block.BlockChain;
|
||||||
import org.qortal.crosschain.BTC.BitcoinNet;
|
import org.qortal.crosschain.Bitcoin.BitcoinNet;
|
||||||
|
import org.qortal.crosschain.Litecoin.LitecoinNet;
|
||||||
|
|
||||||
// All properties to be converted to JSON via JAXB
|
// All properties to be converted to JSON via JAXB
|
||||||
@XmlAccessorType(XmlAccessType.FIELD)
|
@XmlAccessorType(XmlAccessType.FIELD)
|
||||||
@ -123,6 +124,7 @@ public class Settings {
|
|||||||
// Which blockchains this node is running
|
// Which blockchains this node is running
|
||||||
private String blockchainConfig = null; // use default from resources
|
private String blockchainConfig = null; // use default from resources
|
||||||
private BitcoinNet bitcoinNet = BitcoinNet.MAIN;
|
private BitcoinNet bitcoinNet = BitcoinNet.MAIN;
|
||||||
|
private LitecoinNet litecoinNet = LitecoinNet.MAIN;
|
||||||
// Also crosschain-related:
|
// Also crosschain-related:
|
||||||
/** Whether to show SysTray pop-up notifications when trade-bot entries change state */
|
/** Whether to show SysTray pop-up notifications when trade-bot entries change state */
|
||||||
private boolean tradebotSystrayEnabled = false;
|
private boolean tradebotSystrayEnabled = false;
|
||||||
@ -406,6 +408,10 @@ public class Settings {
|
|||||||
return this.bitcoinNet;
|
return this.bitcoinNet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LitecoinNet getLitecoinNet() {
|
||||||
|
return this.litecoinNet;
|
||||||
|
}
|
||||||
|
|
||||||
public boolean isTradebotSystrayEnabled() {
|
public boolean isTradebotSystrayEnabled() {
|
||||||
return this.tradebotSystrayEnabled;
|
return this.tradebotSystrayEnabled;
|
||||||
}
|
}
|
||||||
|
@ -141,7 +141,7 @@ public class ChatTransaction extends Transaction {
|
|||||||
// If we exist in the repository then we've been imported as unconfirmed,
|
// If we exist in the repository then we've been imported as unconfirmed,
|
||||||
// but we don't want to make it into a block, so return fake non-OK result.
|
// but we don't want to make it into a block, so return fake non-OK result.
|
||||||
if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature()))
|
if (this.repository.getTransactionRepository().exists(this.chatTransactionData.getSignature()))
|
||||||
return ValidationResult.CHAT;
|
return ValidationResult.INVALID_BUT_OK;
|
||||||
|
|
||||||
// If we have a recipient, check it is a valid address
|
// If we have a recipient, check it is a valid address
|
||||||
String recipientAddress = chatTransactionData.getRecipient();
|
String recipientAddress = chatTransactionData.getRecipient();
|
||||||
@ -188,6 +188,16 @@ public class ChatTransaction extends Transaction {
|
|||||||
return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce);
|
return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, difficulty, nonce);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure there's at least a skeleton account so people
|
||||||
|
* can retrieve sender's public key using address, even if all their messages
|
||||||
|
* expire.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void onImportAsUnconfirmed() throws DataException {
|
||||||
|
this.getCreator().ensureAccount();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void process() throws DataException {
|
public void process() throws DataException {
|
||||||
throw new DataException("CHAT transactions should never be processed");
|
throw new DataException("CHAT transactions should never be processed");
|
||||||
|
255
src/main/java/org/qortal/transaction/PresenceTransaction.java
Normal file
255
src/main/java/org/qortal/transaction/PresenceTransaction.java
Normal file
@ -0,0 +1,255 @@
|
|||||||
|
package org.qortal.transaction;
|
||||||
|
|
||||||
|
import static java.util.Arrays.stream;
|
||||||
|
import static java.util.stream.Collectors.toMap;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager;
|
||||||
|
import org.apache.logging.log4j.Logger;
|
||||||
|
import org.qortal.account.Account;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.crosschain.ACCT;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.crypto.MemoryPoW;
|
||||||
|
import org.qortal.data.at.ATData;
|
||||||
|
import org.qortal.data.crosschain.CrossChainTradeData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.TransactionData;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.transform.transaction.PresenceTransactionTransformer;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
import org.qortal.utils.ByteArray;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
public class PresenceTransaction extends Transaction {
|
||||||
|
|
||||||
|
private static final Logger LOGGER = LogManager.getLogger(PresenceTransaction.class);
|
||||||
|
|
||||||
|
// Properties
|
||||||
|
private PresenceTransactionData presenceTransactionData;
|
||||||
|
|
||||||
|
// Other useful constants
|
||||||
|
public static final int POW_BUFFER_SIZE = 8 * 1024 * 1024; // bytes
|
||||||
|
public static final int POW_DIFFICULTY = 8; // leading zero bits
|
||||||
|
|
||||||
|
public enum PresenceType {
|
||||||
|
REWARD_SHARE(0) {
|
||||||
|
@Override
|
||||||
|
public long getLifetime() {
|
||||||
|
return Controller.ONLINE_TIMESTAMP_MODULUS;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TRADE_BOT(1) {
|
||||||
|
@Override
|
||||||
|
public long getLifetime() {
|
||||||
|
return 30 * 60 * 1000L; // 30 minutes in milliseconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public final int value;
|
||||||
|
private static final Map<Integer, PresenceType> map = stream(PresenceType.values()).collect(toMap(type -> type.value, type -> type));
|
||||||
|
|
||||||
|
PresenceType(int value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract long getLifetime();
|
||||||
|
|
||||||
|
public static PresenceType valueOf(int value) {
|
||||||
|
return map.get(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns PresenceType with matching <tt>name</tt> or <tt>null</tt> (instead of throwing IllegalArgumentException). */
|
||||||
|
public static PresenceType fromString(String name) {
|
||||||
|
try {
|
||||||
|
return PresenceType.valueOf(name);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constructors
|
||||||
|
|
||||||
|
public PresenceTransaction(Repository repository, TransactionData transactionData) {
|
||||||
|
super(repository, transactionData);
|
||||||
|
|
||||||
|
this.presenceTransactionData = (PresenceTransactionData) this.transactionData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// More information
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getDeadline() {
|
||||||
|
return this.transactionData.getTimestamp() + this.presenceTransactionData.getPresenceType().getLifetime();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> getRecipientAddresses() throws DataException {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
|
||||||
|
public Account getSender() {
|
||||||
|
return this.getCreator();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Processing
|
||||||
|
|
||||||
|
public void computeNonce() throws DataException {
|
||||||
|
byte[] transactionBytes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionBytes = TransactionTransformer.toBytesForSigning(this.transactionData);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw new RuntimeException("Unable to transform transaction to byte array for verification", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear nonce from transactionBytes
|
||||||
|
PresenceTransactionTransformer.clearNonce(transactionBytes);
|
||||||
|
|
||||||
|
// Calculate nonce
|
||||||
|
this.presenceTransactionData.setNonce(MemoryPoW.compute2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether PRESENCE transaction has valid txGroupId.
|
||||||
|
* <p>
|
||||||
|
* We insist on NO_GROUP.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean isValidTxGroupId() throws DataException {
|
||||||
|
int txGroupId = this.transactionData.getTxGroupId();
|
||||||
|
|
||||||
|
return txGroupId == Group.NO_GROUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ValidationResult isFeeValid() throws DataException {
|
||||||
|
if (this.transactionData.getFee() < 0)
|
||||||
|
return ValidationResult.NEGATIVE_FEE;
|
||||||
|
|
||||||
|
return ValidationResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean hasValidReference() throws DataException {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ValidationResult isValid() throws DataException {
|
||||||
|
// Nonce checking is done via isSignatureValid() as that method is only called once per import
|
||||||
|
|
||||||
|
// If we exist in the repository then we've been imported as unconfirmed,
|
||||||
|
// but we don't want to make it into a block, so return fake non-OK result.
|
||||||
|
if (this.repository.getTransactionRepository().exists(this.presenceTransactionData.getSignature()))
|
||||||
|
return ValidationResult.INVALID_BUT_OK;
|
||||||
|
|
||||||
|
// We only support TRADE_BOT-type PRESENCE at this time
|
||||||
|
if (PresenceType.TRADE_BOT != this.presenceTransactionData.getPresenceType())
|
||||||
|
return ValidationResult.NOT_YET_RELEASED;
|
||||||
|
|
||||||
|
// Check timestamp signature
|
||||||
|
byte[] timestampSignature = this.presenceTransactionData.getTimestampSignature();
|
||||||
|
byte[] timestampBytes = Longs.toByteArray(this.presenceTransactionData.getTimestamp());
|
||||||
|
if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), timestampSignature, timestampBytes))
|
||||||
|
return ValidationResult.INVALID_TIMESTAMP_SIGNATURE;
|
||||||
|
|
||||||
|
Map<ByteArray, Supplier<ACCT>> acctSuppliersByCodeHash = SupportedBlockchain.getAcctMap();
|
||||||
|
Set<ByteArray> codeHashes = acctSuppliersByCodeHash.keySet();
|
||||||
|
boolean isExecutable = true;
|
||||||
|
|
||||||
|
List<ATData> atsData = repository.getATRepository().getAllATsByFunctionality(codeHashes, isExecutable);
|
||||||
|
|
||||||
|
// Convert signer's public key to address form
|
||||||
|
String signerAddress = Crypto.toAddress(this.transactionData.getCreatorPublicKey());
|
||||||
|
|
||||||
|
for (ATData atData : atsData) {
|
||||||
|
ByteArray atCodeHash = new ByteArray(atData.getCodeHash());
|
||||||
|
Supplier<ACCT> acctSupplier = acctSuppliersByCodeHash.get(atCodeHash);
|
||||||
|
if (acctSupplier == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
CrossChainTradeData crossChainTradeData = acctSupplier.get().populateTradeData(repository, atData);
|
||||||
|
|
||||||
|
// OK if signer's public key (in address form) matches Bob's trade public key (in address form)
|
||||||
|
if (signerAddress.equals(crossChainTradeData.qortalCreatorTradeAddress))
|
||||||
|
return ValidationResult.OK;
|
||||||
|
|
||||||
|
// OK if signer's public key (in address form) matches Alice's trade public key (in address form)
|
||||||
|
if (signerAddress.equals(crossChainTradeData.qortalPartnerAddress))
|
||||||
|
return ValidationResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidationResult.AT_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isSignatureValid() {
|
||||||
|
byte[] signature = this.transactionData.getSignature();
|
||||||
|
if (signature == null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
byte[] transactionBytes;
|
||||||
|
|
||||||
|
try {
|
||||||
|
transactionBytes = PresenceTransactionTransformer.toBytesForSigning(this.transactionData);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
throw new RuntimeException("Unable to transform transaction to byte array for verification", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Crypto.verify(this.transactionData.getCreatorPublicKey(), signature, transactionBytes))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int nonce = this.presenceTransactionData.getNonce();
|
||||||
|
|
||||||
|
// Clear nonce from transactionBytes
|
||||||
|
PresenceTransactionTransformer.clearNonce(transactionBytes);
|
||||||
|
|
||||||
|
// Check nonce
|
||||||
|
return MemoryPoW.verify2(transactionBytes, POW_BUFFER_SIZE, POW_DIFFICULTY, nonce);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove any PRESENCE transactions by the same signer that have older timestamps.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected void onImportAsUnconfirmed() throws DataException {
|
||||||
|
byte[] creatorPublicKey = this.transactionData.getCreatorPublicKey();
|
||||||
|
List<TransactionData> creatorsPresenceTransactions = this.repository.getTransactionRepository().getUnconfirmedTransactions(TransactionType.PRESENCE, creatorPublicKey);
|
||||||
|
|
||||||
|
if (creatorsPresenceTransactions.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
// List should contain oldest transaction first, so remove all but last from repository.
|
||||||
|
creatorsPresenceTransactions.remove(creatorsPresenceTransactions.size() - 1);
|
||||||
|
for (TransactionData transactionData : creatorsPresenceTransactions) {
|
||||||
|
LOGGER.info(() -> String.format("Deleting older PRESENCE transaction %s", Base58.encode(transactionData.getSignature())));
|
||||||
|
this.repository.getTransactionRepository().delete(transactionData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void process() throws DataException {
|
||||||
|
throw new DataException("PRESENCE transactions should never be processed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void orphan() throws DataException {
|
||||||
|
throw new DataException("PRESENCE transactions should never be orphaned");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -83,7 +83,8 @@ public abstract class Transaction {
|
|||||||
ENABLE_FORGING(37, false),
|
ENABLE_FORGING(37, false),
|
||||||
REWARD_SHARE(38, false),
|
REWARD_SHARE(38, false),
|
||||||
ACCOUNT_LEVEL(39, false),
|
ACCOUNT_LEVEL(39, false),
|
||||||
TRANSFER_PRIVS(40, false);
|
TRANSFER_PRIVS(40, false),
|
||||||
|
PRESENCE(41, false);
|
||||||
|
|
||||||
public final int value;
|
public final int value;
|
||||||
public final boolean needsApproval;
|
public final boolean needsApproval;
|
||||||
@ -244,7 +245,8 @@ public abstract class Transaction {
|
|||||||
ACCOUNT_ALREADY_EXISTS(92),
|
ACCOUNT_ALREADY_EXISTS(92),
|
||||||
INVALID_GROUP_BLOCK_DELAY(93),
|
INVALID_GROUP_BLOCK_DELAY(93),
|
||||||
INCORRECT_NONCE(94),
|
INCORRECT_NONCE(94),
|
||||||
CHAT(999),
|
INVALID_TIMESTAMP_SIGNATURE(95),
|
||||||
|
INVALID_BUT_OK(999),
|
||||||
NOT_YET_RELEASED(1000);
|
NOT_YET_RELEASED(1000);
|
||||||
|
|
||||||
public final int value;
|
public final int value;
|
||||||
@ -763,15 +765,20 @@ public abstract class Transaction {
|
|||||||
/**
|
/**
|
||||||
* Import into our repository as a new, unconfirmed transaction.
|
* Import into our repository as a new, unconfirmed transaction.
|
||||||
* <p>
|
* <p>
|
||||||
* Calls <tt>repository.saveChanges()</tt>
|
* @implSpec <i>blocks</i> to obtain blockchain lock
|
||||||
|
* <p>
|
||||||
|
* If transaction is valid, then:
|
||||||
|
* <ul>
|
||||||
|
* <li>calls {@link Repository#discardChanges()}</li>
|
||||||
|
* <li>calls {@link Controller#onNewTransaction(TransactionData, Peer)}</li>
|
||||||
|
* </ul>
|
||||||
*
|
*
|
||||||
* @throws DataException
|
* @throws DataException
|
||||||
*/
|
*/
|
||||||
public ValidationResult importAsUnconfirmed() throws DataException {
|
public ValidationResult importAsUnconfirmed() throws DataException {
|
||||||
// Attempt to acquire blockchain lock
|
// Attempt to acquire blockchain lock
|
||||||
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock();
|
||||||
if (!blockchainLock.tryLock())
|
blockchainLock.lock();
|
||||||
return ValidationResult.NO_BLOCKCHAIN_LOCK;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check transaction doesn't already exist
|
// Check transaction doesn't already exist
|
||||||
@ -798,22 +805,30 @@ public abstract class Transaction {
|
|||||||
repository.getTransactionRepository().save(transactionData);
|
repository.getTransactionRepository().save(transactionData);
|
||||||
repository.getTransactionRepository().unconfirmTransaction(transactionData);
|
repository.getTransactionRepository().unconfirmTransaction(transactionData);
|
||||||
|
|
||||||
/*
|
this.onImportAsUnconfirmed();
|
||||||
* If CHAT transaction then ensure there's at least a skeleton account so people
|
|
||||||
* can retrieve sender's public key using address, even if all their messages
|
|
||||||
* expire.
|
|
||||||
*/
|
|
||||||
if (transactionData.getType() == TransactionType.CHAT)
|
|
||||||
this.getCreator().ensureAccount();
|
|
||||||
|
|
||||||
repository.saveChanges();
|
repository.saveChanges();
|
||||||
|
|
||||||
|
// Notify controller of new transaction
|
||||||
|
Controller.getInstance().onNewTransaction(transactionData);
|
||||||
|
|
||||||
return ValidationResult.OK;
|
return ValidationResult.OK;
|
||||||
} finally {
|
} finally {
|
||||||
blockchainLock.unlock();
|
blockchainLock.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for when a transaction is imported as unconfirmed.
|
||||||
|
* <p>
|
||||||
|
* Called after transaction is added to repository, but before commit.
|
||||||
|
* <p>
|
||||||
|
* Blockchain lock is being held during this time.
|
||||||
|
*/
|
||||||
|
protected void onImportAsUnconfirmed() throws DataException {
|
||||||
|
/* To be optionally overridden */
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether transaction can be added to the blockchain.
|
* Returns whether transaction can be added to the blockchain.
|
||||||
* <p>
|
* <p>
|
||||||
|
@ -0,0 +1,108 @@
|
|||||||
|
package org.qortal.transform.transaction;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.ByteBuffer;
|
||||||
|
|
||||||
|
import org.qortal.data.transaction.BaseTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
import org.qortal.data.transaction.TransactionData;
|
||||||
|
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||||
|
import org.qortal.transaction.Transaction.TransactionType;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.utils.Serialization;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Ints;
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
public class PresenceTransactionTransformer extends TransactionTransformer {
|
||||||
|
|
||||||
|
// Property lengths
|
||||||
|
private static final int NONCE_LENGTH = INT_LENGTH;
|
||||||
|
private static final int PRESENCE_TYPE_LENGTH = BYTE_LENGTH;
|
||||||
|
private static final int TIMESTAMP_SIGNATURE_LENGTH = SIGNATURE_LENGTH;
|
||||||
|
|
||||||
|
private static final int EXTRAS_LENGTH = NONCE_LENGTH + PRESENCE_TYPE_LENGTH + TIMESTAMP_SIGNATURE_LENGTH;
|
||||||
|
|
||||||
|
protected static final TransactionLayout layout;
|
||||||
|
|
||||||
|
static {
|
||||||
|
layout = new TransactionLayout();
|
||||||
|
layout.add("txType: " + TransactionType.PRESENCE.valueString, TransformationType.INT);
|
||||||
|
layout.add("timestamp", TransformationType.TIMESTAMP);
|
||||||
|
layout.add("transaction's groupID", TransformationType.INT);
|
||||||
|
layout.add("reference", TransformationType.SIGNATURE);
|
||||||
|
layout.add("sender's public key", TransformationType.PUBLIC_KEY);
|
||||||
|
layout.add("proof-of-work nonce", TransformationType.INT);
|
||||||
|
layout.add("presence type (reward-share=0, trade-bot=1)", TransformationType.BYTE);
|
||||||
|
layout.add("timestamp-signature", TransformationType.SIGNATURE);
|
||||||
|
layout.add("fee", TransformationType.AMOUNT);
|
||||||
|
layout.add("signature", TransformationType.SIGNATURE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TransactionData fromByteBuffer(ByteBuffer byteBuffer) throws TransformationException {
|
||||||
|
long timestamp = byteBuffer.getLong();
|
||||||
|
|
||||||
|
int txGroupId = byteBuffer.getInt();
|
||||||
|
|
||||||
|
byte[] reference = new byte[REFERENCE_LENGTH];
|
||||||
|
byteBuffer.get(reference);
|
||||||
|
|
||||||
|
byte[] senderPublicKey = Serialization.deserializePublicKey(byteBuffer);
|
||||||
|
|
||||||
|
int nonce = byteBuffer.getInt();
|
||||||
|
|
||||||
|
PresenceType presenceType = PresenceType.valueOf(byteBuffer.get());
|
||||||
|
|
||||||
|
byte[] timestampSignature = new byte[SIGNATURE_LENGTH];
|
||||||
|
byteBuffer.get(timestampSignature);
|
||||||
|
|
||||||
|
long fee = byteBuffer.getLong();
|
||||||
|
|
||||||
|
byte[] signature = new byte[SIGNATURE_LENGTH];
|
||||||
|
byteBuffer.get(signature);
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, senderPublicKey, fee, signature);
|
||||||
|
|
||||||
|
return new PresenceTransactionData(baseTransactionData, nonce, presenceType, timestampSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int getDataLength(TransactionData transactionData) {
|
||||||
|
return getBaseLength(transactionData) + EXTRAS_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] toBytes(TransactionData transactionData) throws TransformationException {
|
||||||
|
try {
|
||||||
|
PresenceTransactionData presenceTransactionData = (PresenceTransactionData) transactionData;
|
||||||
|
|
||||||
|
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
transformCommonBytes(transactionData, bytes);
|
||||||
|
|
||||||
|
bytes.write(Ints.toByteArray(presenceTransactionData.getNonce()));
|
||||||
|
|
||||||
|
bytes.write(presenceTransactionData.getPresenceType().value);
|
||||||
|
|
||||||
|
bytes.write(presenceTransactionData.getTimestampSignature());
|
||||||
|
|
||||||
|
bytes.write(Longs.toByteArray(presenceTransactionData.getFee()));
|
||||||
|
|
||||||
|
if (presenceTransactionData.getSignature() != null)
|
||||||
|
bytes.write(presenceTransactionData.getSignature());
|
||||||
|
|
||||||
|
return bytes.toByteArray();
|
||||||
|
} catch (IOException | ClassCastException e) {
|
||||||
|
throw new TransformationException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clearNonce(byte[] transactionBytes) {
|
||||||
|
int nonceIndex = TYPE_LENGTH + TIMESTAMP_LENGTH + GROUPID_LENGTH + REFERENCE_LENGTH + PUBLIC_KEY_LENGTH;
|
||||||
|
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
transactionBytes[nonceIndex++] = (byte) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,12 +1,19 @@
|
|||||||
package org.qortal.utils;
|
package org.qortal.utils;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
public class ByteArray implements Comparable<ByteArray> {
|
public class ByteArray implements Comparable<ByteArray> {
|
||||||
|
|
||||||
private int hash;
|
private int hash;
|
||||||
public final byte[] value;
|
public final byte[] value;
|
||||||
|
|
||||||
public ByteArray(byte[] value) {
|
public ByteArray(byte[] value) {
|
||||||
this.value = value;
|
this.value = Objects.requireNonNull(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ByteArray of(byte[] value) {
|
||||||
|
return new ByteArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -14,36 +21,39 @@ public class ByteArray implements Comparable<ByteArray> {
|
|||||||
if (this == other)
|
if (this == other)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
if (other instanceof ByteArray)
|
|
||||||
return this.compareTo((ByteArray) other) == 0;
|
|
||||||
|
|
||||||
if (other instanceof byte[])
|
if (other instanceof byte[])
|
||||||
return this.compareTo((byte[]) other) == 0;
|
return Arrays.equals(this.value, (byte[]) other);
|
||||||
|
|
||||||
|
if (other instanceof ByteArray)
|
||||||
|
return Arrays.equals(this.value, ((ByteArray) other).value);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int hashCode() {
|
public int hashCode() {
|
||||||
int h = hash;
|
int h = this.hash;
|
||||||
if (h == 0 && value.length > 0) {
|
byte[] val = this.value;
|
||||||
byte[] val = value;
|
|
||||||
|
if (h == 0 && val.length > 0) {
|
||||||
|
h = 1;
|
||||||
|
|
||||||
for (int i = 0; i < val.length; ++i)
|
for (int i = 0; i < val.length; ++i)
|
||||||
h = 31 * h + val[i];
|
h = 31 * h + val[i];
|
||||||
|
|
||||||
hash = h;
|
this.hash = h;
|
||||||
}
|
}
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int compareTo(ByteArray other) {
|
public int compareTo(ByteArray other) {
|
||||||
return this.compareTo(other.value);
|
Objects.requireNonNull(other);
|
||||||
|
return this.compareToPrimitive(other.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
public int compareTo(byte[] otherValue) {
|
public int compareToPrimitive(byte[] otherValue) {
|
||||||
byte[] val = value;
|
byte[] val = this.value;
|
||||||
|
|
||||||
if (val.length < otherValue.length)
|
if (val.length < otherValue.length)
|
||||||
return -1;
|
return -1;
|
||||||
@ -63,4 +73,17 @@ public class ByteArray implements Comparable<ByteArray> {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String toString() {
|
||||||
|
StringBuilder sb = new StringBuilder(3 + this.value.length * 6);
|
||||||
|
sb.append("[");
|
||||||
|
|
||||||
|
if (this.value.length > 0)
|
||||||
|
sb.append(this.value[0]);
|
||||||
|
|
||||||
|
for (int i = 1; i < this.value.length; ++i)
|
||||||
|
sb.append(", ").append(this.value[i]);
|
||||||
|
|
||||||
|
return sb.append("]").toString();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,42 +1,55 @@
|
|||||||
package org.qortal.utils;
|
package org.qortal.utils;
|
||||||
|
|
||||||
public class Triple<T, U, V> {
|
public class Triple<A, B, C> {
|
||||||
|
|
||||||
private T a;
|
@FunctionalInterface
|
||||||
private U b;
|
public interface TripleConsumer<A, B, C> {
|
||||||
private V c;
|
public void accept(A a, B b, C c);
|
||||||
|
}
|
||||||
|
|
||||||
|
private A a;
|
||||||
|
private B b;
|
||||||
|
private C c;
|
||||||
|
|
||||||
|
public static <A, B, C> Triple<A, B, C> valueOf(A a, B b, C c) {
|
||||||
|
return new Triple<>(a, b, c);
|
||||||
|
}
|
||||||
|
|
||||||
public Triple() {
|
public Triple() {
|
||||||
}
|
}
|
||||||
|
|
||||||
public Triple(T a, U b, V c) {
|
public Triple(A a, B b, C c) {
|
||||||
this.a = a;
|
this.a = a;
|
||||||
this.b = b;
|
this.b = b;
|
||||||
this.c = c;
|
this.c = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setA(T a) {
|
public void setA(A a) {
|
||||||
this.a = a;
|
this.a = a;
|
||||||
}
|
}
|
||||||
|
|
||||||
public T getA() {
|
public A getA() {
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setB(U b) {
|
public void setB(B b) {
|
||||||
this.b = b;
|
this.b = b;
|
||||||
}
|
}
|
||||||
|
|
||||||
public U getB() {
|
public B getB() {
|
||||||
return b;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setC(V c) {
|
public void setC(C c) {
|
||||||
this.c = c;
|
this.c = c;
|
||||||
}
|
}
|
||||||
|
|
||||||
public V getC() {
|
public C getC() {
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void consume(TripleConsumer<A, B, C> consumer) {
|
||||||
|
consumer.accept(this.a, this.b, this.c);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,12 @@ package org.qortal.test;
|
|||||||
import static org.junit.Assert.*;
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
import org.junit.Before;
|
import org.junit.Before;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
@ -28,15 +30,13 @@ public class ByteArrayTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fillMap(Map<ByteArray, String> map) {
|
private static void fillMap(Map<ByteArray, String> map) {
|
||||||
for (byte[] testValue : testValues)
|
for (byte[] testValue : testValues)
|
||||||
map.put(new ByteArray(testValue), String.valueOf(map.size()));
|
map.put(new ByteArray(testValue), String.valueOf(map.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private byte[] dup(byte[] value) {
|
private static byte[] dup(byte[] value) {
|
||||||
byte[] copiedValue = new byte[value.length];
|
return Arrays.copyOf(value, value.length);
|
||||||
System.arraycopy(value, 0, copiedValue, 0, copiedValue.length);
|
|
||||||
return copiedValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -92,7 +92,7 @@ public class ByteArrayTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
@SuppressWarnings("unlikely-arg-type")
|
@SuppressWarnings("unlikely-arg-type")
|
||||||
public void testMapContainsKey() {
|
public void testHashMapContainsKey() {
|
||||||
Map<ByteArray, String> testMap = new HashMap<>();
|
Map<ByteArray, String> testMap = new HashMap<>();
|
||||||
fillMap(testMap);
|
fillMap(testMap);
|
||||||
|
|
||||||
@ -105,8 +105,59 @@ public class ByteArrayTests {
|
|||||||
|
|
||||||
assertTrue("boxed not equal to primitive", ba.equals(copiedValue));
|
assertTrue("boxed not equal to primitive", ba.equals(copiedValue));
|
||||||
|
|
||||||
// This won't work because copiedValue.hashCode() will not match ba.hashCode()
|
/*
|
||||||
assertFalse("Primitive shouldn't be found in map", testMap.containsKey(copiedValue));
|
* Unfortunately this doesn't work because HashMap::containsKey compares hashCodes first,
|
||||||
|
* followed by object references, and copiedValue.hashCode() will never match ba.hashCode().
|
||||||
|
*/
|
||||||
|
assertFalse("Primitive shouldn't be found in HashMap", testMap.containsKey(copiedValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unlikely-arg-type")
|
||||||
|
public void testTreeMapContainsKey() {
|
||||||
|
Map<ByteArray, String> testMap = new TreeMap<>();
|
||||||
|
fillMap(testMap);
|
||||||
|
|
||||||
|
// Create new ByteArray object with an existing value.
|
||||||
|
byte[] copiedValue = dup(testValues.get(3));
|
||||||
|
ByteArray ba = new ByteArray(copiedValue);
|
||||||
|
|
||||||
|
// Confirm object can be found in map
|
||||||
|
assertTrue("ByteArray not found in map", testMap.containsKey(ba));
|
||||||
|
|
||||||
|
assertTrue("boxed not equal to primitive", ba.equals(copiedValue));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unfortunately this doesn't work because TreeMap::containsKey(x) wants to cast x to
|
||||||
|
* Comparable<? super ByteArray> and byte[] does not fit <? super ByteArray>
|
||||||
|
* so this throws a ClassCastException.
|
||||||
|
*/
|
||||||
|
try {
|
||||||
|
assertFalse("Primitive shouldn't be found in TreeMap", testMap.containsKey(copiedValue));
|
||||||
|
fail();
|
||||||
|
} catch (ClassCastException e) {
|
||||||
|
// Expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@SuppressWarnings("unlikely-arg-type")
|
||||||
|
public void testArrayListContains() {
|
||||||
|
// Create new ByteArray object with an existing value.
|
||||||
|
byte[] copiedValue = dup(testValues.get(3));
|
||||||
|
ByteArray ba = new ByteArray(copiedValue);
|
||||||
|
|
||||||
|
// Confirm object can be found in list
|
||||||
|
assertTrue("ByteArray not found in map", testValues.contains(ba));
|
||||||
|
|
||||||
|
assertTrue("boxed not equal to primitive", ba.equals(copiedValue));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Unfortunately this doesn't work because ArrayList::contains performs
|
||||||
|
* copiedValue.equals(x) for each x in testValues, and byte[].equals()
|
||||||
|
* simply compares object references, so will never match any ByteArray.
|
||||||
|
*/
|
||||||
|
assertFalse("Primitive shouldn't be found in ArrayList", testValues.contains(copiedValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -116,8 +167,9 @@ public class ByteArrayTests {
|
|||||||
|
|
||||||
byte[] copiedValue = dup(testValue);
|
byte[] copiedValue = dup(testValue);
|
||||||
|
|
||||||
|
System.out.println(String.format("Primitive hashCode: 0x%08x", testValue.hashCode()));
|
||||||
System.out.println(String.format("Boxed hashCode: 0x%08x", ba1.hashCode()));
|
System.out.println(String.format("Boxed hashCode: 0x%08x", ba1.hashCode()));
|
||||||
System.out.println(String.format("Primitive hashCode: 0x%08x", copiedValue.hashCode()));
|
System.out.println(String.format("Duplicated primitive hashCode: 0x%08x", copiedValue.hashCode()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
133
src/test/java/org/qortal/test/PresenceTests.java
Normal file
133
src/test/java/org/qortal/test/PresenceTests.java
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
package org.qortal.test;
|
||||||
|
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.crosschain.BitcoinACCTv1;
|
||||||
|
import org.qortal.data.transaction.BaseTransactionData;
|
||||||
|
import org.qortal.data.transaction.DeployAtTransactionData;
|
||||||
|
import org.qortal.data.transaction.PresenceTransactionData;
|
||||||
|
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.PresenceTransaction;
|
||||||
|
import org.qortal.transaction.PresenceTransaction.PresenceType;
|
||||||
|
import org.qortal.transaction.Transaction;
|
||||||
|
import org.qortal.transaction.Transaction.ValidationResult;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
public class PresenceTests extends Common {
|
||||||
|
|
||||||
|
private static final byte[] BITCOIN_PKH = new byte[20];
|
||||||
|
private static final byte[] HASH_OF_SECRET_B = new byte[32];
|
||||||
|
|
||||||
|
private PrivateKeyAccount signer;
|
||||||
|
private Repository repository;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void beforeTest() throws DataException {
|
||||||
|
Common.useDefaultSettings();
|
||||||
|
|
||||||
|
this.repository = RepositoryManager.getRepository();
|
||||||
|
this.signer = Common.getTestAccount(this.repository, "bob");
|
||||||
|
|
||||||
|
// We need to create corresponding test trade offer
|
||||||
|
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(this.signer.getAddress(), BITCOIN_PKH, HASH_OF_SECRET_B,
|
||||||
|
0L, 0L,
|
||||||
|
7 * 24 * 60 * 60);
|
||||||
|
|
||||||
|
long txTimestamp = NTP.getTime();
|
||||||
|
byte[] lastReference = this.signer.getLastReference();
|
||||||
|
|
||||||
|
long fee = 0;
|
||||||
|
String name = "QORT-BTC cross-chain trade";
|
||||||
|
String description = "Qortal-Bitcoin cross-chain trade";
|
||||||
|
String atType = "ACCT";
|
||||||
|
String tags = "QORT-BTC ACCT";
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, this.signer.getPublicKey(), fee, null);
|
||||||
|
TransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, 1L, Asset.QORT);
|
||||||
|
|
||||||
|
Transaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
|
||||||
|
|
||||||
|
fee = deployAtTransaction.calcRecommendedFee();
|
||||||
|
deployAtTransactionData.setFee(fee);
|
||||||
|
|
||||||
|
TransactionUtils.signAndImportValid(this.repository, deployAtTransactionData, this.signer);
|
||||||
|
BlockUtils.mintBlock(this.repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void afterTest() throws DataException {
|
||||||
|
if (this.repository != null)
|
||||||
|
this.repository.close();
|
||||||
|
|
||||||
|
this.repository = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void validityTests() throws DataException {
|
||||||
|
long timestamp = System.currentTimeMillis();
|
||||||
|
byte[] timestampBytes = Longs.toByteArray(timestamp);
|
||||||
|
|
||||||
|
byte[] timestampSignature = this.signer.sign(timestampBytes);
|
||||||
|
|
||||||
|
assertTrue(isValid(Group.NO_GROUP, this.signer, timestamp, timestampSignature));
|
||||||
|
|
||||||
|
PrivateKeyAccount nonTrader = Common.getTestAccount(repository, "alice");
|
||||||
|
assertFalse(isValid(Group.NO_GROUP, nonTrader, timestamp, timestampSignature));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void newestOnlyTests() throws DataException {
|
||||||
|
long OLDER_TIMESTAMP = System.currentTimeMillis() - 2000L;
|
||||||
|
long NEWER_TIMESTAMP = OLDER_TIMESTAMP + 1000L;
|
||||||
|
|
||||||
|
PresenceTransaction older = buildPresenceTransaction(Group.NO_GROUP, this.signer, OLDER_TIMESTAMP, null);
|
||||||
|
older.computeNonce();
|
||||||
|
TransactionUtils.signAndImportValid(repository, older.getTransactionData(), this.signer);
|
||||||
|
|
||||||
|
assertTrue(this.repository.getTransactionRepository().exists(older.getTransactionData().getSignature()));
|
||||||
|
|
||||||
|
PresenceTransaction newer = buildPresenceTransaction(Group.NO_GROUP, this.signer, NEWER_TIMESTAMP, null);
|
||||||
|
newer.computeNonce();
|
||||||
|
TransactionUtils.signAndImportValid(repository, newer.getTransactionData(), this.signer);
|
||||||
|
|
||||||
|
assertTrue(this.repository.getTransactionRepository().exists(newer.getTransactionData().getSignature()));
|
||||||
|
assertFalse(this.repository.getTransactionRepository().exists(older.getTransactionData().getSignature()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValid(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException {
|
||||||
|
Transaction transaction = buildPresenceTransaction(txGroupId, signer, timestamp, timestampSignature);
|
||||||
|
return transaction.isValidUnconfirmed() == ValidationResult.OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PresenceTransaction buildPresenceTransaction(int txGroupId, PrivateKeyAccount signer, long timestamp, byte[] timestampSignature) throws DataException {
|
||||||
|
int nonce = 0;
|
||||||
|
|
||||||
|
byte[] reference = signer.getLastReference();
|
||||||
|
byte[] creatorPublicKey = signer.getPublicKey();
|
||||||
|
long fee = 0L;
|
||||||
|
|
||||||
|
if (timestampSignature == null)
|
||||||
|
timestampSignature = this.signer.sign(Longs.toByteArray(timestamp));
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(timestamp, txGroupId, reference, creatorPublicKey, fee, null);
|
||||||
|
PresenceTransactionData transactionData = new PresenceTransactionData(baseTransactionData, nonce, PresenceType.TRADE_BOT, timestampSignature);
|
||||||
|
|
||||||
|
return new PresenceTransaction(this.repository, transactionData);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -4,7 +4,7 @@ import org.junit.Before;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.qortal.account.Account;
|
import org.qortal.account.Account;
|
||||||
import org.qortal.asset.Asset;
|
import org.qortal.asset.Asset;
|
||||||
import org.qortal.crosschain.BTCACCT;
|
import org.qortal.crosschain.BitcoinACCTv1;
|
||||||
import org.qortal.crypto.Crypto;
|
import org.qortal.crypto.Crypto;
|
||||||
import org.qortal.repository.DataException;
|
import org.qortal.repository.DataException;
|
||||||
import org.qortal.repository.Repository;
|
import org.qortal.repository.Repository;
|
||||||
@ -398,7 +398,7 @@ public class RepositoryTests extends Common {
|
|||||||
@Test
|
@Test
|
||||||
public void testAtLateral() {
|
public void testAtLateral() {
|
||||||
try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) {
|
try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) {
|
||||||
byte[] codeHash = BTCACCT.CODE_BYTES_HASH;
|
byte[] codeHash = BitcoinACCTv1.CODE_BYTES_HASH;
|
||||||
Boolean isFinished = null;
|
Boolean isFinished = null;
|
||||||
Integer dataByteOffset = null;
|
Integer dataByteOffset = null;
|
||||||
Long expectedValue = null;
|
Long expectedValue = null;
|
||||||
|
@ -4,10 +4,13 @@ import org.junit.Before;
|
|||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.qortal.api.ApiError;
|
import org.qortal.api.ApiError;
|
||||||
import org.qortal.api.resource.CrossChainResource;
|
import org.qortal.api.resource.CrossChainResource;
|
||||||
|
import org.qortal.crosschain.SupportedBlockchain;
|
||||||
import org.qortal.test.common.ApiCommon;
|
import org.qortal.test.common.ApiCommon;
|
||||||
|
|
||||||
public class CrossChainApiTests extends ApiCommon {
|
public class CrossChainApiTests extends ApiCommon {
|
||||||
|
|
||||||
|
private static final SupportedBlockchain SPECIFIC_BLOCKCHAIN = null;
|
||||||
|
|
||||||
private CrossChainResource crossChainResource;
|
private CrossChainResource crossChainResource;
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@ -17,12 +20,13 @@ public class CrossChainApiTests extends ApiCommon {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetTradeOffers() {
|
public void testGetTradeOffers() {
|
||||||
assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getTradeOffers(limit, offset, reverse));
|
assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getTradeOffers(SPECIFIC_BLOCKCHAIN, limit, offset, reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetCompletedTrades() {
|
public void testGetCompletedTrades() {
|
||||||
assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getCompletedTrades(System.currentTimeMillis() /*minimumTimestamp*/, limit, offset, reverse));
|
long minimumTimestamp = System.currentTimeMillis();
|
||||||
|
assertNoApiError((limit, offset, reverse) -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, minimumTimestamp, limit, offset, reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -31,8 +35,8 @@ public class CrossChainApiTests extends ApiCommon {
|
|||||||
Integer offset = null;
|
Integer offset = null;
|
||||||
Boolean reverse = null;
|
Boolean reverse = null;
|
||||||
|
|
||||||
assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(-1L /*minimumTimestamp*/, limit, offset, reverse));
|
assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, -1L /*minimumTimestamp*/, limit, offset, reverse));
|
||||||
assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(0L /*minimumTimestamp*/, limit, offset, reverse));
|
assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, 0L /*minimumTimestamp*/, limit, offset, reverse));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,327 +0,0 @@
|
|||||||
package org.qortal.test.apps;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.net.UnknownHostException;
|
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.security.NoSuchAlgorithmException;
|
|
||||||
import java.security.SecureRandom;
|
|
||||||
import java.security.Security;
|
|
||||||
import java.util.concurrent.CancellationException;
|
|
||||||
import java.util.concurrent.ExecutionException;
|
|
||||||
|
|
||||||
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.Transaction.SigHash;
|
|
||||||
import org.bitcoinj.core.TransactionBroadcast;
|
|
||||||
import org.bitcoinj.core.TransactionInput;
|
|
||||||
import org.bitcoinj.core.TransactionOutPoint;
|
|
||||||
import org.bitcoinj.crypto.TransactionSignature;
|
|
||||||
import org.bitcoinj.kits.WalletAppKit;
|
|
||||||
import org.bitcoinj.params.TestNet3Params;
|
|
||||||
import org.bitcoinj.script.Script;
|
|
||||||
import org.bitcoinj.script.Script.ScriptType;
|
|
||||||
import org.bitcoinj.script.ScriptBuilder;
|
|
||||||
import org.bitcoinj.script.ScriptChunk;
|
|
||||||
import org.bitcoinj.script.ScriptOpCodes;
|
|
||||||
import org.bitcoinj.wallet.WalletTransaction.Pool;
|
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
|
||||||
import com.google.common.primitives.Bytes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiator must be Qortal-chain so that initiator can send initial message to BTC P2SH then Qortal can scan for P2SH add send corresponding message to Qortal AT.
|
|
||||||
*
|
|
||||||
* Initiator (wants QORT, has BTC)
|
|
||||||
* Funds BTC P2SH address
|
|
||||||
*
|
|
||||||
* Responder (has QORT, wants BTC)
|
|
||||||
* Builds Qortal ACCT AT and funds it with QORT
|
|
||||||
*
|
|
||||||
* Initiator sends recipient+secret+script as input to BTC P2SH address, releasing BTC amount - fees to responder
|
|
||||||
*
|
|
||||||
* Qortal nodes scan for P2SH output, checks amount and recipient and if ok sends secret to Qortal ACCT AT
|
|
||||||
* (Or it's possible to feed BTC transaction details into Qortal AT so it can check them itself?)
|
|
||||||
*
|
|
||||||
* Qortal ACCT AT sends its QORT to initiator
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
public class BTCACCTTests {
|
|
||||||
|
|
||||||
private static final long TIMEOUT = 600L;
|
|
||||||
private static final Coin sendValue = Coin.valueOf(6_000L);
|
|
||||||
private static final Coin fee = Coin.valueOf(2_000L);
|
|
||||||
|
|
||||||
private static final byte[] senderPrivKeyBytes = HashCode.fromString("027fb5828c5e201eaf6de4cd3b0b340d16a191ef848cd691f35ef8f727358c9c").asBytes();
|
|
||||||
private static final byte[] recipientPrivKeyBytes = HashCode.fromString("ec199a4abc9d3bf024349e397535dfee9d287e174aeabae94237eb03a0118c03").asBytes();
|
|
||||||
|
|
||||||
// The following need to be updated manually
|
|
||||||
private static final String prevTxHash = "70ee97f20afea916c2e7b47f6abf3c75f97c4c2251b4625419406a2dd47d16b5";
|
|
||||||
private static final Coin prevTxBalance = Coin.valueOf(562_000L); // This is NOT the amount but the unspent balance
|
|
||||||
private static final long prevTxOutputIndex = 1L;
|
|
||||||
|
|
||||||
// For when we want to re-run
|
|
||||||
private static final byte[] prevSecret = HashCode.fromString("30a13291e350214bea5318f990b77bc11d2cb709f7c39859f248bef396961dcc").asBytes();
|
|
||||||
private static final long prevLockTime = 1539347892L;
|
|
||||||
private static final boolean usePreviousFundingTx = false;
|
|
||||||
|
|
||||||
private static final boolean doRefundNotRedeem = false;
|
|
||||||
|
|
||||||
public static void main(String[] args) throws NoSuchAlgorithmException, InsufficientMoneyException, InterruptedException, ExecutionException, UnknownHostException {
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
|
||||||
|
|
||||||
byte[] secret = new byte[32];
|
|
||||||
new SecureRandom().nextBytes(secret);
|
|
||||||
|
|
||||||
if (usePreviousFundingTx)
|
|
||||||
secret = prevSecret;
|
|
||||||
|
|
||||||
System.out.println("Secret: " + HashCode.fromBytes(secret).toString());
|
|
||||||
|
|
||||||
MessageDigest sha256Digester = MessageDigest.getInstance("SHA-256");
|
|
||||||
|
|
||||||
byte[] secretHash = sha256Digester.digest(secret);
|
|
||||||
String secretHashHex = HashCode.fromBytes(secretHash).toString();
|
|
||||||
|
|
||||||
System.out.println("SHA256(secret): " + secretHashHex);
|
|
||||||
|
|
||||||
NetworkParameters params = TestNet3Params.get();
|
|
||||||
// NetworkParameters params = RegTestParams.get();
|
|
||||||
System.out.println("Network: " + params.getId());
|
|
||||||
|
|
||||||
WalletAppKit kit = new WalletAppKit(params, new File("."), "btc-tests");
|
|
||||||
|
|
||||||
kit.setBlockingStartup(false);
|
|
||||||
kit.startAsync();
|
|
||||||
kit.awaitRunning();
|
|
||||||
|
|
||||||
long now = System.currentTimeMillis() / 1000L;
|
|
||||||
long lockTime = now + TIMEOUT;
|
|
||||||
|
|
||||||
if (usePreviousFundingTx)
|
|
||||||
lockTime = prevLockTime;
|
|
||||||
|
|
||||||
System.out.println("LockTime: " + lockTime);
|
|
||||||
|
|
||||||
ECKey senderKey = ECKey.fromPrivate(senderPrivKeyBytes);
|
|
||||||
kit.wallet().importKey(senderKey);
|
|
||||||
ECKey recipientKey = ECKey.fromPrivate(recipientPrivKeyBytes);
|
|
||||||
kit.wallet().importKey(recipientKey);
|
|
||||||
|
|
||||||
byte[] senderPubKey = senderKey.getPubKey();
|
|
||||||
System.out.println("Sender address: " + Address.fromKey(params, senderKey, ScriptType.P2PKH).toString());
|
|
||||||
System.out.println("Sender pubkey: " + HashCode.fromBytes(senderPubKey).toString());
|
|
||||||
|
|
||||||
byte[] recipientPubKey = recipientKey.getPubKey();
|
|
||||||
System.out.println("Recipient address: " + Address.fromKey(params, recipientKey, ScriptType.P2PKH).toString());
|
|
||||||
System.out.println("Recipient pubkey: " + HashCode.fromBytes(recipientPubKey).toString());
|
|
||||||
|
|
||||||
byte[] redeemScriptBytes = buildRedeemScript(secret, senderPubKey, recipientPubKey, lockTime);
|
|
||||||
System.out.println("Redeem script: " + HashCode.fromBytes(redeemScriptBytes).toString());
|
|
||||||
|
|
||||||
byte[] redeemScriptHash = hash160(redeemScriptBytes);
|
|
||||||
|
|
||||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
|
||||||
System.out.println("P2SH address: " + p2shAddress.toString());
|
|
||||||
|
|
||||||
// Send amount to P2SH address
|
|
||||||
Transaction fundingTransaction = buildFundingTransaction(params, Sha256Hash.wrap(prevTxHash), prevTxOutputIndex, prevTxBalance, senderKey,
|
|
||||||
sendValue.add(fee), redeemScriptHash);
|
|
||||||
|
|
||||||
System.out.println("Sending " + sendValue.add(fee).toPlainString() + " to " + p2shAddress.toString());
|
|
||||||
if (!usePreviousFundingTx)
|
|
||||||
broadcastWithConfirmation(kit, fundingTransaction);
|
|
||||||
|
|
||||||
if (doRefundNotRedeem) {
|
|
||||||
// Refund
|
|
||||||
System.out.println("Refunding " + sendValue.toPlainString() + " back to " + Address.fromKey(params, senderKey, ScriptType.P2PKH).toString());
|
|
||||||
|
|
||||||
now = System.currentTimeMillis() / 1000L;
|
|
||||||
long refundLockTime = now - 60 * 30; // 30 minutes in the past, needs to before 'now' and before "median block time" (median of previous 11 block
|
|
||||||
// timestamps)
|
|
||||||
if (refundLockTime < lockTime)
|
|
||||||
throw new RuntimeException("Too soon to refund");
|
|
||||||
|
|
||||||
TransactionOutPoint fundingOutPoint = new TransactionOutPoint(params, 0, fundingTransaction);
|
|
||||||
Transaction refundTransaction = buildRefundTransaction(params, fundingOutPoint, senderKey, sendValue, redeemScriptBytes, refundLockTime);
|
|
||||||
broadcastWithConfirmation(kit, refundTransaction);
|
|
||||||
} else {
|
|
||||||
// Redeem
|
|
||||||
System.out.println("Redeeming " + sendValue.toPlainString() + " to " + Address.fromKey(params, recipientKey, ScriptType.P2PKH).toString());
|
|
||||||
|
|
||||||
TransactionOutPoint fundingOutPoint = new TransactionOutPoint(params, 0, fundingTransaction);
|
|
||||||
Transaction redeemTransaction = buildRedeemTransaction(params, fundingOutPoint, recipientKey, sendValue, secret, redeemScriptBytes);
|
|
||||||
broadcastWithConfirmation(kit, redeemTransaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
kit.wallet().cleanup();
|
|
||||||
|
|
||||||
for (Transaction transaction : kit.wallet().getTransactionPool(Pool.PENDING).values())
|
|
||||||
System.out.println("Pending tx: " + transaction.getTxId().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final byte[] redeemScript1 = HashCode.fromString("76a820").asBytes();
|
|
||||||
private static final byte[] redeemScript2 = HashCode.fromString("87637576a914").asBytes();
|
|
||||||
private static final byte[] redeemScript3 = HashCode.fromString("88ac6704").asBytes();
|
|
||||||
private static final byte[] redeemScript4 = HashCode.fromString("b17576a914").asBytes();
|
|
||||||
private static final byte[] redeemScript5 = HashCode.fromString("88ac68").asBytes();
|
|
||||||
|
|
||||||
private static byte[] buildRedeemScript(byte[] secret, byte[] senderPubKey, byte[] recipientPubKey, long lockTime) {
|
|
||||||
try {
|
|
||||||
MessageDigest sha256Digester = MessageDigest.getInstance("SHA-256");
|
|
||||||
|
|
||||||
byte[] secretHash = sha256Digester.digest(secret);
|
|
||||||
byte[] senderPubKeyHash = hash160(senderPubKey);
|
|
||||||
byte[] recipientPubKeyHash = hash160(recipientPubKey);
|
|
||||||
|
|
||||||
return Bytes.concat(redeemScript1, secretHash, redeemScript2, recipientPubKeyHash, redeemScript3, toLEByteArray((int) (lockTime & 0xffffffffL)),
|
|
||||||
redeemScript4, senderPubKeyHash, redeemScript5);
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new RuntimeException("Message digest unsupported", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] hash160(byte[] input) {
|
|
||||||
try {
|
|
||||||
MessageDigest rmd160Digester = MessageDigest.getInstance("RIPEMD160");
|
|
||||||
MessageDigest sha256Digester = MessageDigest.getInstance("SHA-256");
|
|
||||||
|
|
||||||
return rmd160Digester.digest(sha256Digester.digest(input));
|
|
||||||
} catch (NoSuchAlgorithmException e) {
|
|
||||||
throw new RuntimeException("Message digest unsupported", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Transaction buildFundingTransaction(NetworkParameters params, Sha256Hash prevTxHash, long outputIndex, Coin balance, ECKey sigKey, Coin value,
|
|
||||||
byte[] redeemScriptHash) {
|
|
||||||
Transaction fundingTransaction = new Transaction(params);
|
|
||||||
|
|
||||||
// Outputs (needed before input so inputs can be signed)
|
|
||||||
// Fixed amount to P2SH
|
|
||||||
fundingTransaction.addOutput(value, ScriptBuilder.createP2SHOutputScript(redeemScriptHash));
|
|
||||||
// Change to sender
|
|
||||||
fundingTransaction.addOutput(balance.minus(value).minus(fee), ScriptBuilder.createOutputScript(Address.fromKey(params, sigKey, ScriptType.P2PKH)));
|
|
||||||
|
|
||||||
// Input
|
|
||||||
// We create fake "to address" scriptPubKey for prev tx so our spending input is P2PKH type
|
|
||||||
Script fakeScriptPubKey = ScriptBuilder.createOutputScript(Address.fromKey(params, sigKey, ScriptType.P2PKH));
|
|
||||||
TransactionOutPoint prevOut = new TransactionOutPoint(params, outputIndex, prevTxHash);
|
|
||||||
fundingTransaction.addSignedInput(prevOut, fakeScriptPubKey, sigKey);
|
|
||||||
|
|
||||||
return fundingTransaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Transaction buildRedeemTransaction(NetworkParameters params, TransactionOutPoint fundingOutPoint, ECKey recipientKey, Coin value, byte[] secret,
|
|
||||||
byte[] redeemScriptBytes) {
|
|
||||||
Transaction redeemTransaction = new Transaction(params);
|
|
||||||
redeemTransaction.setVersion(2);
|
|
||||||
|
|
||||||
// Outputs
|
|
||||||
redeemTransaction.addOutput(value, ScriptBuilder.createOutputScript(Address.fromKey(params, recipientKey, ScriptType.P2PKH)));
|
|
||||||
|
|
||||||
// Input
|
|
||||||
byte[] recipientPubKey = recipientKey.getPubKey();
|
|
||||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
|
||||||
scriptBuilder.addChunk(new ScriptChunk(recipientPubKey.length, recipientPubKey));
|
|
||||||
scriptBuilder.addChunk(new ScriptChunk(secret.length, secret));
|
|
||||||
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
|
|
||||||
byte[] scriptPubKey = scriptBuilder.build().getProgram();
|
|
||||||
|
|
||||||
TransactionInput input = new TransactionInput(params, null, scriptPubKey, fundingOutPoint);
|
|
||||||
input.setSequenceNumber(0xffffffffL); // Final
|
|
||||||
redeemTransaction.addInput(input);
|
|
||||||
|
|
||||||
// Generate transaction signature for input
|
|
||||||
boolean anyoneCanPay = false;
|
|
||||||
Sha256Hash hash = redeemTransaction.hashForSignature(0, redeemScriptBytes, SigHash.ALL, anyoneCanPay);
|
|
||||||
System.out.println("redeem transaction's input hash: " + hash.toString());
|
|
||||||
|
|
||||||
ECKey.ECDSASignature ecSig = recipientKey.sign(hash);
|
|
||||||
TransactionSignature txSig = new TransactionSignature(ecSig, SigHash.ALL, anyoneCanPay);
|
|
||||||
byte[] txSigBytes = txSig.encodeToBitcoin();
|
|
||||||
System.out.println("redeem transaction's signature: " + HashCode.fromBytes(txSigBytes).toString());
|
|
||||||
|
|
||||||
// Prepend signature to input
|
|
||||||
scriptBuilder.addChunk(0, new ScriptChunk(txSigBytes.length, txSigBytes));
|
|
||||||
input.setScriptSig(scriptBuilder.build());
|
|
||||||
|
|
||||||
return redeemTransaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Transaction buildRefundTransaction(NetworkParameters params, TransactionOutPoint fundingOutPoint, ECKey senderKey, Coin value,
|
|
||||||
byte[] redeemScriptBytes, long lockTime) {
|
|
||||||
Transaction refundTransaction = new Transaction(params);
|
|
||||||
refundTransaction.setVersion(2);
|
|
||||||
|
|
||||||
// Outputs
|
|
||||||
refundTransaction.addOutput(value, ScriptBuilder.createOutputScript(Address.fromKey(params, senderKey, ScriptType.P2PKH)));
|
|
||||||
|
|
||||||
// Input
|
|
||||||
byte[] recipientPubKey = senderKey.getPubKey();
|
|
||||||
ScriptBuilder scriptBuilder = new ScriptBuilder();
|
|
||||||
scriptBuilder.addChunk(new ScriptChunk(recipientPubKey.length, recipientPubKey));
|
|
||||||
scriptBuilder.addChunk(new ScriptChunk(ScriptOpCodes.OP_PUSHDATA1, redeemScriptBytes));
|
|
||||||
byte[] scriptPubKey = scriptBuilder.build().getProgram();
|
|
||||||
|
|
||||||
TransactionInput input = new TransactionInput(params, null, scriptPubKey, fundingOutPoint);
|
|
||||||
input.setSequenceNumber(0);
|
|
||||||
refundTransaction.addInput(input);
|
|
||||||
|
|
||||||
// Set locktime after input but before input signature is generated
|
|
||||||
refundTransaction.setLockTime(lockTime);
|
|
||||||
|
|
||||||
// Generate transaction signature for input
|
|
||||||
boolean anyoneCanPay = false;
|
|
||||||
Sha256Hash hash = refundTransaction.hashForSignature(0, redeemScriptBytes, SigHash.ALL, anyoneCanPay);
|
|
||||||
System.out.println("refund transaction's input hash: " + hash.toString());
|
|
||||||
|
|
||||||
ECKey.ECDSASignature ecSig = senderKey.sign(hash);
|
|
||||||
TransactionSignature txSig = new TransactionSignature(ecSig, SigHash.ALL, anyoneCanPay);
|
|
||||||
byte[] txSigBytes = txSig.encodeToBitcoin();
|
|
||||||
System.out.println("refund transaction's signature: " + HashCode.fromBytes(txSigBytes).toString());
|
|
||||||
|
|
||||||
// Prepend signature to input
|
|
||||||
scriptBuilder.addChunk(0, new ScriptChunk(txSigBytes.length, txSigBytes));
|
|
||||||
input.setScriptSig(scriptBuilder.build());
|
|
||||||
|
|
||||||
return refundTransaction;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void broadcastWithConfirmation(WalletAppKit kit, Transaction transaction) {
|
|
||||||
System.out.println("Broadcasting tx: " + transaction.getTxId().toString());
|
|
||||||
System.out.println("TX hex: " + HashCode.fromBytes(transaction.bitcoinSerialize()).toString());
|
|
||||||
|
|
||||||
System.out.println("Number of connected peers: " + kit.peerGroup().numConnectedPeers());
|
|
||||||
TransactionBroadcast txBroadcast = kit.peerGroup().broadcastTransaction(transaction);
|
|
||||||
|
|
||||||
try {
|
|
||||||
txBroadcast.future().get();
|
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
|
||||||
throw new RuntimeException("Transaction broadcast failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait for confirmation
|
|
||||||
System.out.println("Waiting for confirmation of tx: " + transaction.getTxId().toString());
|
|
||||||
|
|
||||||
try {
|
|
||||||
transaction.getConfidence().getDepthFuture(1).get();
|
|
||||||
} catch (CancellationException | ExecutionException | InterruptedException e) {
|
|
||||||
throw new RuntimeException("Transaction confirmation failed", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
System.out.println("Confirmed tx: " + transaction.getTxId().toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert int to little-endian byte array */
|
|
||||||
private static byte[] toLEByteArray(int value) {
|
|
||||||
return new byte[] { (byte) (value), (byte) (value >> 8), (byte) (value >> 16), (byte) (value >> 24) };
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,126 +0,0 @@
|
|||||||
package org.qortal.test.btcacct;
|
|
||||||
|
|
||||||
import java.security.Security;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
|
|
||||||
import org.bitcoinj.core.Address;
|
|
||||||
import org.bitcoinj.core.Coin;
|
|
||||||
import org.bitcoinj.core.LegacyAddress;
|
|
||||||
import org.bitcoinj.core.NetworkParameters;
|
|
||||||
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.BTCP2SH;
|
|
||||||
import org.qortal.crypto.Crypto;
|
|
||||||
import org.qortal.repository.DataException;
|
|
||||||
import org.qortal.repository.Repository;
|
|
||||||
import org.qortal.repository.RepositoryFactory;
|
|
||||||
import org.qortal.repository.RepositoryManager;
|
|
||||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
|
||||||
import org.qortal.settings.Settings;
|
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
|
||||||
|
|
||||||
public class BuildP2SH {
|
|
||||||
|
|
||||||
private static void usage(String error) {
|
|
||||||
if (error != null)
|
|
||||||
System.err.println(error);
|
|
||||||
|
|
||||||
System.err.println(String.format("usage: BuildP2SH <refund-BTC-P2PKH> <BTC-amount> <redeem-BTC-P2PKH> <HASH160-of-secret> <locktime> (<BTC-redeem/refund-fee>)"));
|
|
||||||
System.err.println(String.format("example: BuildP2SH "
|
|
||||||
+ "mrTDPdM15cFWJC4g223BXX5snicfVJBx6M \\\n"
|
|
||||||
+ "\t0.00008642 \\\n"
|
|
||||||
+ "\tn2N5VKrzq39nmuefZwp3wBiF4icdXX2B6o \\\n"
|
|
||||||
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
|
||||||
+ "\t1585920000"));
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
if (args.length < 5 || args.length > 6)
|
|
||||||
usage(null);
|
|
||||||
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
|
||||||
Settings.fileInstance("settings-test.json");
|
|
||||||
|
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
NetworkParameters params = btc.getNetworkParameters();
|
|
||||||
|
|
||||||
Address refundBitcoinAddress = null;
|
|
||||||
Coin bitcoinAmount = null;
|
|
||||||
Address redeemBitcoinAddress = null;
|
|
||||||
byte[] secretHash = null;
|
|
||||||
int lockTime = 0;
|
|
||||||
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
|
|
||||||
|
|
||||||
int argIndex = 0;
|
|
||||||
try {
|
|
||||||
refundBitcoinAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
|
|
||||||
usage("Refund BTC address must be in P2PKH form");
|
|
||||||
|
|
||||||
bitcoinAmount = Coin.parseCoin(args[argIndex++]);
|
|
||||||
|
|
||||||
redeemBitcoinAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
|
|
||||||
usage("Redeem BTC address must be in P2PKH form");
|
|
||||||
|
|
||||||
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
|
|
||||||
if (secretHash.length != 20)
|
|
||||||
usage("Hash of secret must be 20 bytes");
|
|
||||||
|
|
||||||
lockTime = Integer.parseInt(args[argIndex++]);
|
|
||||||
int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L);
|
|
||||||
if (refundTimeoutDelay < 600 || refundTimeoutDelay > 30 * 24 * 60 * 60)
|
|
||||||
usage("Locktime (seconds) should be at between 10 minutes and 1 month from now");
|
|
||||||
|
|
||||||
if (args.length > argIndex)
|
|
||||||
bitcoinFee = Coin.parseCoin(args[argIndex++]);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
|
||||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
|
||||||
} catch (DataException e) {
|
|
||||||
throw new RuntimeException("Repository startup issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
|
||||||
System.out.println("Confirm the following is correct based on the info you've given:");
|
|
||||||
|
|
||||||
System.out.println(String.format("Refund Bitcoin address: %s", refundBitcoinAddress));
|
|
||||||
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(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)));
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
|
||||||
System.out.println(String.format("P2SH address: %s", p2shAddress));
|
|
||||||
|
|
||||||
bitcoinAmount = bitcoinAmount.add(bitcoinFee);
|
|
||||||
|
|
||||||
// Fund P2SH
|
|
||||||
System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)",
|
|
||||||
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) {
|
|
||||||
throw new RuntimeException("Repository issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,170 +0,0 @@
|
|||||||
package org.qortal.test.btcacct;
|
|
||||||
|
|
||||||
import java.security.Security;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.bitcoinj.core.Address;
|
|
||||||
import org.bitcoinj.core.Coin;
|
|
||||||
import org.bitcoinj.core.LegacyAddress;
|
|
||||||
import org.bitcoinj.core.NetworkParameters;
|
|
||||||
import org.bitcoinj.core.TransactionOutput;
|
|
||||||
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.BTCP2SH;
|
|
||||||
import org.qortal.crosschain.BitcoinException;
|
|
||||||
import org.qortal.crypto.Crypto;
|
|
||||||
import org.qortal.repository.DataException;
|
|
||||||
import org.qortal.repository.Repository;
|
|
||||||
import org.qortal.repository.RepositoryFactory;
|
|
||||||
import org.qortal.repository.RepositoryManager;
|
|
||||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
|
||||||
import org.qortal.settings.Settings;
|
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
|
||||||
|
|
||||||
public class CheckP2SH {
|
|
||||||
|
|
||||||
private static void usage(String error) {
|
|
||||||
if (error != null)
|
|
||||||
System.err.println(error);
|
|
||||||
|
|
||||||
System.err.println(String.format("usage: CheckP2SH <P2SH-address> <refund-BTC-P2PKH> <BTC-amount> <redeem-BTC-P2PKH> <HASH160-of-secret> <locktime> (<BTC-redeem/refund-fee>)"));
|
|
||||||
System.err.println(String.format("example: CheckP2SH "
|
|
||||||
+ "2NEZboTLhBDPPQciR7sExBhy3TsDi7wV3Cv \\\n"
|
|
||||||
+ "mrTDPdM15cFWJC4g223BXX5snicfVJBx6M \\\n"
|
|
||||||
+ "\t0.00008642 \\\n"
|
|
||||||
+ "\tn2N5VKrzq39nmuefZwp3wBiF4icdXX2B6o \\\n"
|
|
||||||
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
|
||||||
+ "\t1585920000"));
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
if (args.length < 6 || args.length > 7)
|
|
||||||
usage(null);
|
|
||||||
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
|
||||||
Settings.fileInstance("settings-test.json");
|
|
||||||
|
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
NetworkParameters params = btc.getNetworkParameters();
|
|
||||||
|
|
||||||
Address p2shAddress = null;
|
|
||||||
Address refundBitcoinAddress = null;
|
|
||||||
Coin bitcoinAmount = null;
|
|
||||||
Address redeemBitcoinAddress = null;
|
|
||||||
byte[] secretHash = null;
|
|
||||||
int lockTime = 0;
|
|
||||||
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
|
|
||||||
|
|
||||||
int argIndex = 0;
|
|
||||||
try {
|
|
||||||
p2shAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
|
|
||||||
usage("P2SH address invalid");
|
|
||||||
|
|
||||||
refundBitcoinAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
|
|
||||||
usage("Refund BTC address must be in P2PKH form");
|
|
||||||
|
|
||||||
bitcoinAmount = Coin.parseCoin(args[argIndex++]);
|
|
||||||
|
|
||||||
redeemBitcoinAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
|
|
||||||
usage("Redeem BTC address must be in P2PKH form");
|
|
||||||
|
|
||||||
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
|
|
||||||
if (secretHash.length != 20)
|
|
||||||
usage("Hash of secret must be 20 bytes");
|
|
||||||
|
|
||||||
lockTime = Integer.parseInt(args[argIndex++]);
|
|
||||||
int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L);
|
|
||||||
if (refundTimeoutDelay < 600 || refundTimeoutDelay > 7 * 24 * 60 * 60)
|
|
||||||
usage("Locktime (seconds) should be at between 10 minutes and 1 week from now");
|
|
||||||
|
|
||||||
if (args.length > argIndex)
|
|
||||||
bitcoinFee = Coin.parseCoin(args[argIndex++]);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
|
||||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
|
||||||
} catch (DataException e) {
|
|
||||||
throw new RuntimeException("Repository startup issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
|
||||||
System.out.println("Confirm the following is correct based on the info you've given:");
|
|
||||||
|
|
||||||
System.out.println(String.format("Refund Bitcoin address: %s", redeemBitcoinAddress));
|
|
||||||
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(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)));
|
|
||||||
|
|
||||||
System.out.println(String.format("P2SH address: %s", p2shAddress));
|
|
||||||
|
|
||||||
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);
|
|
||||||
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
|
||||||
|
|
||||||
if (!derivedP2shAddress.equals(p2shAddress)) {
|
|
||||||
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
bitcoinAmount = bitcoinAmount.add(bitcoinFee);
|
|
||||||
|
|
||||||
long medianBlockTime = BTC.getInstance().getMedianBlockTime();
|
|
||||||
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
|
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
if (now < medianBlockTime * 1000L)
|
|
||||||
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
|
|
||||||
long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString());
|
|
||||||
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<TransactionOutput> fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
|
|
||||||
if (fundingOutputs == null) {
|
|
||||||
System.err.println(String.format("Can't find outputs for P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
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(fundingOutput.getValue())));
|
|
||||||
|
|
||||||
if (fundingOutputs.isEmpty()) {
|
|
||||||
System.err.println(String.format("Can't redeem spent/unfunded P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fundingOutputs.size() != 1) {
|
|
||||||
System.err.println(String.format("Expecting only one unspent output for P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
} catch (DataException e) {
|
|
||||||
System.err.println("Repository issue: " + e.getMessage());
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
System.err.println("Bitcoin issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,9 +0,0 @@
|
|||||||
package org.qortal.test.btcacct;
|
|
||||||
|
|
||||||
import org.bitcoinj.core.Coin;
|
|
||||||
|
|
||||||
public abstract class Common {
|
|
||||||
|
|
||||||
public static final Coin DEFAULT_BTC_FEE = Coin.parseCoin("0.00001000");
|
|
||||||
|
|
||||||
}
|
|
@ -1,53 +0,0 @@
|
|||||||
package org.qortal.test.btcacct;
|
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
|
||||||
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.junit.After;
|
|
||||||
import org.junit.Before;
|
|
||||||
import org.junit.Test;
|
|
||||||
import org.qortal.crosschain.BTC;
|
|
||||||
import org.qortal.crosschain.BTCP2SH;
|
|
||||||
import org.qortal.crosschain.BitcoinException;
|
|
||||||
import org.qortal.repository.DataException;
|
|
||||||
import org.qortal.test.common.Common;
|
|
||||||
|
|
||||||
public class P2shTests extends Common {
|
|
||||||
|
|
||||||
@Before
|
|
||||||
public void beforeTest() throws DataException {
|
|
||||||
Common.useDefaultSettings(); // TestNet3
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
public void afterTest() {
|
|
||||||
BTC.resetForTesting();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testFindP2shSecret() throws BitcoinException {
|
|
||||||
// This actually exists on TEST3 but can take a while to fetch
|
|
||||||
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
|
||||||
|
|
||||||
List<byte[]> rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress);
|
|
||||||
|
|
||||||
byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
|
|
||||||
byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions);
|
|
||||||
|
|
||||||
assertNotNull(secret);
|
|
||||||
assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void testDetermineP2shStatus() throws BitcoinException {
|
|
||||||
// This actually exists on TEST3 but can take a while to fetch
|
|
||||||
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
|
||||||
|
|
||||||
BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddress, 1L);
|
|
||||||
|
|
||||||
System.out.println(String.format("P2SH %s status: %s", p2shAddress, p2shStatus.name()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,211 +0,0 @@
|
|||||||
package org.qortal.test.btcacct;
|
|
||||||
|
|
||||||
import java.security.Security;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
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.TransactionOutput;
|
|
||||||
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.BTCP2SH;
|
|
||||||
import org.qortal.crosschain.BitcoinException;
|
|
||||||
import org.qortal.crypto.Crypto;
|
|
||||||
import org.qortal.repository.DataException;
|
|
||||||
import org.qortal.repository.Repository;
|
|
||||||
import org.qortal.repository.RepositoryFactory;
|
|
||||||
import org.qortal.repository.RepositoryManager;
|
|
||||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
|
||||||
import org.qortal.settings.Settings;
|
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
|
||||||
|
|
||||||
public class Redeem {
|
|
||||||
|
|
||||||
static {
|
|
||||||
// This must go before any calls to LogManager/Logger
|
|
||||||
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void usage(String error) {
|
|
||||||
if (error != null)
|
|
||||||
System.err.println(error);
|
|
||||||
|
|
||||||
System.err.println(String.format("usage: Redeem <P2SH-address> <refund-BTC-P2PKH> <redeem-BTC-PRIVATE-key> <secret> <locktime> (<BTC-redeem/refund-fee>)"));
|
|
||||||
System.err.println(String.format("example: Redeem "
|
|
||||||
+ "2NEZboTLhBDPPQciR7sExBhy3TsDi7wV3Cv \\\n"
|
|
||||||
+ "\tmrTDPdM15cFWJC4g223BXX5snicfVJBx6M \\\n"
|
|
||||||
+ "\tec199a4abc9d3bf024349e397535dfee9d287e174aeabae94237eb03a0118c03 \\\n"
|
|
||||||
+ "\t5468697320737472696e672069732065786163746c7920333220627974657321 \\\n"
|
|
||||||
+ "\t1585920000"));
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
if (args.length < 5 || args.length > 6)
|
|
||||||
usage(null);
|
|
||||||
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
|
||||||
Settings.fileInstance("settings-test.json");
|
|
||||||
|
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
NetworkParameters params = btc.getNetworkParameters();
|
|
||||||
|
|
||||||
Address p2shAddress = null;
|
|
||||||
Address refundBitcoinAddress = null;
|
|
||||||
byte[] redeemPrivateKey = null;
|
|
||||||
byte[] secret = null;
|
|
||||||
int lockTime = 0;
|
|
||||||
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
|
|
||||||
|
|
||||||
int argIndex = 0;
|
|
||||||
try {
|
|
||||||
p2shAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
|
|
||||||
usage("P2SH address invalid");
|
|
||||||
|
|
||||||
refundBitcoinAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (refundBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
|
|
||||||
usage("Refund BTC address must be in P2PKH form");
|
|
||||||
|
|
||||||
redeemPrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
|
|
||||||
// Auto-trim
|
|
||||||
if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38)
|
|
||||||
redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33);
|
|
||||||
if (redeemPrivateKey.length != 32)
|
|
||||||
usage("Redeem private key must be 32 bytes");
|
|
||||||
|
|
||||||
secret = HashCode.fromString(args[argIndex++]).asBytes();
|
|
||||||
if (secret.length == 0)
|
|
||||||
usage("Invalid secret bytes");
|
|
||||||
|
|
||||||
lockTime = Integer.parseInt(args[argIndex++]);
|
|
||||||
|
|
||||||
if (args.length > argIndex)
|
|
||||||
bitcoinFee = Coin.parseCoin(args[argIndex++]);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
|
||||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
|
||||||
} catch (DataException e) {
|
|
||||||
throw new RuntimeException("Repository startup issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
|
||||||
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(bitcoinFee)));
|
|
||||||
System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
|
|
||||||
|
|
||||||
// New/derived info
|
|
||||||
|
|
||||||
byte[] secretHash = Crypto.hash160(secret);
|
|
||||||
System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash)));
|
|
||||||
|
|
||||||
ECKey redeemKey = ECKey.fromPrivate(redeemPrivateKey);
|
|
||||||
Address redeemAddress = Address.fromKey(params, redeemKey, ScriptType.P2PKH);
|
|
||||||
System.out.println(String.format("Redeem recipient (PKH): %s (%s)", redeemAddress, HashCode.fromBytes(redeemAddress.getHash())));
|
|
||||||
|
|
||||||
System.out.println(String.format("P2SH address: %s", p2shAddress));
|
|
||||||
|
|
||||||
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);
|
|
||||||
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
|
||||||
|
|
||||||
if (!derivedP2shAddress.equals(p2shAddress)) {
|
|
||||||
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some checks
|
|
||||||
|
|
||||||
System.out.println("\nProcessing:");
|
|
||||||
|
|
||||||
long medianBlockTime;
|
|
||||||
try {
|
|
||||||
medianBlockTime = BTC.getInstance().getMedianBlockTime();
|
|
||||||
} catch (BitcoinException e1) {
|
|
||||||
System.err.println("Unable to determine median block time");
|
|
||||||
System.exit(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
|
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
if (now < medianBlockTime * 1000L) {
|
|
||||||
System.err.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)));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check P2SH is funded
|
|
||||||
long p2shBalance;
|
|
||||||
try {
|
|
||||||
p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString());
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress));
|
|
||||||
System.exit(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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<TransactionOutput> fundingOutputs;
|
|
||||||
try {
|
|
||||||
fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
System.err.println(String.format("Can't find outputs for P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(fundingOutput.getValue())));
|
|
||||||
|
|
||||||
if (fundingOutputs.isEmpty()) {
|
|
||||||
System.err.println(String.format("Can't redeem spent/unfunded P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fundingOutputs.size() != 1) {
|
|
||||||
System.err.println(String.format("Expecting only one unspent output for P2SH"));
|
|
||||||
// No longer fatal
|
|
||||||
}
|
|
||||||
|
|
||||||
for (TransactionOutput fundingOutput : fundingOutputs)
|
|
||||||
System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex()));
|
|
||||||
|
|
||||||
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, redeemAddress.getHash());
|
|
||||||
|
|
||||||
byte[] redeemBytes = redeemTransaction.bitcoinSerialize();
|
|
||||||
|
|
||||||
System.out.println(String.format("\nLoad this transaction into your wallet and broadcast:\n%s\n", HashCode.fromBytes(redeemBytes).toString()));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
usage(String.format("Number format exception: %s", e.getMessage()));
|
|
||||||
} catch (DataException e) {
|
|
||||||
throw new RuntimeException("Repository issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,215 +0,0 @@
|
|||||||
package org.qortal.test.btcacct;
|
|
||||||
|
|
||||||
import java.security.Security;
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.Arrays;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
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.TransactionOutput;
|
|
||||||
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.BTCP2SH;
|
|
||||||
import org.qortal.crosschain.BitcoinException;
|
|
||||||
import org.qortal.crypto.Crypto;
|
|
||||||
import org.qortal.repository.DataException;
|
|
||||||
import org.qortal.repository.Repository;
|
|
||||||
import org.qortal.repository.RepositoryFactory;
|
|
||||||
import org.qortal.repository.RepositoryManager;
|
|
||||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
|
||||||
import org.qortal.settings.Settings;
|
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
|
||||||
|
|
||||||
public class Refund {
|
|
||||||
|
|
||||||
static {
|
|
||||||
// This must go before any calls to LogManager/Logger
|
|
||||||
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void usage(String error) {
|
|
||||||
if (error != null)
|
|
||||||
System.err.println(error);
|
|
||||||
|
|
||||||
System.err.println(String.format("usage: Refund <P2SH-address> <refund-BTC-PRIVATE-KEY> <redeem-BTC-P2PKH> <HASH160-of-secret> <locktime> (<BTC-redeem/refund-fee>)"));
|
|
||||||
System.err.println(String.format("example: Refund "
|
|
||||||
+ "2NEZboTLhBDPPQciR7sExBhy3TsDi7wV3Cv \\\n"
|
|
||||||
+ "\tef027fb5828c5e201eaf6de4cd3b0b340d16a191ef848cd691f35ef8f727358c9c01b576fb7e \\\n"
|
|
||||||
+ "\tn2N5VKrzq39nmuefZwp3wBiF4icdXX2B6o \\\n"
|
|
||||||
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
|
||||||
+ "\t1585920000"));
|
|
||||||
System.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
if (args.length < 5 || args.length > 6)
|
|
||||||
usage(null);
|
|
||||||
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
|
||||||
Settings.fileInstance("settings-test.json");
|
|
||||||
|
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
NetworkParameters params = btc.getNetworkParameters();
|
|
||||||
|
|
||||||
Address p2shAddress = null;
|
|
||||||
byte[] refundPrivateKey = null;
|
|
||||||
Address redeemBitcoinAddress = null;
|
|
||||||
byte[] secretHash = null;
|
|
||||||
int lockTime = 0;
|
|
||||||
Coin bitcoinFee = Common.DEFAULT_BTC_FEE;
|
|
||||||
|
|
||||||
int argIndex = 0;
|
|
||||||
try {
|
|
||||||
p2shAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
|
|
||||||
usage("P2SH address invalid");
|
|
||||||
|
|
||||||
refundPrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
|
|
||||||
// Auto-trim
|
|
||||||
if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38)
|
|
||||||
refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33);
|
|
||||||
if (refundPrivateKey.length != 32)
|
|
||||||
usage("Refund private key must be 32 bytes");
|
|
||||||
|
|
||||||
redeemBitcoinAddress = Address.fromString(params, args[argIndex++]);
|
|
||||||
if (redeemBitcoinAddress.getOutputScriptType() != ScriptType.P2PKH)
|
|
||||||
usage("Their BTC address must be in P2PKH form");
|
|
||||||
|
|
||||||
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
|
|
||||||
if (secretHash.length != 20)
|
|
||||||
usage("HASH160 of secret must be 20 bytes");
|
|
||||||
|
|
||||||
lockTime = Integer.parseInt(args[argIndex++]);
|
|
||||||
|
|
||||||
if (args.length > argIndex)
|
|
||||||
bitcoinFee = Coin.parseCoin(args[argIndex++]);
|
|
||||||
} catch (IllegalArgumentException e) {
|
|
||||||
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
|
||||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
|
||||||
} catch (DataException e) {
|
|
||||||
throw new RuntimeException("Repository startup issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
|
||||||
System.out.println("Confirm the following is correct based on the info you've given:");
|
|
||||||
|
|
||||||
System.out.println(String.format("Refund PRIVATE key: %s", HashCode.fromBytes(refundPrivateKey)));
|
|
||||||
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(bitcoinFee)));
|
|
||||||
|
|
||||||
// New/derived info
|
|
||||||
|
|
||||||
System.out.println("\nCHECKING info from other party:");
|
|
||||||
|
|
||||||
ECKey refundKey = ECKey.fromPrivate(refundPrivateKey);
|
|
||||||
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 = BTCP2SH.buildScript(refundAddress.getHash(), lockTime, redeemBitcoinAddress.getHash(), secretHash);
|
|
||||||
System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes)));
|
|
||||||
|
|
||||||
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
|
||||||
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
|
||||||
|
|
||||||
if (!derivedP2shAddress.equals(p2shAddress)) {
|
|
||||||
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some checks
|
|
||||||
|
|
||||||
System.out.println("\nProcessing:");
|
|
||||||
|
|
||||||
long medianBlockTime;
|
|
||||||
try {
|
|
||||||
medianBlockTime = BTC.getInstance().getMedianBlockTime();
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
System.err.println("Unable to determine median block time");
|
|
||||||
System.exit(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
|
|
||||||
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
if (now < medianBlockTime * 1000L) {
|
|
||||||
System.err.println(String.format("Too soon (%s) to refund based on median block time %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (now < lockTime * 1000L) {
|
|
||||||
System.err.println(String.format("Too soon (%s) to refund based on lockTime %s", LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC), LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC)));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check P2SH is funded
|
|
||||||
long p2shBalance;
|
|
||||||
try {
|
|
||||||
p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString());
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
System.err.println(String.format("Unable to check P2SH address %s balance", p2shAddress));
|
|
||||||
System.exit(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
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<TransactionOutput> fundingOutputs;
|
|
||||||
try {
|
|
||||||
fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString());
|
|
||||||
} catch (BitcoinException e) {
|
|
||||||
System.err.println(String.format("Can't find outputs for P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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(fundingOutput.getValue())));
|
|
||||||
|
|
||||||
if (fundingOutputs.isEmpty()) {
|
|
||||||
System.err.println(String.format("Can't refund spent/unfunded P2SH"));
|
|
||||||
System.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fundingOutputs.size() != 1) {
|
|
||||||
System.err.println(String.format("Expecting only one unspent output for P2SH"));
|
|
||||||
// No longer fatal
|
|
||||||
}
|
|
||||||
|
|
||||||
for (TransactionOutput fundingOutput : fundingOutputs)
|
|
||||||
System.out.println(String.format("Using output %s:%d for redeem", HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex()));
|
|
||||||
|
|
||||||
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 = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundKey.getPubKeyHash());
|
|
||||||
|
|
||||||
byte[] redeemBytes = redeemTransaction.bitcoinSerialize();
|
|
||||||
|
|
||||||
System.out.println(String.format("\nLoad this transaction into your wallet and broadcast:\n%s\n", HashCode.fromBytes(redeemBytes).toString()));
|
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
usage(String.format("Number format exception: %s", e.getMessage()));
|
|
||||||
} catch (DataException e) {
|
|
||||||
throw new RuntimeException("Repository issue: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,44 +1,46 @@
|
|||||||
package org.qortal.test.btcacct;
|
package org.qortal.test.crosschain;
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import org.bitcoinj.core.Transaction;
|
import org.bitcoinj.core.Transaction;
|
||||||
import org.bitcoinj.store.BlockStoreException;
|
import org.bitcoinj.store.BlockStoreException;
|
||||||
import org.junit.After;
|
import org.junit.After;
|
||||||
import org.junit.Before;
|
import org.junit.Before;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.qortal.crosschain.BTC;
|
import org.qortal.crosschain.Bitcoin;
|
||||||
import org.qortal.crosschain.BTCP2SH;
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
import org.qortal.crosschain.BitcoinException;
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
import org.qortal.repository.DataException;
|
import org.qortal.repository.DataException;
|
||||||
import org.qortal.test.common.Common;
|
import org.qortal.test.common.Common;
|
||||||
|
|
||||||
public class BtcTests extends Common {
|
public class BitcoinTests extends Common {
|
||||||
|
|
||||||
|
private Bitcoin bitcoin;
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
public void beforeTest() throws DataException {
|
public void beforeTest() throws DataException {
|
||||||
Common.useDefaultSettings(); // TestNet3
|
Common.useDefaultSettings(); // TestNet3
|
||||||
|
bitcoin = Bitcoin.getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
public void afterTest() {
|
public void afterTest() {
|
||||||
BTC.resetForTesting();
|
Bitcoin.resetForTesting();
|
||||||
|
bitcoin = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetMedianBlockTime() throws BlockStoreException, BitcoinException {
|
public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException {
|
||||||
System.out.println(String.format("Starting BTC instance..."));
|
System.out.println(String.format("Starting BTC instance..."));
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
System.out.println(String.format("BTC instance started"));
|
System.out.println(String.format("BTC instance started"));
|
||||||
|
|
||||||
long before = System.currentTimeMillis();
|
long before = System.currentTimeMillis();
|
||||||
System.out.println(String.format("Bitcoin median blocktime: %d", btc.getMedianBlockTime()));
|
System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime()));
|
||||||
long afterFirst = System.currentTimeMillis();
|
long afterFirst = System.currentTimeMillis();
|
||||||
|
|
||||||
System.out.println(String.format("Bitcoin median blocktime: %d", btc.getMedianBlockTime()));
|
System.out.println(String.format("Bitcoin median blocktime: %d", bitcoin.getMedianBlockTime()));
|
||||||
long afterSecond = System.currentTimeMillis();
|
long afterSecond = System.currentTimeMillis();
|
||||||
|
|
||||||
long firstPeriod = afterFirst - before;
|
long firstPeriod = afterFirst - before;
|
||||||
@ -51,14 +53,12 @@ public class BtcTests extends Common {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testFindP2shSecret() throws BitcoinException {
|
public void testFindHtlcSecret() throws ForeignBlockchainException {
|
||||||
// This actually exists on TEST3 but can take a while to fetch
|
// This actually exists on TEST3 but can take a while to fetch
|
||||||
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
|
|
||||||
List<byte[]> rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress);
|
|
||||||
|
|
||||||
byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
|
byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
|
||||||
byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions);
|
byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress);
|
||||||
|
|
||||||
assertNotNull(secret);
|
assertNotNull(secret);
|
||||||
assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret));
|
assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret));
|
||||||
@ -66,52 +66,46 @@ public class BtcTests extends Common {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testBuildSpend() {
|
public void testBuildSpend() {
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
|
|
||||||
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||||
|
|
||||||
String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
long amount = 1000L;
|
long amount = 1000L;
|
||||||
|
|
||||||
Transaction transaction = btc.buildSpend(xprv58, recipient, amount);
|
Transaction transaction = bitcoin.buildSpend(xprv58, recipient, amount);
|
||||||
assertNotNull(transaction);
|
assertNotNull(transaction);
|
||||||
|
|
||||||
// Check spent key caching doesn't affect outcome
|
// Check spent key caching doesn't affect outcome
|
||||||
|
|
||||||
transaction = btc.buildSpend(xprv58, recipient, amount);
|
transaction = bitcoin.buildSpend(xprv58, recipient, amount);
|
||||||
assertNotNull(transaction);
|
assertNotNull(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetWalletBalance() {
|
public void testGetWalletBalance() {
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
|
|
||||||
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||||
|
|
||||||
Long balance = btc.getWalletBalance(xprv58);
|
Long balance = bitcoin.getWalletBalance(xprv58);
|
||||||
|
|
||||||
assertNotNull(balance);
|
assertNotNull(balance);
|
||||||
|
|
||||||
System.out.println(BTC.format(balance));
|
System.out.println(bitcoin.format(balance));
|
||||||
|
|
||||||
// Check spent key caching doesn't affect outcome
|
// Check spent key caching doesn't affect outcome
|
||||||
|
|
||||||
Long repeatBalance = btc.getWalletBalance(xprv58);
|
Long repeatBalance = bitcoin.getWalletBalance(xprv58);
|
||||||
|
|
||||||
assertNotNull(repeatBalance);
|
assertNotNull(repeatBalance);
|
||||||
|
|
||||||
System.out.println(BTC.format(repeatBalance));
|
System.out.println(bitcoin.format(repeatBalance));
|
||||||
|
|
||||||
assertEquals(balance, repeatBalance);
|
assertEquals(balance, repeatBalance);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetUnusedReceiveAddress() throws BitcoinException {
|
public void testGetUnusedReceiveAddress() throws ForeignBlockchainException {
|
||||||
BTC btc = BTC.getInstance();
|
|
||||||
|
|
||||||
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||||
|
|
||||||
String address = btc.getUnusedReceiveAddress(xprv58);
|
String address = bitcoin.getUnusedReceiveAddress(xprv58);
|
||||||
|
|
||||||
assertNotNull(address);
|
assertNotNull(address);
|
||||||
|
|
@ -1,9 +1,11 @@
|
|||||||
package org.qortal.test.btcacct;
|
package org.qortal.test.crosschain;
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
import java.security.Security;
|
import java.security.Security;
|
||||||
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
import org.bitcoinj.core.Address;
|
import org.bitcoinj.core.Address;
|
||||||
import org.bitcoinj.params.TestNet3Params;
|
import org.bitcoinj.params.TestNet3Params;
|
||||||
@ -11,11 +13,13 @@ import org.bitcoinj.script.ScriptBuilder;
|
|||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
||||||
import org.junit.Test;
|
import org.junit.Test;
|
||||||
import org.qortal.crosschain.BitcoinException;
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
import org.qortal.crosschain.BitcoinTransaction;
|
import org.qortal.crosschain.BitcoinyTransaction;
|
||||||
import org.qortal.crosschain.ElectrumX;
|
import org.qortal.crosschain.ElectrumX;
|
||||||
import org.qortal.crosschain.TransactionHash;
|
import org.qortal.crosschain.TransactionHash;
|
||||||
import org.qortal.crosschain.UnspentOutput;
|
import org.qortal.crosschain.UnspentOutput;
|
||||||
|
import org.qortal.crosschain.Bitcoin.BitcoinNet;
|
||||||
|
import org.qortal.crosschain.ElectrumX.Server.ConnectionType;
|
||||||
import org.qortal.utils.BitTwiddling;
|
import org.qortal.utils.BitTwiddling;
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
import com.google.common.hash.HashCode;
|
||||||
@ -30,15 +34,25 @@ public class ElectrumXTests {
|
|||||||
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static final Map<ElectrumX.Server.ConnectionType, Integer> DEFAULT_ELECTRUMX_PORTS = new EnumMap<>(ElectrumX.Server.ConnectionType.class);
|
||||||
|
static {
|
||||||
|
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.TCP, 50001);
|
||||||
|
DEFAULT_ELECTRUMX_PORTS.put(ConnectionType.SSL, 50002);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ElectrumX getInstance() {
|
||||||
|
return new ElectrumX("Bitcoin-" + BitcoinNet.TEST3.name(), BitcoinNet.TEST3.getGenesisHash(), BitcoinNet.TEST3.getServers(), DEFAULT_ELECTRUMX_PORTS);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testInstance() {
|
public void testInstance() {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
assertNotNull(electrumX);
|
assertNotNull(electrumX);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetCurrentHeight() throws BitcoinException {
|
public void testGetCurrentHeight() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
int height = electrumX.getCurrentHeight();
|
int height = electrumX.getCurrentHeight();
|
||||||
|
|
||||||
@ -48,10 +62,10 @@ public class ElectrumXTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testInvalidRequest() {
|
public void testInvalidRequest() {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
try {
|
try {
|
||||||
electrumX.getBlockHeaders(-1, -1);
|
electrumX.getRawBlockHeaders(-1, -1);
|
||||||
} catch (BitcoinException e) {
|
} catch (ForeignBlockchainException e) {
|
||||||
// Should throw due to negative start block height
|
// Should throw due to negative start block height
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -60,13 +74,13 @@ public class ElectrumXTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetRecentBlocks() throws BitcoinException {
|
public void testGetRecentBlocks() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
int height = electrumX.getCurrentHeight();
|
int height = electrumX.getCurrentHeight();
|
||||||
assertTrue(height > 10000);
|
assertTrue(height > 10000);
|
||||||
|
|
||||||
List<byte[]> recentBlockHeaders = electrumX.getBlockHeaders(height - 11, 11);
|
List<byte[]> recentBlockHeaders = electrumX.getRawBlockHeaders(height - 11, 11);
|
||||||
|
|
||||||
System.out.println(String.format("Returned %d recent blocks", recentBlockHeaders.size()));
|
System.out.println(String.format("Returned %d recent blocks", recentBlockHeaders.size()));
|
||||||
for (int i = 0; i < recentBlockHeaders.size(); ++i) {
|
for (int i = 0; i < recentBlockHeaders.size(); ++i) {
|
||||||
@ -80,8 +94,8 @@ public class ElectrumXTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetP2PKHBalance() throws BitcoinException {
|
public void testGetP2PKHBalance() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
Address address = Address.fromString(TestNet3Params.get(), "n3GNqMveyvaPvUbH469vDRadqpJMPc84JA");
|
Address address = Address.fromString(TestNet3Params.get(), "n3GNqMveyvaPvUbH469vDRadqpJMPc84JA");
|
||||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
@ -93,8 +107,8 @@ public class ElectrumXTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetP2SHBalance() throws BitcoinException {
|
public void testGetP2SHBalance() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF");
|
Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF");
|
||||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
@ -106,8 +120,8 @@ public class ElectrumXTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetUnspentOutputs() throws BitcoinException {
|
public void testGetUnspentOutputs() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF");
|
Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF");
|
||||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
||||||
@ -120,8 +134,8 @@ public class ElectrumXTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetRawTransaction() throws BitcoinException {
|
public void testGetRawTransaction() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
byte[] txHash = HashCode.fromString("7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af").asBytes();
|
byte[] txHash = HashCode.fromString("7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af").asBytes();
|
||||||
|
|
||||||
@ -132,26 +146,26 @@ public class ElectrumXTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetUnknownRawTransaction() {
|
public void testGetUnknownRawTransaction() {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
byte[] txHash = HashCode.fromString("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0").asBytes();
|
byte[] txHash = HashCode.fromString("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0").asBytes();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
electrumX.getRawTransaction(txHash);
|
electrumX.getRawTransaction(txHash);
|
||||||
fail("Bitcoin transaction should be unknown and hence throw exception");
|
fail("Bitcoin transaction should be unknown and hence throw exception");
|
||||||
} catch (BitcoinException e) {
|
} catch (ForeignBlockchainException e) {
|
||||||
if (!(e instanceof BitcoinException.NotFoundException))
|
if (!(e instanceof ForeignBlockchainException.NotFoundException))
|
||||||
fail("Bitcoin transaction should be unknown and hence throw NotFoundException");
|
fail("Bitcoin transaction should be unknown and hence throw NotFoundException");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetTransaction() throws BitcoinException {
|
public void testGetTransaction() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
String txHash = "7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af";
|
String txHash = "7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af";
|
||||||
|
|
||||||
BitcoinTransaction transaction = electrumX.getTransaction(txHash);
|
BitcoinyTransaction transaction = electrumX.getTransaction(txHash);
|
||||||
|
|
||||||
assertNotNull(transaction);
|
assertNotNull(transaction);
|
||||||
assertTrue(transaction.txHash.equals(txHash));
|
assertTrue(transaction.txHash.equals(txHash));
|
||||||
@ -159,22 +173,22 @@ public class ElectrumXTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetUnknownTransaction() {
|
public void testGetUnknownTransaction() {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
String txHash = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0";
|
String txHash = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
electrumX.getTransaction(txHash);
|
electrumX.getTransaction(txHash);
|
||||||
fail("Bitcoin transaction should be unknown and hence throw exception");
|
fail("Bitcoin transaction should be unknown and hence throw exception");
|
||||||
} catch (BitcoinException e) {
|
} catch (ForeignBlockchainException e) {
|
||||||
if (!(e instanceof BitcoinException.NotFoundException))
|
if (!(e instanceof ForeignBlockchainException.NotFoundException))
|
||||||
fail("Bitcoin transaction should be unknown and hence throw NotFoundException");
|
fail("Bitcoin transaction should be unknown and hence throw NotFoundException");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void testGetAddressTransactions() throws BitcoinException {
|
public void testGetAddressTransactions() throws ForeignBlockchainException {
|
||||||
ElectrumX electrumX = ElectrumX.getInstance("TEST3");
|
ElectrumX electrumX = getInstance();
|
||||||
|
|
||||||
Address address = Address.fromString(TestNet3Params.get(), "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE");
|
Address address = Address.fromString(TestNet3Params.get(), "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE");
|
||||||
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
byte[] script = ScriptBuilder.createOutputScript(address).getProgram();
|
126
src/test/java/org/qortal/test/crosschain/HtlcTests.java
Normal file
126
src/test/java/org/qortal/test/crosschain/HtlcTests.java
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package org.qortal.test.crosschain;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.test.common.Common;
|
||||||
|
|
||||||
|
import com.google.common.primitives.Longs;
|
||||||
|
|
||||||
|
public class HtlcTests extends Common {
|
||||||
|
|
||||||
|
private Bitcoin bitcoin;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void beforeTest() throws DataException {
|
||||||
|
Common.useDefaultSettings(); // TestNet3
|
||||||
|
bitcoin = Bitcoin.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void afterTest() {
|
||||||
|
Bitcoin.resetForTesting();
|
||||||
|
bitcoin = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFindHtlcSecret() throws ForeignBlockchainException {
|
||||||
|
// This actually exists on TEST3 but can take a while to fetch
|
||||||
|
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
|
|
||||||
|
byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
|
||||||
|
byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress);
|
||||||
|
|
||||||
|
assertNotNull(secret);
|
||||||
|
assertArrayEquals("secret incorrect", expectedSecret, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHtlcSecretCaching() throws ForeignBlockchainException {
|
||||||
|
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
|
byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
|
||||||
|
|
||||||
|
do {
|
||||||
|
// We need to perform fresh setup for 1st test
|
||||||
|
Bitcoin.resetForTesting();
|
||||||
|
bitcoin = Bitcoin.getInstance();
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long timestampBoundary = now / 30_000L;
|
||||||
|
|
||||||
|
byte[] secret1 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress);
|
||||||
|
long executionPeriod1 = System.currentTimeMillis() - now;
|
||||||
|
|
||||||
|
assertNotNull(secret1);
|
||||||
|
assertArrayEquals("secret1 incorrect", expectedSecret, secret1);
|
||||||
|
|
||||||
|
assertTrue("1st execution period should not be instant!", executionPeriod1 > 10);
|
||||||
|
|
||||||
|
byte[] secret2 = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress);
|
||||||
|
long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1;
|
||||||
|
|
||||||
|
assertNotNull(secret2);
|
||||||
|
assertArrayEquals("secret2 incorrect", expectedSecret, secret2);
|
||||||
|
|
||||||
|
// Test is only valid if we've called within same timestampBoundary
|
||||||
|
if (System.currentTimeMillis() / 30_000L != timestampBoundary)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
assertArrayEquals(secret1, secret2);
|
||||||
|
|
||||||
|
assertTrue("2st execution period should be effectively instant!", executionPeriod2 < 10);
|
||||||
|
} while (false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testDetermineHtlcStatus() throws ForeignBlockchainException {
|
||||||
|
// This actually exists on TEST3 but can take a while to fetch
|
||||||
|
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||||
|
assertNotNull(htlcStatus);
|
||||||
|
|
||||||
|
System.out.println(String.format("HTLC %s status: %s", p2shAddress, htlcStatus.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testHtlcStatusCaching() throws ForeignBlockchainException {
|
||||||
|
do {
|
||||||
|
// We need to perform fresh setup for 1st test
|
||||||
|
Bitcoin.resetForTesting();
|
||||||
|
bitcoin = Bitcoin.getInstance();
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
long timestampBoundary = now / 30_000L;
|
||||||
|
|
||||||
|
// Won't ever exist
|
||||||
|
String p2shAddress = bitcoin.deriveP2shAddress(Crypto.hash160(Longs.toByteArray(now)));
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatus1 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||||
|
long executionPeriod1 = System.currentTimeMillis() - now;
|
||||||
|
|
||||||
|
assertNotNull(htlcStatus1);
|
||||||
|
assertTrue("1st execution period should not be instant!", executionPeriod1 > 10);
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatus2 = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddress, 1L);
|
||||||
|
long executionPeriod2 = System.currentTimeMillis() - now - executionPeriod1;
|
||||||
|
|
||||||
|
assertNotNull(htlcStatus2);
|
||||||
|
assertEquals(htlcStatus1, htlcStatus2);
|
||||||
|
|
||||||
|
// Test is only valid if we've called within same timestampBoundary
|
||||||
|
if (System.currentTimeMillis() / 30_000L != timestampBoundary)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
assertTrue("2st execution period should be effectively instant!", executionPeriod2 < 10);
|
||||||
|
} while (false);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
112
src/test/java/org/qortal/test/crosschain/LitecoinTests.java
Normal file
112
src/test/java/org/qortal/test/crosschain/LitecoinTests.java
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package org.qortal.test.crosschain;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Transaction;
|
||||||
|
import org.bitcoinj.store.BlockStoreException;
|
||||||
|
import org.junit.After;
|
||||||
|
import org.junit.Before;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.test.common.Common;
|
||||||
|
|
||||||
|
public class LitecoinTests extends Common {
|
||||||
|
|
||||||
|
private Litecoin litecoin;
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void beforeTest() throws DataException {
|
||||||
|
Common.useDefaultSettings(); // TestNet3
|
||||||
|
litecoin = Litecoin.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
public void afterTest() {
|
||||||
|
Litecoin.resetForTesting();
|
||||||
|
litecoin = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException {
|
||||||
|
long before = System.currentTimeMillis();
|
||||||
|
System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime()));
|
||||||
|
long afterFirst = System.currentTimeMillis();
|
||||||
|
|
||||||
|
System.out.println(String.format("Bitcoin median blocktime: %d", litecoin.getMedianBlockTime()));
|
||||||
|
long afterSecond = System.currentTimeMillis();
|
||||||
|
|
||||||
|
long firstPeriod = afterFirst - before;
|
||||||
|
long secondPeriod = afterSecond - afterFirst;
|
||||||
|
|
||||||
|
System.out.println(String.format("1st call: %d ms, 2nd call: %d ms", firstPeriod, secondPeriod));
|
||||||
|
|
||||||
|
assertTrue("2nd call should be quicker than 1st", secondPeriod < firstPeriod);
|
||||||
|
assertTrue("2nd call should take less than 5 seconds", secondPeriod < 5000L);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testFindHtlcSecret() throws ForeignBlockchainException {
|
||||||
|
// This actually exists on TEST3 but can take a while to fetch
|
||||||
|
String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
|
|
||||||
|
byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
|
||||||
|
byte[] secret = BitcoinyHTLC.findHtlcSecret(litecoin, p2shAddress);
|
||||||
|
|
||||||
|
assertNotNull("secret not found", secret);
|
||||||
|
assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testBuildSpend() {
|
||||||
|
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||||
|
|
||||||
|
String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE";
|
||||||
|
long amount = 1000L;
|
||||||
|
|
||||||
|
Transaction transaction = litecoin.buildSpend(xprv58, recipient, amount);
|
||||||
|
assertNotNull("insufficient funds", transaction);
|
||||||
|
|
||||||
|
// Check spent key caching doesn't affect outcome
|
||||||
|
|
||||||
|
transaction = litecoin.buildSpend(xprv58, recipient, amount);
|
||||||
|
assertNotNull("insufficient funds", transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetWalletBalance() {
|
||||||
|
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||||
|
|
||||||
|
Long balance = litecoin.getWalletBalance(xprv58);
|
||||||
|
|
||||||
|
assertNotNull(balance);
|
||||||
|
|
||||||
|
System.out.println(litecoin.format(balance));
|
||||||
|
|
||||||
|
// Check spent key caching doesn't affect outcome
|
||||||
|
|
||||||
|
Long repeatBalance = litecoin.getWalletBalance(xprv58);
|
||||||
|
|
||||||
|
assertNotNull(repeatBalance);
|
||||||
|
|
||||||
|
System.out.println(litecoin.format(repeatBalance));
|
||||||
|
|
||||||
|
assertEquals(balance, repeatBalance);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testGetUnusedReceiveAddress() throws ForeignBlockchainException {
|
||||||
|
String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ";
|
||||||
|
|
||||||
|
String address = litecoin.getUnusedReceiveAddress(xprv58);
|
||||||
|
|
||||||
|
assertNotNull(address);
|
||||||
|
|
||||||
|
System.out.println(address);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
114
src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java
Normal file
114
src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Address;
|
||||||
|
import org.bitcoinj.core.Coin;
|
||||||
|
import org.bitcoinj.core.NetworkParameters;
|
||||||
|
import org.bitcoinj.script.Script.ScriptType;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class BuildHTLC {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: BuildHTLC (-b | -l) <refund-P2PKH> <amount> <redeem-P2PKH> <HASH160-of-secret> <locktime>"));
|
||||||
|
System.err.println("where: -b means use Bitcoin, -l means use Litecoin");
|
||||||
|
System.err.println(String.format("example: BuildHTLC -l "
|
||||||
|
+ "msAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n"
|
||||||
|
+ "\t0.00008642 \\\n"
|
||||||
|
+ "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n"
|
||||||
|
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
||||||
|
+ "\t1600000000"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length < 6 || args.length > 6)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
NetworkParameters params = null;
|
||||||
|
|
||||||
|
Address refundAddress = null;
|
||||||
|
Coin amount = null;
|
||||||
|
Address redeemAddress = null;
|
||||||
|
byte[] hashOfSecret = null;
|
||||||
|
int lockTime = 0;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
params = bitcoiny.getNetworkParameters();
|
||||||
|
|
||||||
|
refundAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (refundAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Refund address must be in P2PKH form");
|
||||||
|
|
||||||
|
amount = Coin.parseCoin(args[argIndex++]);
|
||||||
|
|
||||||
|
redeemAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Redeem address must be in P2PKH form");
|
||||||
|
|
||||||
|
hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (hashOfSecret.length != 20)
|
||||||
|
usage("Hash of secret must be 20 bytes");
|
||||||
|
|
||||||
|
lockTime = Integer.parseInt(args[argIndex++]);
|
||||||
|
int refundTimeoutDelay = lockTime - (int) (System.currentTimeMillis() / 1000L);
|
||||||
|
if (refundTimeoutDelay < 600 || refundTimeoutDelay > 30 * 24 * 60 * 60)
|
||||||
|
usage("Locktime (seconds) should be at between 10 minutes and 1 month from now");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny));
|
||||||
|
if (p2shFee.isZero())
|
||||||
|
return;
|
||||||
|
|
||||||
|
System.out.println(String.format("Refund address: %s", refundAddress));
|
||||||
|
System.out.println(String.format("Amount: %s", amount.toPlainString()));
|
||||||
|
System.out.println(String.format("Redeem address: %s", redeemAddress));
|
||||||
|
System.out.println(String.format("Refund/redeem miner's fee: %s", bitcoiny.format(p2shFee)));
|
||||||
|
System.out.println(String.format("Script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
|
||||||
|
System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(hashOfSecret)));
|
||||||
|
|
||||||
|
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret);
|
||||||
|
System.out.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes)));
|
||||||
|
|
||||||
|
String p2shAddress = bitcoiny.deriveP2shAddress(redeemScriptBytes);
|
||||||
|
System.out.println(String.format("P2SH address: %s", p2shAddress));
|
||||||
|
|
||||||
|
amount = amount.add(p2shFee);
|
||||||
|
|
||||||
|
// Fund P2SH
|
||||||
|
System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)",
|
||||||
|
p2shAddress, bitcoiny.format(amount), bitcoiny.format(p2shFee)));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
135
src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java
Normal file
135
src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Address;
|
||||||
|
import org.bitcoinj.core.Coin;
|
||||||
|
import org.bitcoinj.core.LegacyAddress;
|
||||||
|
import org.bitcoinj.core.NetworkParameters;
|
||||||
|
import org.bitcoinj.script.Script.ScriptType;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class CheckHTLC {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: CheckHTLC (-b | -l) <P2SH-address> <refund-P2PKH> <amount> <redeem-P2PKH> <HASH160-of-secret> <locktime>"));
|
||||||
|
System.err.println("where: -b means use Bitcoin, -l means use Litecoin");
|
||||||
|
System.err.println(String.format("example: CheckP2SH -l "
|
||||||
|
+ "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n"
|
||||||
|
+ "msAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n"
|
||||||
|
+ "\t0.00008642 \\\n"
|
||||||
|
+ "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n"
|
||||||
|
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
||||||
|
+ "\t1600184800"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length < 7 || args.length > 7)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
NetworkParameters params = null;
|
||||||
|
|
||||||
|
Address p2shAddress = null;
|
||||||
|
Address refundAddress = null;
|
||||||
|
Coin amount = null;
|
||||||
|
Address redeemAddress = null;
|
||||||
|
byte[] hashOfSecret = null;
|
||||||
|
int lockTime = 0;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
params = bitcoiny.getNetworkParameters();
|
||||||
|
|
||||||
|
p2shAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
|
||||||
|
usage("P2SH address invalid");
|
||||||
|
|
||||||
|
refundAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (refundAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Refund address must be in P2PKH form");
|
||||||
|
|
||||||
|
amount = Coin.parseCoin(args[argIndex++]);
|
||||||
|
|
||||||
|
redeemAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Redeem address must be in P2PKH form");
|
||||||
|
|
||||||
|
hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (hashOfSecret.length != 20)
|
||||||
|
usage("Hash of secret must be 20 bytes");
|
||||||
|
|
||||||
|
lockTime = Integer.parseInt(args[argIndex++]);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny));
|
||||||
|
if (p2shFee.isZero())
|
||||||
|
return;
|
||||||
|
|
||||||
|
System.out.println(String.format("P2SH address: %s", p2shAddress));
|
||||||
|
System.out.println(String.format("Refund PKH: %s", refundAddress));
|
||||||
|
System.out.println(String.format("Redeem/refund amount: %s", amount.toPlainString()));
|
||||||
|
System.out.println(String.format("Redeem PKH: %s", redeemAddress));
|
||||||
|
System.out.println(String.format("Hash of secret: %s", HashCode.fromBytes(hashOfSecret)));
|
||||||
|
System.out.println(String.format("Script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
|
||||||
|
|
||||||
|
System.out.println(String.format("Redeem/refund miner's fee: %s", bitcoiny.format(p2shFee)));
|
||||||
|
|
||||||
|
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret);
|
||||||
|
System.out.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes)));
|
||||||
|
|
||||||
|
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||||
|
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||||
|
|
||||||
|
if (!derivedP2shAddress.equals(p2shAddress)) {
|
||||||
|
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
amount = amount.add(p2shFee);
|
||||||
|
|
||||||
|
// Check network's median block time
|
||||||
|
int medianBlockTime = Common.checkMedianBlockTime(bitcoiny, null);
|
||||||
|
if (medianBlockTime == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check P2SH is funded
|
||||||
|
Common.getBalance(bitcoiny, p2shAddress.toString());
|
||||||
|
|
||||||
|
// Grab all unspent outputs
|
||||||
|
Common.getUnspentOutputs(bitcoiny, p2shAddress.toString());
|
||||||
|
|
||||||
|
Common.determineHtlcStatus(bitcoiny, p2shAddress.toString(), amount.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
158
src/test/java/org/qortal/test/crosschain/apps/Common.java
Normal file
158
src/test/java/org/qortal/test/crosschain/apps/Common.java
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.security.Security;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Transaction;
|
||||||
|
import org.bitcoinj.core.TransactionOutput;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public abstract class Common {
|
||||||
|
|
||||||
|
public static void init() {
|
||||||
|
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
||||||
|
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||||
|
|
||||||
|
Settings.fileInstance("settings-test.json");
|
||||||
|
|
||||||
|
NTP.setFixedOffset(0L);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long getP2shFee(Bitcoiny bitcoiny) {
|
||||||
|
long p2shFee;
|
||||||
|
|
||||||
|
try {
|
||||||
|
p2shFee = bitcoiny.getP2shFee(null);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Unable to determine P2SH fee: %s", e.getMessage()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return p2shFee;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int checkMedianBlockTime(Bitcoiny bitcoiny, Integer lockTime) {
|
||||||
|
int medianBlockTime;
|
||||||
|
|
||||||
|
try {
|
||||||
|
medianBlockTime = bitcoiny.getMedianBlockTime();
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Unable to determine median block time: %s", e.getMessage()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Median block time: %s", LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
|
||||||
|
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
|
||||||
|
if (now < medianBlockTime * 1000L) {
|
||||||
|
System.out.println(String.format("Too soon (%s) based on median block time %s",
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC),
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochSecond(medianBlockTime), ZoneOffset.UTC)));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lockTime != null && now < lockTime * 1000L) {
|
||||||
|
System.err.println(String.format("Too soon (%s) based on lockTime %s",
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochMilli(now), ZoneOffset.UTC),
|
||||||
|
LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC)));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return medianBlockTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long getBalance(Bitcoiny bitcoiny, String address58) {
|
||||||
|
long balance;
|
||||||
|
|
||||||
|
try {
|
||||||
|
balance = bitcoiny.getConfirmedBalance(address58);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Unable to check address %s balance: %s", address58, e.getMessage()));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Address %s balance: %s", address58, bitcoiny.format(balance)));
|
||||||
|
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<TransactionOutput> getUnspentOutputs(Bitcoiny bitcoiny, String address58) {
|
||||||
|
List<TransactionOutput> unspentOutputs = Collections.emptyList();
|
||||||
|
|
||||||
|
try {
|
||||||
|
unspentOutputs = bitcoiny.getUnspentOutputs(address58);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Can't find unspent outputs for %s: %s", address58, e.getMessage()));
|
||||||
|
return unspentOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Found %d output%s for %s",
|
||||||
|
unspentOutputs.size(),
|
||||||
|
(unspentOutputs.size() != 1 ? "s" : ""),
|
||||||
|
address58));
|
||||||
|
|
||||||
|
for (TransactionOutput fundingOutput : unspentOutputs)
|
||||||
|
System.out.println(String.format("Output %s:%d amount %s",
|
||||||
|
HashCode.fromBytes(fundingOutput.getParentTransactionHash().getBytes()), fundingOutput.getIndex(),
|
||||||
|
bitcoiny.format(fundingOutput.getValue())));
|
||||||
|
|
||||||
|
if (unspentOutputs.isEmpty())
|
||||||
|
System.err.println(String.format("Can't use spent/unfunded %s", address58));
|
||||||
|
|
||||||
|
if (unspentOutputs.size() != 1)
|
||||||
|
System.err.println(String.format("Expecting only one unspent output?"));
|
||||||
|
|
||||||
|
return unspentOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static BitcoinyHTLC.Status determineHtlcStatus(Bitcoiny bitcoiny, String address58, long minimumAmount) {
|
||||||
|
BitcoinyHTLC.Status htlcStatus = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
htlcStatus = BitcoinyHTLC.determineHtlcStatus(bitcoiny.getBlockchainProvider(), address58, minimumAmount);
|
||||||
|
|
||||||
|
System.out.println(String.format("HTLC status: %s", htlcStatus.name()));
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Unable to determine HTLC status: %s", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return htlcStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void broadcastTransaction(Bitcoiny bitcoiny, Transaction transaction) {
|
||||||
|
byte[] rawTransactionBytes = transaction.bitcoinSerialize();
|
||||||
|
|
||||||
|
System.out.println(String.format("%nRaw transaction bytes:%n%s%n", HashCode.fromBytes(rawTransactionBytes).toString()));
|
||||||
|
|
||||||
|
for (int countDown = 5; countDown >= 1; --countDown) {
|
||||||
|
System.out.print(String.format("\rBroadcasting transaction in %d second%s... use CTRL-C to abort ", countDown, (countDown != 1 ? "s" : "")));
|
||||||
|
try {
|
||||||
|
Thread.sleep(1000L);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
System.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("Broadcasting transaction... ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
bitcoiny.broadcastTransaction(transaction);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Failed to broadcast transaction: %s", e.getMessage()));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.security.Security;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.AddressFormatException;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
|
||||||
|
public class GetNextReceiveAddress {
|
||||||
|
|
||||||
|
static {
|
||||||
|
// This must go before any calls to LogManager/Logger
|
||||||
|
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: GetNextReceiveAddress (-b | -l) <xprv/xpub>"));
|
||||||
|
System.err.println(String.format("example (testnet): GetNextReceiveAddress -l tpubD6NzVbkrYhZ4X3jV96Wo3Kr8Au2v9cUUEmPRk1smwduFrRVfBjkkw49rRYjgff1fGSktFMfabbvv8b1dmfyLjjbDax6QGyxpsNsx5PXukCB"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 2)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
||||||
|
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||||
|
|
||||||
|
Settings.fileInstance("settings-test.json");
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
String key58 = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
key58 = args[argIndex++];
|
||||||
|
|
||||||
|
if (!bitcoiny.isValidDeterministicKey(key58))
|
||||||
|
usage("Not valid xprv/xpub/tprv/tpub");
|
||||||
|
} catch (NumberFormatException | AddressFormatException e) {
|
||||||
|
usage(String.format("Argument format exception: %s", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
String receiveAddress = null;
|
||||||
|
try {
|
||||||
|
receiveAddress = bitcoiny.getUnusedReceiveAddress(key58);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Failed to determine next receive address: %s", e.getMessage()));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Next receive address: %s", receiveAddress));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.qortal.test.btcacct;
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
import java.security.Security;
|
import java.security.Security;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -6,8 +6,11 @@ import java.util.List;
|
|||||||
import org.bitcoinj.core.AddressFormatException;
|
import org.bitcoinj.core.AddressFormatException;
|
||||||
import org.bitcoinj.core.TransactionOutput;
|
import org.bitcoinj.core.TransactionOutput;
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
import org.qortal.crosschain.BTC;
|
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
||||||
import org.qortal.crosschain.BitcoinException;
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
import org.qortal.settings.Settings;
|
import org.qortal.settings.Settings;
|
||||||
|
|
||||||
import com.google.common.hash.HashCode;
|
import com.google.common.hash.HashCode;
|
||||||
@ -23,34 +26,51 @@ public class GetTransaction {
|
|||||||
if (error != null)
|
if (error != null)
|
||||||
System.err.println(error);
|
System.err.println(error);
|
||||||
|
|
||||||
System.err.println(String.format("usage: GetTransaction <bitcoin-tx>"));
|
System.err.println(String.format("usage: GetTransaction (-b | -l) <tx-hash>"));
|
||||||
System.err.println(String.format("example (mainnet): GetTransaction 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660"));
|
System.err.println(String.format("example (mainnet): GetTransaction -b 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660"));
|
||||||
System.err.println(String.format("example (testnet): GetTransaction 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e"));
|
System.err.println(String.format("example (testnet): GetTransaction -b 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e"));
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
if (args.length != 1)
|
if (args.length != 2)
|
||||||
usage(null);
|
usage(null);
|
||||||
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
||||||
|
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||||
|
|
||||||
Settings.fileInstance("settings-test.json");
|
Settings.fileInstance("settings-test.json");
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
byte[] transactionId = null;
|
byte[] transactionId = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
try {
|
try {
|
||||||
int argIndex = 0;
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
|
||||||
transactionId = HashCode.fromString(args[argIndex++]).asBytes();
|
transactionId = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
} catch (NumberFormatException | AddressFormatException e) {
|
} catch (NumberFormatException | AddressFormatException e) {
|
||||||
usage(String.format("Argument format exception: %s", e.getMessage()));
|
usage(String.format("Argument format exception: %s", e.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
// Grab all outputs from transaction
|
// Grab all outputs from transaction
|
||||||
List<TransactionOutput> fundingOutputs;
|
List<TransactionOutput> fundingOutputs;
|
||||||
try {
|
try {
|
||||||
fundingOutputs = BTC.getInstance().getOutputs(transactionId);
|
fundingOutputs = bitcoiny.getOutputs(transactionId);
|
||||||
} catch (BitcoinException e) {
|
} catch (ForeignBlockchainException e) {
|
||||||
System.out.println(String.format("Transaction not found (or error occurred)"));
|
System.out.println(String.format("Transaction not found (or error occurred)"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
@ -0,0 +1,86 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.security.Security;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.AddressFormatException;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.BitcoinyTransaction;
|
||||||
|
import org.qortal.crosschain.ForeignBlockchainException;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.settings.Settings;
|
||||||
|
|
||||||
|
public class GetWalletTransactions {
|
||||||
|
|
||||||
|
static {
|
||||||
|
// This must go before any calls to LogManager/Logger
|
||||||
|
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: GetWalletTransactions (-b | -l) <xprv/xpub>"));
|
||||||
|
System.err.println(String.format("example (testnet): GetWalletTransactions -l tpubD6NzVbkrYhZ4X3jV96Wo3Kr8Au2v9cUUEmPRk1smwduFrRVfBjkkw49rRYjgff1fGSktFMfabbvv8b1dmfyLjjbDax6QGyxpsNsx5PXukCB"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 2)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
||||||
|
Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
|
||||||
|
|
||||||
|
Settings.fileInstance("settings-test.json");
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
String key58 = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
key58 = args[argIndex++];
|
||||||
|
|
||||||
|
if (!bitcoiny.isValidDeterministicKey(key58))
|
||||||
|
usage("Not valid xprv/xpub/tprv/tpub");
|
||||||
|
} catch (NumberFormatException | AddressFormatException e) {
|
||||||
|
usage(String.format("Argument format exception: %s", e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
// Grab all outputs from transaction
|
||||||
|
List<BitcoinyTransaction> transactions = null;
|
||||||
|
try {
|
||||||
|
transactions = bitcoiny.getWalletTransactions(key58);
|
||||||
|
} catch (ForeignBlockchainException e) {
|
||||||
|
System.err.println(String.format("Failed to obtain wallet transactions: %s", e.getMessage()));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Found %d transaction%s", transactions.size(), (transactions.size() != 1 ? "s" : "")));
|
||||||
|
|
||||||
|
for (BitcoinyTransaction transaction : transactions.stream().sorted(Comparator.comparingInt(t -> t.timestamp)).collect(Collectors.toList()))
|
||||||
|
System.out.println(String.format("%s", transaction));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
80
src/test/java/org/qortal/test/crosschain/apps/Pay.java
Normal file
80
src/test/java/org/qortal/test/crosschain/apps/Pay.java
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.Address;
|
||||||
|
import org.bitcoinj.core.Coin;
|
||||||
|
import org.bitcoinj.core.NetworkParameters;
|
||||||
|
import org.bitcoinj.core.Transaction;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
|
||||||
|
public class Pay {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: Pay (-b | -l) <xprv58> <recipient> <LTC-amount>"));
|
||||||
|
System.err.println("where: -b means use Bitcoin, -l means use Litecoin");
|
||||||
|
System.err.println(String.format("example: Pay -l "
|
||||||
|
+ "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ \\\n"
|
||||||
|
+ "\tmsAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n"
|
||||||
|
+ "\t0.00008642"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length < 4 || args.length > 4)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
NetworkParameters params = null;
|
||||||
|
|
||||||
|
String xprv58 = null;
|
||||||
|
Address address = null;
|
||||||
|
Coin amount = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
params = bitcoiny.getNetworkParameters();
|
||||||
|
|
||||||
|
xprv58 = args[argIndex++];
|
||||||
|
if (!bitcoiny.isValidDeterministicKey(xprv58))
|
||||||
|
usage("xprv invalid");
|
||||||
|
|
||||||
|
address = Address.fromString(params, args[argIndex++]);
|
||||||
|
|
||||||
|
amount = Coin.parseCoin(args[argIndex++]);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
System.out.println(String.format("Address: %s", address));
|
||||||
|
System.out.println(String.format("Amount: %s", amount.toPlainString()));
|
||||||
|
|
||||||
|
Transaction transaction = bitcoiny.buildSpend(xprv58, address.toString(), amount.value);
|
||||||
|
if (transaction == null) {
|
||||||
|
System.err.println("Insufficent funds");
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
Common.broadcastTransaction(bitcoiny, transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
166
src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java
Normal file
166
src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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.TransactionOutput;
|
||||||
|
import org.bitcoinj.script.Script.ScriptType;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class RedeemHTLC {
|
||||||
|
|
||||||
|
static {
|
||||||
|
// This must go before any calls to LogManager/Logger
|
||||||
|
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: Redeem (-b | -l) <P2SH-address> <refund-P2PKH> <redeem-PRIVATE-key> <secret> <locktime> <output-address>"));
|
||||||
|
System.err.println("where: -b means use Bitcoin, -l means use Litecoin");
|
||||||
|
System.err.println(String.format("example: Redeem -l "
|
||||||
|
+ "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n"
|
||||||
|
+ "\tmsAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n"
|
||||||
|
+ "\tefdaed23c4bc85c8ccae40d774af3c2a10391c648b6420cdd83cd44c27fcb5955201c64e372d \\\n"
|
||||||
|
+ "\t5468697320737472696e672069732065786163746c7920333220627974657321 \\\n"
|
||||||
|
+ "\t1600184800 \\\n"
|
||||||
|
+ "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length < 7 || args.length > 7)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
NetworkParameters params = null;
|
||||||
|
|
||||||
|
Address p2shAddress = null;
|
||||||
|
Address refundAddress = null;
|
||||||
|
byte[] redeemPrivateKey = null;
|
||||||
|
byte[] secret = null;
|
||||||
|
int lockTime = 0;
|
||||||
|
Address outputAddress = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
params = bitcoiny.getNetworkParameters();
|
||||||
|
|
||||||
|
p2shAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
|
||||||
|
usage("P2SH address invalid");
|
||||||
|
|
||||||
|
refundAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (refundAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Refund address must be in P2PKH form");
|
||||||
|
|
||||||
|
redeemPrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
// Auto-trim
|
||||||
|
if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38)
|
||||||
|
redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33);
|
||||||
|
if (redeemPrivateKey.length != 32)
|
||||||
|
usage("Redeem private key must be 32 bytes");
|
||||||
|
|
||||||
|
secret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (secret.length == 0)
|
||||||
|
usage("Invalid secret bytes");
|
||||||
|
|
||||||
|
lockTime = Integer.parseInt(args[argIndex++]);
|
||||||
|
|
||||||
|
outputAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (outputAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Output address invalid");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny));
|
||||||
|
if (p2shFee.isZero())
|
||||||
|
return;
|
||||||
|
|
||||||
|
System.out.println(String.format("Attempting to redeem HTLC %s to %s", p2shAddress, outputAddress));
|
||||||
|
|
||||||
|
byte[] hashOfSecret = Crypto.hash160(secret);
|
||||||
|
|
||||||
|
ECKey redeemKey = ECKey.fromPrivate(redeemPrivateKey);
|
||||||
|
Address redeemAddress = Address.fromKey(params, redeemKey, ScriptType.P2PKH);
|
||||||
|
|
||||||
|
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret);
|
||||||
|
|
||||||
|
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||||
|
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||||
|
|
||||||
|
if (!derivedP2shAddress.equals(p2shAddress)) {
|
||||||
|
System.err.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes)));
|
||||||
|
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
|
||||||
|
System.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual live processing...
|
||||||
|
|
||||||
|
int medianBlockTime = Common.checkMedianBlockTime(bitcoiny, null);
|
||||||
|
if (medianBlockTime == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check P2SH is funded
|
||||||
|
long p2shBalance = Common.getBalance(bitcoiny, p2shAddress.toString());
|
||||||
|
if (p2shBalance == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Grab all unspent outputs
|
||||||
|
List<TransactionOutput> unspentOutputs = Common.getUnspentOutputs(bitcoiny, p2shAddress.toString());
|
||||||
|
if (unspentOutputs.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
Coin redeemAmount = Coin.valueOf(p2shBalance).subtract(p2shFee);
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatus = Common.determineHtlcStatus(bitcoiny, p2shAddress.toString(), redeemAmount.value);
|
||||||
|
if (htlcStatus == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (htlcStatus != BitcoinyHTLC.Status.FUNDED) {
|
||||||
|
System.err.println(String.format("Expecting %s HTLC status, but actual status is %s", "FUNDED", htlcStatus.name()));
|
||||||
|
System.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Spending %s of outputs, with %s as mining fee", bitcoiny.format(redeemAmount), bitcoiny.format(p2shFee)));
|
||||||
|
|
||||||
|
Transaction redeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoiny.getNetworkParameters(), redeemAmount, redeemKey,
|
||||||
|
unspentOutputs, redeemScriptBytes, secret, outputAddress.getHash());
|
||||||
|
|
||||||
|
Common.broadcastTransaction(bitcoiny, redeemTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
163
src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java
Normal file
163
src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
package org.qortal.test.crosschain.apps;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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.TransactionOutput;
|
||||||
|
import org.bitcoinj.script.Script.ScriptType;
|
||||||
|
import org.qortal.crosschain.Litecoin;
|
||||||
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
|
import org.qortal.crosschain.BitcoinyHTLC;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class RefundHTLC {
|
||||||
|
|
||||||
|
static {
|
||||||
|
// This must go before any calls to LogManager/Logger
|
||||||
|
System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: RefundHTLC (-b | -l) <P2SH-address> <refund-PRIVATE-KEY> <redeem-P2PKH> <HASH160-of-secret> <locktime> <output-address>"));
|
||||||
|
System.err.println("where: -b means use Bitcoin, -l means use Litecoin");
|
||||||
|
System.err.println(String.format("example: RefundHTLC -l "
|
||||||
|
+ "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n"
|
||||||
|
+ "\tef8f31b49c31b4a140aebcd9605fded88cc2dad0844c4b984f9191a5a416f72d3801e16447b0 \\\n"
|
||||||
|
+ "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n"
|
||||||
|
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
||||||
|
+ "\t1600184800 \\\n"
|
||||||
|
+ "\tmoJtbbhs7T4Z5hmBH2iyKhGrCWBzQWS2CL"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length < 7 || args.length > 7)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
Bitcoiny bitcoiny = null;
|
||||||
|
NetworkParameters params = null;
|
||||||
|
|
||||||
|
Address p2shAddress = null;
|
||||||
|
byte[] refundPrivateKey = null;
|
||||||
|
Address redeemAddress = null;
|
||||||
|
byte[] hashOfSecret = null;
|
||||||
|
int lockTime = 0;
|
||||||
|
Address outputAddress = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
switch (args[argIndex++]) {
|
||||||
|
case "-b":
|
||||||
|
bitcoiny = Bitcoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "-l":
|
||||||
|
bitcoiny = Litecoin.getInstance();
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
usage("Only Bitcoin (-b) or Litecoin (-l) supported");
|
||||||
|
}
|
||||||
|
params = bitcoiny.getNetworkParameters();
|
||||||
|
|
||||||
|
p2shAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (p2shAddress.getOutputScriptType() != ScriptType.P2SH)
|
||||||
|
usage("P2SH address invalid");
|
||||||
|
|
||||||
|
refundPrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
// Auto-trim
|
||||||
|
if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38)
|
||||||
|
refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33);
|
||||||
|
if (refundPrivateKey.length != 32)
|
||||||
|
usage("Refund private key must be 32 bytes");
|
||||||
|
|
||||||
|
redeemAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Redeem address must be in P2PKH form");
|
||||||
|
|
||||||
|
hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (hashOfSecret.length != 20)
|
||||||
|
usage("HASH160 of secret must be 20 bytes");
|
||||||
|
|
||||||
|
lockTime = Integer.parseInt(args[argIndex++]);
|
||||||
|
|
||||||
|
outputAddress = Address.fromString(params, args[argIndex++]);
|
||||||
|
if (outputAddress.getOutputScriptType() != ScriptType.P2PKH)
|
||||||
|
usage("Output address invalid");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Using %s", bitcoiny.getBlockchainProvider().getNetId()));
|
||||||
|
|
||||||
|
Coin p2shFee = Coin.valueOf(Common.getP2shFee(bitcoiny));
|
||||||
|
if (p2shFee.isZero())
|
||||||
|
return;
|
||||||
|
|
||||||
|
System.out.println(String.format("Attempting to refund HTLC %s to %s", p2shAddress, outputAddress));
|
||||||
|
|
||||||
|
ECKey refundKey = ECKey.fromPrivate(refundPrivateKey);
|
||||||
|
Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH);
|
||||||
|
|
||||||
|
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.getHash(), lockTime, redeemAddress.getHash(), hashOfSecret);
|
||||||
|
|
||||||
|
byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes);
|
||||||
|
Address derivedP2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash);
|
||||||
|
|
||||||
|
if (!derivedP2shAddress.equals(p2shAddress)) {
|
||||||
|
System.err.println(String.format("Raw script bytes: %s", HashCode.fromBytes(redeemScriptBytes)));
|
||||||
|
System.err.println(String.format("Derived P2SH address %s does not match given address %s", derivedP2shAddress, p2shAddress));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actual live processing...
|
||||||
|
|
||||||
|
int medianBlockTime = Common.checkMedianBlockTime(bitcoiny, lockTime);
|
||||||
|
if (medianBlockTime == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Check P2SH is funded
|
||||||
|
long p2shBalance = Common.getBalance(bitcoiny, p2shAddress.toString());
|
||||||
|
if (p2shBalance == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Grab all unspent outputs
|
||||||
|
List<TransactionOutput> unspentOutputs = Common.getUnspentOutputs(bitcoiny, p2shAddress.toString());
|
||||||
|
if (unspentOutputs.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
Coin refundAmount = Coin.valueOf(p2shBalance).subtract(p2shFee);
|
||||||
|
|
||||||
|
BitcoinyHTLC.Status htlcStatus = Common.determineHtlcStatus(bitcoiny, p2shAddress.toString(), refundAmount.value);
|
||||||
|
if (htlcStatus == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (htlcStatus != BitcoinyHTLC.Status.FUNDED) {
|
||||||
|
System.err.println(String.format("Expecting %s HTLC status, but actual status is %s", "FUNDED", htlcStatus.name()));
|
||||||
|
System.exit(2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("Spending %s of outputs, with %s as mining fee", bitcoiny.format(refundAmount), bitcoiny.format(p2shFee)));
|
||||||
|
|
||||||
|
Transaction refundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
|
||||||
|
unspentOutputs, redeemScriptBytes, lockTime, outputAddress.getHash());
|
||||||
|
|
||||||
|
Common.broadcastTransaction(bitcoiny, refundTransaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.qortal.test.btcacct;
|
package org.qortal.test.crosschain.bitcoinv1;
|
||||||
|
|
||||||
import static org.junit.Assert.*;
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
@ -18,7 +18,8 @@ import org.qortal.account.Account;
|
|||||||
import org.qortal.account.PrivateKeyAccount;
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
import org.qortal.asset.Asset;
|
import org.qortal.asset.Asset;
|
||||||
import org.qortal.block.Block;
|
import org.qortal.block.Block;
|
||||||
import org.qortal.crosschain.BTCACCT;
|
import org.qortal.crosschain.BitcoinACCTv1;
|
||||||
|
import org.qortal.crosschain.AcctMode;
|
||||||
import org.qortal.crypto.Crypto;
|
import org.qortal.crypto.Crypto;
|
||||||
import org.qortal.data.at.ATData;
|
import org.qortal.data.at.ATData;
|
||||||
import org.qortal.data.at.ATStateData;
|
import org.qortal.data.at.ATStateData;
|
||||||
@ -41,7 +42,7 @@ import org.qortal.utils.Amounts;
|
|||||||
import com.google.common.hash.HashCode;
|
import com.google.common.hash.HashCode;
|
||||||
import com.google.common.primitives.Bytes;
|
import com.google.common.primitives.Bytes;
|
||||||
|
|
||||||
public class AtTests extends Common {
|
public class BitcoinACCTv1Tests extends Common {
|
||||||
|
|
||||||
public static final byte[] secretA = "This string is exactly 32 bytes!".getBytes();
|
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[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a
|
||||||
@ -51,7 +52,7 @@ public class AtTests extends Common {
|
|||||||
public static final int tradeTimeout = 20; // blocks
|
public static final int tradeTimeout = 20; // blocks
|
||||||
public static final long redeemAmount = 80_40200000L;
|
public static final long redeemAmount = 80_40200000L;
|
||||||
public static final long fundingAmount = 123_45600000L;
|
public static final long fundingAmount = 123_45600000L;
|
||||||
public static final long bitcoinAmount = 864200L;
|
public static final long bitcoinAmount = 864200L; // 0.00864200 BTC
|
||||||
|
|
||||||
private static final Random RANDOM = new Random();
|
private static final Random RANDOM = new Random();
|
||||||
|
|
||||||
@ -64,8 +65,10 @@ public class AtTests extends Common {
|
|||||||
public void testCompile() {
|
public void testCompile() {
|
||||||
PrivateKeyAccount tradeAccount = createTradeAccount(null);
|
PrivateKeyAccount tradeAccount = createTradeAccount(null);
|
||||||
|
|
||||||
byte[] creationBytes = BTCACCT.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout);
|
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout);
|
||||||
System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
|
assertNotNull(creationBytes);
|
||||||
|
|
||||||
|
System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -136,7 +139,7 @@ public class AtTests extends Common {
|
|||||||
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
|
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
|
||||||
|
|
||||||
// Send creator's address to AT, instead of typical partner's address
|
// Send creator's address to AT, instead of typical partner's address
|
||||||
byte[] messageData = BTCACCT.buildCancelMessage(deployer.getAddress());
|
byte[] messageData = BitcoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress());
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress);
|
||||||
long messageFee = messageTransaction.getTransactionData().getFee();
|
long messageFee = messageTransaction.getTransactionData().getFee();
|
||||||
|
|
||||||
@ -150,8 +153,8 @@ public class AtTests extends Common {
|
|||||||
assertTrue(atData.getIsFinished());
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should be in CANCELLED mode
|
// AT should be in CANCELLED mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode);
|
assertEquals(AcctMode.CANCELLED, tradeData.mode);
|
||||||
|
|
||||||
// Check balances
|
// Check balances
|
||||||
long expectedMinimumBalance = deployersPostDeploymentBalance;
|
long expectedMinimumBalance = deployersPostDeploymentBalance;
|
||||||
@ -209,8 +212,8 @@ public class AtTests extends Common {
|
|||||||
assertTrue(atData.getIsFinished());
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should be in CANCELLED mode
|
// AT should be in CANCELLED mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode);
|
assertEquals(AcctMode.CANCELLED, tradeData.mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,10 +235,10 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT
|
// Send trade info to AT
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
Block postDeploymentBlock = BlockUtils.mintBlock(repository);
|
Block postDeploymentBlock = BlockUtils.mintBlock(repository);
|
||||||
@ -247,10 +250,10 @@ public class AtTests extends Common {
|
|||||||
describeAt(repository, atAddress);
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
// AT should be in TRADE mode
|
// AT should be in TRADE mode
|
||||||
assertEquals(BTCACCT.Mode.TRADING, tradeData.mode);
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
|
||||||
// Check hashOfSecretA was extracted correctly
|
// Check hashOfSecretA was extracted correctly
|
||||||
assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA));
|
assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA));
|
||||||
@ -259,7 +262,7 @@ public class AtTests extends Common {
|
|||||||
assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress);
|
assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress);
|
||||||
|
|
||||||
// Check trade partner's Bitcoin PKH was extracted correctly
|
// Check trade partner's Bitcoin PKH was extracted correctly
|
||||||
assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerBitcoinPKH));
|
assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH));
|
||||||
|
|
||||||
// Test orphaning
|
// Test orphaning
|
||||||
BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight);
|
BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight);
|
||||||
@ -293,10 +296,10 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT BUT NOT FROM AT CREATOR
|
// Send trade info to AT BUT NOT FROM AT CREATOR
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress);
|
||||||
|
|
||||||
BlockUtils.mintBlock(repository);
|
BlockUtils.mintBlock(repository);
|
||||||
@ -309,10 +312,10 @@ public class AtTests extends Common {
|
|||||||
describeAt(repository, atAddress);
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
// AT should still be in OFFER mode
|
// AT should still be in OFFER mode
|
||||||
assertEquals(BTCACCT.Mode.OFFERING, tradeData.mode);
|
assertEquals(AcctMode.OFFERING, tradeData.mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,10 +337,10 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT
|
// Send trade info to AT
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
Block postDeploymentBlock = BlockUtils.mintBlock(repository);
|
Block postDeploymentBlock = BlockUtils.mintBlock(repository);
|
||||||
@ -356,8 +359,8 @@ public class AtTests extends Common {
|
|||||||
assertTrue(atData.getIsFinished());
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should be in REFUNDED mode
|
// AT should be in REFUNDED mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.REFUNDED, tradeData.mode);
|
assertEquals(AcctMode.REFUNDED, tradeData.mode);
|
||||||
|
|
||||||
// Test orphaning
|
// Test orphaning
|
||||||
BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight);
|
BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight);
|
||||||
@ -388,17 +391,17 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT
|
// Send trade info to AT
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
// Give AT time to process message
|
// Give AT time to process message
|
||||||
BlockUtils.mintBlock(repository);
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
// Send correct secrets to AT, from correct account
|
// Send correct secrets to AT, from correct account
|
||||||
messageData = BTCACCT.buildRedeemMessage(secretA, secretB, partner.getAddress());
|
messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress());
|
||||||
messageTransaction = sendMessage(repository, partner, messageData, atAddress);
|
messageTransaction = sendMessage(repository, partner, messageData, atAddress);
|
||||||
|
|
||||||
// AT should send funds in the next block
|
// AT should send funds in the next block
|
||||||
@ -412,8 +415,8 @@ public class AtTests extends Common {
|
|||||||
assertTrue(atData.getIsFinished());
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should be in REDEEMED mode
|
// AT should be in REDEEMED mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.REDEEMED, tradeData.mode);
|
assertEquals(AcctMode.REDEEMED, tradeData.mode);
|
||||||
|
|
||||||
// Check balances
|
// Check balances
|
||||||
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount;
|
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount;
|
||||||
@ -459,17 +462,17 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT
|
// Send trade info to AT
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
// Give AT time to process message
|
// Give AT time to process message
|
||||||
BlockUtils.mintBlock(repository);
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
// Send correct secrets to AT, but from wrong account
|
// Send correct secrets to AT, but from wrong account
|
||||||
messageData = BTCACCT.buildRedeemMessage(secretA, secretB, partner.getAddress());
|
messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, partner.getAddress());
|
||||||
messageTransaction = sendMessage(repository, bystander, messageData, atAddress);
|
messageTransaction = sendMessage(repository, bystander, messageData, atAddress);
|
||||||
|
|
||||||
// AT should NOT send funds in the next block
|
// AT should NOT send funds in the next block
|
||||||
@ -483,8 +486,8 @@ public class AtTests extends Common {
|
|||||||
assertFalse(atData.getIsFinished());
|
assertFalse(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should still be in TRADE mode
|
// AT should still be in TRADE mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.TRADING, tradeData.mode);
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
|
||||||
// Check balances
|
// Check balances
|
||||||
long expectedBalance = partnersInitialBalance;
|
long expectedBalance = partnersInitialBalance;
|
||||||
@ -517,10 +520,10 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT
|
// Send trade info to AT
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
// Give AT time to process message
|
// Give AT time to process message
|
||||||
@ -529,7 +532,7 @@ public class AtTests extends Common {
|
|||||||
// Send incorrect secrets to AT, from correct account
|
// Send incorrect secrets to AT, from correct account
|
||||||
byte[] wrongSecret = new byte[32];
|
byte[] wrongSecret = new byte[32];
|
||||||
RANDOM.nextBytes(wrongSecret);
|
RANDOM.nextBytes(wrongSecret);
|
||||||
messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB, partner.getAddress());
|
messageData = BitcoinACCTv1.buildRedeemMessage(wrongSecret, secretB, partner.getAddress());
|
||||||
messageTransaction = sendMessage(repository, partner, messageData, atAddress);
|
messageTransaction = sendMessage(repository, partner, messageData, atAddress);
|
||||||
|
|
||||||
// AT should NOT send funds in the next block
|
// AT should NOT send funds in the next block
|
||||||
@ -543,8 +546,8 @@ public class AtTests extends Common {
|
|||||||
assertFalse(atData.getIsFinished());
|
assertFalse(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should still be in TRADE mode
|
// AT should still be in TRADE mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.TRADING, tradeData.mode);
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
|
||||||
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee();
|
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee();
|
||||||
long actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
long actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
||||||
@ -552,7 +555,7 @@ public class AtTests extends Common {
|
|||||||
assertEquals("Partner's balance incorrect", expectedBalance, actualBalance);
|
assertEquals("Partner's balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
// Send incorrect secrets to AT, from correct account
|
// Send incorrect secrets to AT, from correct account
|
||||||
messageData = BTCACCT.buildRedeemMessage(secretA, wrongSecret, partner.getAddress());
|
messageData = BitcoinACCTv1.buildRedeemMessage(secretA, wrongSecret, partner.getAddress());
|
||||||
messageTransaction = sendMessage(repository, partner, messageData, atAddress);
|
messageTransaction = sendMessage(repository, partner, messageData, atAddress);
|
||||||
|
|
||||||
// AT should NOT send funds in the next block
|
// AT should NOT send funds in the next block
|
||||||
@ -565,8 +568,8 @@ public class AtTests extends Common {
|
|||||||
assertFalse(atData.getIsFinished());
|
assertFalse(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should still be in TRADE mode
|
// AT should still be in TRADE mode
|
||||||
tradeData = BTCACCT.populateTradeData(repository, atData);
|
tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.TRADING, tradeData.mode);
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
|
||||||
// Check balances
|
// Check balances
|
||||||
expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2;
|
expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2;
|
||||||
@ -597,10 +600,10 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
// Send trade info to AT
|
// Send trade info to AT
|
||||||
byte[] messageData = BTCACCT.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
byte[] messageData = BitcoinACCTv1.buildTradeMessage(partner.getAddress(), bitcoinPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
|
||||||
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
// Give AT time to process message
|
// Give AT time to process message
|
||||||
@ -621,8 +624,8 @@ public class AtTests extends Common {
|
|||||||
assertFalse(atData.getIsFinished());
|
assertFalse(atData.getIsFinished());
|
||||||
|
|
||||||
// AT should be in TRADING mode
|
// AT should be in TRADING mode
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
assertEquals(BTCACCT.Mode.TRADING, tradeData.mode);
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -654,7 +657,7 @@ public class AtTests extends Common {
|
|||||||
HashCode.fromBytes(codeHash)));
|
HashCode.fromBytes(codeHash)));
|
||||||
|
|
||||||
// Not one of ours?
|
// Not one of ours?
|
||||||
if (!Arrays.equals(codeHash, BTCACCT.CODE_BYTES_HASH))
|
if (!Arrays.equals(codeHash, BitcoinACCTv1.CODE_BYTES_HASH))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
describeAt(repository, atAddress);
|
describeAt(repository, atAddress);
|
||||||
@ -667,7 +670,7 @@ public class AtTests extends Common {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException {
|
private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException {
|
||||||
byte[] creationBytes = BTCACCT.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout);
|
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAddress, bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout);
|
||||||
|
|
||||||
long txTimestamp = System.currentTimeMillis();
|
long txTimestamp = System.currentTimeMillis();
|
||||||
byte[] lastReference = deployer.getLastReference();
|
byte[] lastReference = deployer.getLastReference();
|
||||||
@ -744,7 +747,7 @@ public class AtTests extends Common {
|
|||||||
|
|
||||||
private void describeAt(Repository repository, String atAddress) throws DataException {
|
private void describeAt(Repository repository, String atAddress) throws DataException {
|
||||||
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData);
|
CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
|
Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
|
||||||
int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight();
|
int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight();
|
||||||
@ -760,17 +763,17 @@ public class AtTests extends Common {
|
|||||||
+ "\texpected bitcoin: %s BTC,\n"
|
+ "\texpected bitcoin: %s BTC,\n"
|
||||||
+ "\tcurrent block height: %d,\n",
|
+ "\tcurrent block height: %d,\n",
|
||||||
tradeData.qortalAtAddress,
|
tradeData.qortalAtAddress,
|
||||||
tradeData.mode.name(),
|
tradeData.mode,
|
||||||
tradeData.qortalCreator,
|
tradeData.qortalCreator,
|
||||||
epochMilliFormatter.apply(tradeData.creationTimestamp),
|
epochMilliFormatter.apply(tradeData.creationTimestamp),
|
||||||
Amounts.prettyAmount(tradeData.qortBalance),
|
Amounts.prettyAmount(tradeData.qortBalance),
|
||||||
atData.getIsFinished(),
|
atData.getIsFinished(),
|
||||||
HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40),
|
HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40),
|
||||||
Amounts.prettyAmount(tradeData.qortAmount),
|
Amounts.prettyAmount(tradeData.qortAmount),
|
||||||
Amounts.prettyAmount(tradeData.expectedBitcoin),
|
Amounts.prettyAmount(tradeData.expectedForeignAmount),
|
||||||
currentBlockHeight));
|
currentBlockHeight));
|
||||||
|
|
||||||
if (tradeData.mode != BTCACCT.Mode.OFFERING && tradeData.mode != BTCACCT.Mode.CANCELLED) {
|
if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) {
|
||||||
System.out.println(String.format("\trefund height: block %d,\n"
|
System.out.println(String.format("\trefund height: block %d,\n"
|
||||||
+ "\tHASH160 of secret-A: %s,\n"
|
+ "\tHASH160 of secret-A: %s,\n"
|
||||||
+ "\tBitcoin P2SH-A nLockTime: %d (%s),\n"
|
+ "\tBitcoin P2SH-A nLockTime: %d (%s),\n"
|
@ -1,12 +1,15 @@
|
|||||||
package org.qortal.test.btcacct;
|
package org.qortal.test.crosschain.bitcoinv1;
|
||||||
|
|
||||||
import java.security.Security;
|
import org.bitcoinj.core.Address;
|
||||||
|
import org.bitcoinj.core.AddressFormatException;
|
||||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
import org.bitcoinj.core.LegacyAddress;
|
||||||
|
import org.bitcoinj.core.NetworkParameters;
|
||||||
import org.qortal.account.PrivateKeyAccount;
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
import org.qortal.asset.Asset;
|
import org.qortal.asset.Asset;
|
||||||
import org.qortal.controller.Controller;
|
import org.qortal.controller.Controller;
|
||||||
import org.qortal.crosschain.BTCACCT;
|
import org.qortal.crosschain.Bitcoin;
|
||||||
|
import org.qortal.crosschain.BitcoinACCTv1;
|
||||||
|
import org.qortal.crosschain.Bitcoiny;
|
||||||
import org.qortal.data.transaction.BaseTransactionData;
|
import org.qortal.data.transaction.BaseTransactionData;
|
||||||
import org.qortal.data.transaction.DeployAtTransactionData;
|
import org.qortal.data.transaction.DeployAtTransactionData;
|
||||||
import org.qortal.data.transaction.TransactionData;
|
import org.qortal.data.transaction.TransactionData;
|
||||||
@ -16,7 +19,7 @@ import org.qortal.repository.Repository;
|
|||||||
import org.qortal.repository.RepositoryFactory;
|
import org.qortal.repository.RepositoryFactory;
|
||||||
import org.qortal.repository.RepositoryManager;
|
import org.qortal.repository.RepositoryManager;
|
||||||
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||||
import org.qortal.settings.Settings;
|
import org.qortal.test.crosschain.apps.Common;
|
||||||
import org.qortal.transaction.DeployAtTransaction;
|
import org.qortal.transaction.DeployAtTransaction;
|
||||||
import org.qortal.transaction.Transaction;
|
import org.qortal.transaction.Transaction;
|
||||||
import org.qortal.transform.TransformationException;
|
import org.qortal.transform.TransformationException;
|
||||||
@ -28,20 +31,18 @@ import com.google.common.hash.HashCode;
|
|||||||
|
|
||||||
public class DeployAT {
|
public class DeployAT {
|
||||||
|
|
||||||
public static final long atFundingExtra = 2000000L;
|
|
||||||
|
|
||||||
private static void usage(String error) {
|
private static void usage(String error) {
|
||||||
if (error != null)
|
if (error != null)
|
||||||
System.err.println(error);
|
System.err.println(error);
|
||||||
|
|
||||||
System.err.println(String.format("usage: DeployAT <your Qortal PRIVATE key> <QORT amount> <BTC amount> <your Bitcoin PKH> <HASH160-of-secret> <AT funding amount> <trade-timeout>"));
|
System.err.println(String.format("usage: DeployAT <your Qortal PRIVATE key> <QORT amount> <AT funding amount> <BTC amount> <your Bitcoin PKH/P2PKH> <HASH160-of-secret> <trade-timeout>"));
|
||||||
System.err.println(String.format("example: DeployAT "
|
System.err.println(String.format("example: DeployAT "
|
||||||
+ "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n"
|
+ "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n"
|
||||||
+ "\t80.4020 \\\n"
|
+ "\t10 \\\n"
|
||||||
|
+ "\t10.1 \\\n"
|
||||||
+ "\t0.00864200 \\\n"
|
+ "\t0.00864200 \\\n"
|
||||||
+ "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n"
|
+ "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb (or mrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h) \\\n"
|
||||||
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
||||||
+ "\t123.456 \\\n"
|
|
||||||
+ "\t10080"));
|
+ "\t10080"));
|
||||||
System.exit(1);
|
System.exit(1);
|
||||||
}
|
}
|
||||||
@ -50,15 +51,17 @@ public class DeployAT {
|
|||||||
if (args.length != 7)
|
if (args.length != 7)
|
||||||
usage(null);
|
usage(null);
|
||||||
|
|
||||||
Security.insertProviderAt(new BouncyCastleProvider(), 0);
|
Common.init();
|
||||||
Settings.fileInstance("settings-test.json");
|
|
||||||
|
Bitcoiny bitcoiny = Bitcoin.getInstance();
|
||||||
|
NetworkParameters params = bitcoiny.getNetworkParameters();
|
||||||
|
|
||||||
byte[] refundPrivateKey = null;
|
byte[] refundPrivateKey = null;
|
||||||
long redeemAmount = 0;
|
long redeemAmount = 0;
|
||||||
|
long fundingAmount = 0;
|
||||||
long expectedBitcoin = 0;
|
long expectedBitcoin = 0;
|
||||||
byte[] bitcoinPublicKeyHash = null;
|
byte[] bitcoinPublicKeyHash = null;
|
||||||
byte[] secretHash = null;
|
byte[] hashOfSecret = null;
|
||||||
long fundingAmount = 0;
|
|
||||||
int tradeTimeout = 0;
|
int tradeTimeout = 0;
|
||||||
|
|
||||||
int argIndex = 0;
|
int argIndex = 0;
|
||||||
@ -71,22 +74,30 @@ public class DeployAT {
|
|||||||
if (redeemAmount <= 0)
|
if (redeemAmount <= 0)
|
||||||
usage("QORT amount must be positive");
|
usage("QORT amount must be positive");
|
||||||
|
|
||||||
|
fundingAmount = Long.parseLong(args[argIndex++]);
|
||||||
|
if (fundingAmount <= redeemAmount)
|
||||||
|
usage("AT funding amount must be greater than QORT redeem amount");
|
||||||
|
|
||||||
expectedBitcoin = Long.parseLong(args[argIndex++]);
|
expectedBitcoin = Long.parseLong(args[argIndex++]);
|
||||||
if (expectedBitcoin <= 0)
|
if (expectedBitcoin <= 0)
|
||||||
usage("Expected BTC amount must be positive");
|
usage("Expected BTC amount must be positive");
|
||||||
|
|
||||||
bitcoinPublicKeyHash = HashCode.fromString(args[argIndex++]).asBytes();
|
String bitcoinPKHish = args[argIndex++];
|
||||||
|
// Try P2PKH first
|
||||||
|
try {
|
||||||
|
Address bitcoinAddress = LegacyAddress.fromBase58(params, bitcoinPKHish);
|
||||||
|
bitcoinPublicKeyHash = bitcoinAddress.getHash();
|
||||||
|
} catch (AddressFormatException e) {
|
||||||
|
// Try parsing as PKH hex string instead
|
||||||
|
bitcoinPublicKeyHash = HashCode.fromString(bitcoinPKHish).asBytes();
|
||||||
|
}
|
||||||
if (bitcoinPublicKeyHash.length != 20)
|
if (bitcoinPublicKeyHash.length != 20)
|
||||||
usage("Bitcoin PKH must be 20 bytes");
|
usage("Bitcoin PKH must be 20 bytes");
|
||||||
|
|
||||||
secretHash = HashCode.fromString(args[argIndex++]).asBytes();
|
hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
if (secretHash.length != 20)
|
if (hashOfSecret.length != 20)
|
||||||
usage("Hash of secret must be 20 bytes");
|
usage("Hash of secret must be 20 bytes");
|
||||||
|
|
||||||
fundingAmount = Long.parseLong(args[argIndex++]);
|
|
||||||
if (fundingAmount <= redeemAmount)
|
|
||||||
usage("AT funding amount must be greater than QORT redeem amount");
|
|
||||||
|
|
||||||
tradeTimeout = Integer.parseInt(args[argIndex++]);
|
tradeTimeout = Integer.parseInt(args[argIndex++]);
|
||||||
if (tradeTimeout < 60 || tradeTimeout > 50000)
|
if (tradeTimeout < 60 || tradeTimeout > 50000)
|
||||||
usage("Trade timeout (minutes) must be between 60 and 50000");
|
usage("Trade timeout (minutes) must be between 60 and 50000");
|
||||||
@ -98,12 +109,11 @@ public class DeployAT {
|
|||||||
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||||
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
throw new RuntimeException("Repository startup issue: " + e.getMessage());
|
System.err.println(String.format("Repository start-up issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
try (final Repository repository = RepositoryManager.getRepository()) {
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
System.out.println("Confirm the following is correct based on the info you've given:");
|
|
||||||
|
|
||||||
PrivateKeyAccount refundAccount = new PrivateKeyAccount(repository, refundPrivateKey);
|
PrivateKeyAccount refundAccount = new PrivateKeyAccount(repository, refundPrivateKey);
|
||||||
System.out.println(String.format("Refund Qortal address: %s", refundAccount.getAddress()));
|
System.out.println(String.format("Refund Qortal address: %s", refundAccount.getAddress()));
|
||||||
|
|
||||||
@ -111,11 +121,11 @@ public class DeployAT {
|
|||||||
|
|
||||||
System.out.println(String.format("AT funding amount: %s", Amounts.prettyAmount(fundingAmount)));
|
System.out.println(String.format("AT funding amount: %s", Amounts.prettyAmount(fundingAmount)));
|
||||||
|
|
||||||
System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(secretHash)));
|
System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(hashOfSecret)));
|
||||||
|
|
||||||
// Deploy AT
|
// Deploy AT
|
||||||
byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout);
|
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecret, redeemAmount, expectedBitcoin, tradeTimeout);
|
||||||
System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
|
System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
|
||||||
|
|
||||||
long txTimestamp = System.currentTimeMillis();
|
long txTimestamp = System.currentTimeMillis();
|
||||||
byte[] lastReference = refundAccount.getLastReference();
|
byte[] lastReference = refundAccount.getLastReference();
|
||||||
@ -149,11 +159,10 @@ public class DeployAT {
|
|||||||
System.exit(2);
|
System.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
System.out.println(String.format("\nSigned transaction in base58, ready for POST /transactions/process:\n%s\n", Base58.encode(signedBytes)));
|
System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes)));
|
||||||
} catch (NumberFormatException e) {
|
|
||||||
usage(String.format("Number format exception: %s", e.getMessage()));
|
|
||||||
} catch (DataException e) {
|
} catch (DataException e) {
|
||||||
throw new RuntimeException("Repository issue: " + e.getMessage());
|
System.err.println(String.format("Repository issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,150 @@
|
|||||||
|
package org.qortal.test.crosschain.litecoinv1;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
import org.bitcoinj.core.ECKey;
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.asset.Asset;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.crosschain.LitecoinACCTv1;
|
||||||
|
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.RepositoryFactory;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||||
|
import org.qortal.test.crosschain.apps.Common;
|
||||||
|
import org.qortal.transaction.DeployAtTransaction;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
import org.qortal.utils.Amounts;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class DeployAT {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: DeployAT <your Qortal PRIVATE key> <QORT amount> <AT funding amount> <LTC amount> <trade-timeout>"));
|
||||||
|
System.err.println("A trading key-pair will be generated for you!");
|
||||||
|
System.err.println(String.format("example: DeployAT "
|
||||||
|
+ "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n"
|
||||||
|
+ "\t10 \\\n"
|
||||||
|
+ "\t10.1 \\\n"
|
||||||
|
+ "\t0.00864200 \\\n"
|
||||||
|
+ "\t120"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 5)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
byte[] creatorPrivateKey = null;
|
||||||
|
long redeemAmount = 0;
|
||||||
|
long fundingAmount = 0;
|
||||||
|
long expectedLitecoin = 0;
|
||||||
|
int tradeTimeout = 0;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
creatorPrivateKey = Base58.decode(args[argIndex++]);
|
||||||
|
if (creatorPrivateKey.length != 32)
|
||||||
|
usage("Refund private key must be 32 bytes");
|
||||||
|
|
||||||
|
redeemAmount = new BigDecimal(args[argIndex++]).setScale(8).unscaledValue().longValue();
|
||||||
|
if (redeemAmount <= 0)
|
||||||
|
usage("QORT amount must be positive");
|
||||||
|
|
||||||
|
fundingAmount = new BigDecimal(args[argIndex++]).setScale(8).unscaledValue().longValue();
|
||||||
|
if (fundingAmount <= redeemAmount)
|
||||||
|
usage("AT funding amount must be greater than QORT redeem amount");
|
||||||
|
|
||||||
|
expectedLitecoin = new BigDecimal(args[argIndex++]).setScale(8).unscaledValue().longValue();
|
||||||
|
if (expectedLitecoin <= 0)
|
||||||
|
usage("Expected LTC amount must be positive");
|
||||||
|
|
||||||
|
tradeTimeout = Integer.parseInt(args[argIndex++]);
|
||||||
|
if (tradeTimeout < 60 || tradeTimeout > 50000)
|
||||||
|
usage("Trade timeout (minutes) must be between 60 and 50000");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||||
|
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository start-up issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
PrivateKeyAccount creatorAccount = new PrivateKeyAccount(repository, creatorPrivateKey);
|
||||||
|
System.out.println(String.format("Creator Qortal address: %s", creatorAccount.getAddress()));
|
||||||
|
System.out.println(String.format("QORT redeem amount: %s", Amounts.prettyAmount(redeemAmount)));
|
||||||
|
System.out.println(String.format("AT funding amount: %s", Amounts.prettyAmount(fundingAmount)));
|
||||||
|
|
||||||
|
// Generate trading key-pair
|
||||||
|
byte[] tradePrivateKey = new ECKey().getPrivKeyBytes();
|
||||||
|
PrivateKeyAccount tradeAccount = new PrivateKeyAccount(repository, tradePrivateKey);
|
||||||
|
byte[] litecoinPublicKeyHash = ECKey.fromPrivate(tradePrivateKey).getPubKeyHash();
|
||||||
|
|
||||||
|
System.out.println(String.format("Trade private key: %s", HashCode.fromBytes(tradePrivateKey)));
|
||||||
|
|
||||||
|
// Deploy AT
|
||||||
|
byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, expectedLitecoin, tradeTimeout);
|
||||||
|
System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
|
||||||
|
|
||||||
|
long txTimestamp = System.currentTimeMillis();
|
||||||
|
byte[] lastReference = creatorAccount.getLastReference();
|
||||||
|
|
||||||
|
if (lastReference == null) {
|
||||||
|
System.err.println(String.format("Qortal account %s has no last reference", creatorAccount.getAddress()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
Long fee = null;
|
||||||
|
String name = "QORT-LTC cross-chain trade";
|
||||||
|
String description = String.format("Qortal-Litecoin cross-chain trade");
|
||||||
|
String atType = "ACCT";
|
||||||
|
String tags = "QORT-LTC ACCT";
|
||||||
|
|
||||||
|
BaseTransactionData baseTransactionData = new BaseTransactionData(txTimestamp, Group.NO_GROUP, lastReference, creatorAccount.getPublicKey(), fee, null);
|
||||||
|
DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, atType, tags, creationBytes, fundingAmount, Asset.QORT);
|
||||||
|
|
||||||
|
DeployAtTransaction deployAtTransaction = new DeployAtTransaction(repository, deployAtTransactionData);
|
||||||
|
|
||||||
|
fee = deployAtTransaction.calcRecommendedFee();
|
||||||
|
deployAtTransactionData.setFee(fee);
|
||||||
|
|
||||||
|
deployAtTransaction.sign(creatorAccount);
|
||||||
|
|
||||||
|
byte[] signedBytes = null;
|
||||||
|
try {
|
||||||
|
signedBytes = TransactionTransformer.toBytes(deployAtTransactionData);
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
System.err.println(String.format("Unable to convert transaction to base58: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeployAtTransaction.ensureATAddress(deployAtTransactionData);
|
||||||
|
String atAddress = deployAtTransactionData.getAtAddress();
|
||||||
|
|
||||||
|
System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes)));
|
||||||
|
|
||||||
|
System.out.println(String.format("AT address: %s", atAddress));
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,770 @@
|
|||||||
|
package org.qortal.test.crosschain.litecoinv1;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
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.junit.Before;
|
||||||
|
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.LitecoinACCTv1;
|
||||||
|
import org.qortal.crosschain.AcctMode;
|
||||||
|
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.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.utils.Amounts;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
import com.google.common.primitives.Bytes;
|
||||||
|
|
||||||
|
public class LitecoinACCTv1Tests extends Common {
|
||||||
|
|
||||||
|
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[] litecoinPublicKeyHash = HashCode.fromString("bb00bb11bb22bb33bb44bb55bb66bb77bb88bb99").asBytes();
|
||||||
|
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 litecoinAmount = 864200L; // 0.00864200 LTC
|
||||||
|
|
||||||
|
private static final Random RANDOM = new Random();
|
||||||
|
|
||||||
|
@Before
|
||||||
|
public void beforeTest() throws DataException {
|
||||||
|
Common.useDefaultSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void testCompile() {
|
||||||
|
PrivateKeyAccount tradeAccount = createTradeAccount(null);
|
||||||
|
|
||||||
|
byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAccount.getAddress(), litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout);
|
||||||
|
assertNotNull(creationBytes);
|
||||||
|
|
||||||
|
System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
|
||||||
|
long expectedBalance = deployersInitialBalance - fundingAmount - deployAtTransaction.getTransactionData().getFee();
|
||||||
|
long actualBalance = deployer.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Deployer's post-deployment balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
expectedBalance = fundingAmount;
|
||||||
|
actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("AT's post-deployment balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
expectedBalance = partnersInitialBalance;
|
||||||
|
actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Partner's post-deployment balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
// Test orphaning
|
||||||
|
BlockUtils.orphanLastBlock(repository);
|
||||||
|
|
||||||
|
expectedBalance = deployersInitialBalance;
|
||||||
|
actualBalance = deployer.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Deployer's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
expectedBalance = 0;
|
||||||
|
actualBalance = deployAtTransaction.getATAccount().getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("AT's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
expectedBalance = partnersInitialBalance;
|
||||||
|
actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Partner's post-orphan/pre-deployment balance incorrect", expectedBalance, actualBalance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
|
||||||
|
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
|
||||||
|
|
||||||
|
// Send creator's address to AT, instead of typical partner's address
|
||||||
|
byte[] messageData = LitecoinACCTv1.getInstance().buildCancelMessage(deployer.getAddress());
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, deployer, messageData, atAddress);
|
||||||
|
long messageFee = messageTransaction.getTransactionData().getFee();
|
||||||
|
|
||||||
|
// AT should process 'cancel' message in next block
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
|
// Check AT is finished
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
|
// AT should be in CANCELLED mode
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.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);
|
||||||
|
|
||||||
|
assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
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 but cancel code doesn't care about message content, so should be ok
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
|
// Check AT is finished
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
|
// AT should be in CANCELLED mode
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.CANCELLED, tradeData.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
|
Block postDeploymentBlock = BlockUtils.mintBlock(repository);
|
||||||
|
int postDeploymentBlockHeight = postDeploymentBlock.getBlockData().getHeight();
|
||||||
|
|
||||||
|
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
|
||||||
|
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
|
||||||
|
|
||||||
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
|
// AT should be in TRADE mode
|
||||||
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
|
||||||
|
// Check hashOfSecretA was extracted correctly
|
||||||
|
assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA));
|
||||||
|
|
||||||
|
// Check trade partner Qortal address was extracted correctly
|
||||||
|
assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress);
|
||||||
|
|
||||||
|
// Check trade partner's Litecoin PKH was extracted correctly
|
||||||
|
assertTrue(Arrays.equals(litecoinPublicKeyHash, tradeData.partnerForeignPKH));
|
||||||
|
|
||||||
|
// Test orphaning
|
||||||
|
BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight);
|
||||||
|
|
||||||
|
// Check balances
|
||||||
|
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 {
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT BUT NOT FROM AT CREATOR
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, bystander, messageData, atAddress);
|
||||||
|
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
long expectedBalance = partnersInitialBalance;
|
||||||
|
long actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Partner's post-initial-payout balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
|
// AT should still be in OFFER mode
|
||||||
|
assertEquals(AcctMode.OFFERING, tradeData.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
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 deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
|
||||||
|
|
||||||
|
checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee);
|
||||||
|
|
||||||
|
describeAt(repository, atAddress);
|
||||||
|
|
||||||
|
// Check AT is finished
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
assertTrue(atData.getIsFinished());
|
||||||
|
|
||||||
|
// AT should be in REFUNDED mode
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.REFUNDED, tradeData.mode);
|
||||||
|
|
||||||
|
// Test orphaning
|
||||||
|
BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight);
|
||||||
|
|
||||||
|
// Check balances
|
||||||
|
long expectedBalance = deployersPostDeploymentBalance;
|
||||||
|
long actualBalance = deployer.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Deployer's post-orphan/pre-refund balance incorrect", expectedBalance, actualBalance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
public void testCorrectSecretCorrectSender() 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, tradeAccount.getAddress());
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
|
// Give AT time to process message
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
// Send correct secret to AT, from correct account
|
||||||
|
messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress());
|
||||||
|
messageTransaction = sendMessage(repository, partner, 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());
|
||||||
|
|
||||||
|
// AT should be in REDEEMED mode
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.REDEEMED, tradeData.mode);
|
||||||
|
|
||||||
|
// Check balances
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Check balances
|
||||||
|
expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee();
|
||||||
|
actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Partner's post-orphan/pre-redeem balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
// Check AT state
|
||||||
|
ATStateData postOrphanAtStateData = repository.getATRepository().getLatestATState(atAddress);
|
||||||
|
|
||||||
|
assertTrue("AT states mismatch", Arrays.equals(preRedeemAtStateData.getStateData(), postOrphanAtStateData.getStateData()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
public void testCorrectSecretIncorrectSender() 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, tradeAccount.getAddress());
|
||||||
|
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
|
||||||
|
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
|
// Give AT time to process message
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
// Send correct secret to AT, but from wrong account
|
||||||
|
messageData = LitecoinACCTv1.buildRedeemMessage(secretA, partner.getAddress());
|
||||||
|
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());
|
||||||
|
|
||||||
|
// AT should still be in TRADE mode
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
public void testIncorrectSecretCorrectSender() 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, tradeAccount.getAddress());
|
||||||
|
long deployAtFee = deployAtTransaction.getTransactionData().getFee();
|
||||||
|
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
|
// Give AT time to process message
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
// Send incorrect secret to AT, from correct account
|
||||||
|
byte[] wrongSecret = new byte[32];
|
||||||
|
RANDOM.nextBytes(wrongSecret);
|
||||||
|
messageData = LitecoinACCTv1.buildRedeemMessage(wrongSecret, partner.getAddress());
|
||||||
|
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 still be in TRADE mode
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
|
||||||
|
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee();
|
||||||
|
long actualBalance = partner.getConfirmedBalance(Asset.QORT);
|
||||||
|
|
||||||
|
assertEquals("Partner's balance incorrect", expectedBalance, actualBalance);
|
||||||
|
|
||||||
|
// Check eventual refund
|
||||||
|
checkTradeRefund(repository, deployer, deployersInitialBalance, deployAtFee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
public void testCorrectSecretCorrectSenderInvalidMessageLength() 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, tradeAccount.getAddress());
|
||||||
|
Account at = deployAtTransaction.getATAccount();
|
||||||
|
String atAddress = at.getAddress();
|
||||||
|
|
||||||
|
long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis();
|
||||||
|
int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp);
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(partnersOfferMessageTransactionTimestamp, lockTimeA);
|
||||||
|
|
||||||
|
// Send trade info to AT
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partner.getAddress(), litecoinPublicKeyHash, hashOfSecretA, lockTimeA, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = sendMessage(repository, tradeAccount, messageData, atAddress);
|
||||||
|
|
||||||
|
// Give AT time to process message
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
// Send correct secret to AT, from correct account, but missing receive address, hence incorrect length
|
||||||
|
messageData = Bytes.concat(secretA);
|
||||||
|
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 = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
assertEquals(AcctMode.TRADING, tradeData.mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unused")
|
||||||
|
@Test
|
||||||
|
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, tradeAccount.getAddress());
|
||||||
|
|
||||||
|
List<ATData> executableAts = repository.getATRepository().getAllExecutableATs();
|
||||||
|
|
||||||
|
for (ATData atData : executableAts) {
|
||||||
|
String atAddress = atData.getATAddress();
|
||||||
|
byte[] codeBytes = atData.getCodeBytes();
|
||||||
|
byte[] codeHash = Crypto.digest(codeBytes);
|
||||||
|
|
||||||
|
System.out.println(String.format("%s: code length: %d byte%s, code hash: %s",
|
||||||
|
atAddress,
|
||||||
|
codeBytes.length,
|
||||||
|
(codeBytes.length != 1 ? "s": ""),
|
||||||
|
HashCode.fromBytes(codeHash)));
|
||||||
|
|
||||||
|
// Not one of ours?
|
||||||
|
if (!Arrays.equals(codeHash, LitecoinACCTv1.CODE_BYTES_HASH))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
describeAt(repository, atAddress);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int calcTestLockTimeA(long messageTimestamp) {
|
||||||
|
return (int) (messageTimestamp / 1000L + tradeTimeout * 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeployAtTransaction doDeploy(Repository repository, PrivateKeyAccount deployer, String tradeAddress) throws DataException {
|
||||||
|
byte[] creationBytes = LitecoinACCTv1.buildQortalAT(tradeAddress, litecoinPublicKeyHash, redeemAmount, litecoinAmount, tradeTimeout);
|
||||||
|
|
||||||
|
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 = "QORT-LTC cross-chain trade";
|
||||||
|
String description = String.format("Qortal-Litecoin cross-chain trade");
|
||||||
|
String atType = "ACCT";
|
||||||
|
String tags = "QORT-LTC ACCT";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkTradeRefund(Repository repository, Account deployer, long deployersInitialBalance, long deployAtFee) throws DataException {
|
||||||
|
long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee;
|
||||||
|
int refundTimeout = tradeTimeout / 2 + 1; // close enough
|
||||||
|
|
||||||
|
// AT should automatically refund deployer after 'refundTimeout' blocks
|
||||||
|
for (int blockCount = 0; blockCount <= refundTimeout; ++blockCount)
|
||||||
|
BlockUtils.mintBlock(repository);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void describeAt(Repository repository, String atAddress) throws DataException {
|
||||||
|
ATData atData = repository.getATRepository().fromATAddress(atAddress);
|
||||||
|
CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
|
||||||
|
|
||||||
|
Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
|
||||||
|
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"
|
||||||
|
+ "\tis finished: %b,\n"
|
||||||
|
+ "\tredeem payout: %s QORT,\n"
|
||||||
|
+ "\texpected Litecoin: %s LTC,\n"
|
||||||
|
+ "\tcurrent block height: %d,\n",
|
||||||
|
tradeData.qortalAtAddress,
|
||||||
|
tradeData.mode,
|
||||||
|
tradeData.qortalCreator,
|
||||||
|
epochMilliFormatter.apply(tradeData.creationTimestamp),
|
||||||
|
Amounts.prettyAmount(tradeData.qortBalance),
|
||||||
|
atData.getIsFinished(),
|
||||||
|
Amounts.prettyAmount(tradeData.qortAmount),
|
||||||
|
Amounts.prettyAmount(tradeData.expectedForeignAmount),
|
||||||
|
currentBlockHeight));
|
||||||
|
|
||||||
|
if (tradeData.mode != AcctMode.OFFERING && tradeData.mode != AcctMode.CANCELLED) {
|
||||||
|
System.out.println(String.format("\trefund timeout: %d minutes,\n"
|
||||||
|
+ "\trefund height: block %d,\n"
|
||||||
|
+ "\tHASH160 of secret-A: %s,\n"
|
||||||
|
+ "\tLitecoin P2SH-A nLockTime: %d (%s),\n"
|
||||||
|
+ "\ttrade partner: %s\n"
|
||||||
|
+ "\tpartner's receiving address: %s",
|
||||||
|
tradeData.refundTimeout,
|
||||||
|
tradeData.tradeRefundHeight,
|
||||||
|
HashCode.fromBytes(tradeData.hashOfSecretA).toString().substring(0, 40),
|
||||||
|
tradeData.lockTimeA, epochMilliFormatter.apply(tradeData.lockTimeA * 1000L),
|
||||||
|
tradeData.qortalPartnerAddress,
|
||||||
|
tradeData.qortalPartnerReceivingAddress));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PrivateKeyAccount createTradeAccount(Repository repository) {
|
||||||
|
// We actually use a known test account with funds to avoid PoW compute
|
||||||
|
return Common.getTestAccount(repository, "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
package org.qortal.test.crosschain.litecoinv1;
|
||||||
|
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.crosschain.LitecoinACCTv1;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryFactory;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||||
|
import org.qortal.test.crosschain.apps.Common;
|
||||||
|
import org.qortal.transaction.MessageTransaction;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
|
||||||
|
public class SendCancelMessage {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: SendCancelMessage <your Qortal PRIVATE key> <AT address>"));
|
||||||
|
System.err.println(String.format("example: SendCancelMessage "
|
||||||
|
+ "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n"
|
||||||
|
+ "\tAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 2)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
byte[] qortalPrivateKey = null;
|
||||||
|
String atAddress = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
qortalPrivateKey = Base58.decode(args[argIndex++]);
|
||||||
|
if (qortalPrivateKey.length != 32)
|
||||||
|
usage("Refund private key must be 32 bytes");
|
||||||
|
|
||||||
|
atAddress = args[argIndex++];
|
||||||
|
if (!Crypto.isValidAtAddress(atAddress))
|
||||||
|
usage("Invalid AT address");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||||
|
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository start-up issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
PrivateKeyAccount qortalAccount = new PrivateKeyAccount(repository, qortalPrivateKey);
|
||||||
|
|
||||||
|
String creatorQortalAddress = qortalAccount.getAddress();
|
||||||
|
System.out.println(String.format("Qortal address: %s", creatorQortalAddress));
|
||||||
|
|
||||||
|
byte[] messageData = LitecoinACCTv1.getInstance().buildCancelMessage(creatorQortalAddress);
|
||||||
|
MessageTransaction messageTransaction = MessageTransaction.build(repository, qortalAccount, Group.NO_GROUP, atAddress, messageData, false, false);
|
||||||
|
|
||||||
|
System.out.println("Computing nonce...");
|
||||||
|
messageTransaction.computeNonce();
|
||||||
|
messageTransaction.sign(qortalAccount);
|
||||||
|
|
||||||
|
byte[] signedBytes = null;
|
||||||
|
try {
|
||||||
|
signedBytes = TransactionTransformer.toBytes(messageTransaction.getTransactionData());
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
System.err.println(String.format("Unable to convert transaction to bytes: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes)));
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,101 @@
|
|||||||
|
package org.qortal.test.crosschain.litecoinv1;
|
||||||
|
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.crosschain.LitecoinACCTv1;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryFactory;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||||
|
import org.qortal.test.crosschain.apps.Common;
|
||||||
|
import org.qortal.transaction.MessageTransaction;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class SendRedeemMessage {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: SendRedeemMessage <partner trade PRIVATE key> <AT address> <secret> <Qortal receive address>"));
|
||||||
|
System.err.println(String.format("example: SendRedeemMessage "
|
||||||
|
+ "dbfe739f5a3ecf7b0a22cea71f73d86ec71355b740e5972bcdf9e8bb4721ab9d \\\n"
|
||||||
|
+ "\tAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \\\n"
|
||||||
|
+ "\t5468697320737472696e672069732065786163746c7920333220627974657321 \\\n"
|
||||||
|
+ "\tQqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 4)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
byte[] tradePrivateKey = null;
|
||||||
|
String atAddress = null;
|
||||||
|
byte[] secret = null;
|
||||||
|
String receiveAddress = null;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
tradePrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (tradePrivateKey.length != 32)
|
||||||
|
usage("Refund private key must be 32 bytes");
|
||||||
|
|
||||||
|
atAddress = args[argIndex++];
|
||||||
|
if (!Crypto.isValidAtAddress(atAddress))
|
||||||
|
usage("Invalid AT address");
|
||||||
|
|
||||||
|
secret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (secret.length != 32)
|
||||||
|
usage("Secret must be 32 bytes");
|
||||||
|
|
||||||
|
receiveAddress = args[argIndex++];
|
||||||
|
if (!Crypto.isValidAddress(receiveAddress))
|
||||||
|
usage("Invalid Qortal receive address");
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||||
|
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository start-up issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
PrivateKeyAccount tradeAccount = new PrivateKeyAccount(repository, tradePrivateKey);
|
||||||
|
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildRedeemMessage(secret, receiveAddress);
|
||||||
|
MessageTransaction messageTransaction = MessageTransaction.build(repository, tradeAccount, Group.NO_GROUP, atAddress, messageData, false, false);
|
||||||
|
|
||||||
|
System.out.println("Computing nonce...");
|
||||||
|
messageTransaction.computeNonce();
|
||||||
|
messageTransaction.sign(tradeAccount);
|
||||||
|
|
||||||
|
byte[] signedBytes = null;
|
||||||
|
try {
|
||||||
|
signedBytes = TransactionTransformer.toBytes(messageTransaction.getTransactionData());
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
System.err.println(String.format("Unable to convert transaction to bytes: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes)));
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,118 @@
|
|||||||
|
package org.qortal.test.crosschain.litecoinv1;
|
||||||
|
|
||||||
|
import org.qortal.account.PrivateKeyAccount;
|
||||||
|
import org.qortal.controller.Controller;
|
||||||
|
import org.qortal.crosschain.LitecoinACCTv1;
|
||||||
|
import org.qortal.crypto.Crypto;
|
||||||
|
import org.qortal.group.Group;
|
||||||
|
import org.qortal.repository.DataException;
|
||||||
|
import org.qortal.repository.Repository;
|
||||||
|
import org.qortal.repository.RepositoryFactory;
|
||||||
|
import org.qortal.repository.RepositoryManager;
|
||||||
|
import org.qortal.repository.hsqldb.HSQLDBRepositoryFactory;
|
||||||
|
import org.qortal.test.crosschain.apps.Common;
|
||||||
|
import org.qortal.transaction.MessageTransaction;
|
||||||
|
import org.qortal.transform.TransformationException;
|
||||||
|
import org.qortal.transform.transaction.TransactionTransformer;
|
||||||
|
import org.qortal.utils.Base58;
|
||||||
|
import org.qortal.utils.NTP;
|
||||||
|
|
||||||
|
import com.google.common.hash.HashCode;
|
||||||
|
|
||||||
|
public class SendTradeMessage {
|
||||||
|
|
||||||
|
private static void usage(String error) {
|
||||||
|
if (error != null)
|
||||||
|
System.err.println(error);
|
||||||
|
|
||||||
|
System.err.println(String.format("usage: SendTradeMessage <trade PRIVATE key> <AT address> <partner trade Qortal address> <partner tradeLitecoin PKH/P2PKH> <hash-of-secret> <locktime>"));
|
||||||
|
System.err.println(String.format("example: SendTradeMessage "
|
||||||
|
+ "ed77aa2c62d785a9428725fc7f95b907be8a1cc43213239876a62cf70fdb6ecb \\\n"
|
||||||
|
+ "\tAaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \\\n"
|
||||||
|
+ "\tQqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq \\\n"
|
||||||
|
+ "\tffffffffffffffffffffffffffffffffffffffff \\\n"
|
||||||
|
+ "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
|
||||||
|
+ "\t1600184800"));
|
||||||
|
System.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
if (args.length != 6)
|
||||||
|
usage(null);
|
||||||
|
|
||||||
|
Common.init();
|
||||||
|
|
||||||
|
byte[] tradePrivateKey = null;
|
||||||
|
String atAddress = null;
|
||||||
|
String partnerTradeAddress = null;
|
||||||
|
byte[] partnerTradePublicKeyHash = null;
|
||||||
|
byte[] hashOfSecret = null;
|
||||||
|
int lockTime = 0;
|
||||||
|
|
||||||
|
int argIndex = 0;
|
||||||
|
try {
|
||||||
|
tradePrivateKey = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (tradePrivateKey.length != 32)
|
||||||
|
usage("Refund private key must be 32 bytes");
|
||||||
|
|
||||||
|
atAddress = args[argIndex++];
|
||||||
|
if (!Crypto.isValidAtAddress(atAddress))
|
||||||
|
usage("Invalid AT address");
|
||||||
|
|
||||||
|
partnerTradeAddress = args[argIndex++];
|
||||||
|
if (!Crypto.isValidAddress(partnerTradeAddress))
|
||||||
|
usage("Invalid partner trade Qortal address");
|
||||||
|
|
||||||
|
partnerTradePublicKeyHash = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (partnerTradePublicKeyHash.length != 20)
|
||||||
|
usage("Partner trade PKH must be 20 bytes");
|
||||||
|
|
||||||
|
hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes();
|
||||||
|
if (hashOfSecret.length != 20)
|
||||||
|
usage("HASH160 of secret must be 20 bytes");
|
||||||
|
|
||||||
|
lockTime = Integer.parseInt(args[argIndex++]);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl());
|
||||||
|
RepositoryManager.setRepositoryFactory(repositoryFactory);
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository start-up issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (final Repository repository = RepositoryManager.getRepository()) {
|
||||||
|
PrivateKeyAccount tradeAccount = new PrivateKeyAccount(repository, tradePrivateKey);
|
||||||
|
|
||||||
|
int refundTimeout = LitecoinACCTv1.calcRefundTimeout(NTP.getTime(), lockTime);
|
||||||
|
if (refundTimeout < 1) {
|
||||||
|
System.err.println("Refund timeout too small. Is locktime in the past?");
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] messageData = LitecoinACCTv1.buildTradeMessage(partnerTradeAddress, partnerTradePublicKeyHash, hashOfSecret, lockTime, refundTimeout);
|
||||||
|
MessageTransaction messageTransaction = MessageTransaction.build(repository, tradeAccount, Group.NO_GROUP, atAddress, messageData, false, false);
|
||||||
|
|
||||||
|
System.out.println("Computing nonce...");
|
||||||
|
messageTransaction.computeNonce();
|
||||||
|
messageTransaction.sign(tradeAccount);
|
||||||
|
|
||||||
|
byte[] signedBytes = null;
|
||||||
|
try {
|
||||||
|
signedBytes = TransactionTransformer.toBytes(messageTransaction.getTransactionData());
|
||||||
|
} catch (TransformationException e) {
|
||||||
|
System.err.println(String.format("Unable to convert transaction to bytes: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes)));
|
||||||
|
} catch (DataException e) {
|
||||||
|
System.err.println(String.format("Repository issue: %s", e.getMessage()));
|
||||||
|
System.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user