diff --git a/pom.xml b/pom.xml index cb3474ff..e2019691 100644 --- a/pom.xml +++ b/pom.xml @@ -7,7 +7,8 @@ jar true - 0.15.5 + bf9fb80 + 0.15.6 1.64 ${maven.build.timestamp} 1.3.8 @@ -199,6 +200,10 @@ org.qortal.api.model** + + org.qortal.api.model.** + + ${project.build.directory}/generated-sources/package-info @@ -383,6 +388,11 @@ project file:${project.basedir}/lib + + + jitpack.io + https://jitpack.io + @@ -417,6 +427,12 @@ bitcoinj-core ${bitcoinj.version} + + + com.github.jjos2372 + altcoinj + ${altcoinj.version} + com.googlecode.json-simple diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java index 759b8b10..dd7fc4b0 100644 --- a/src/main/java/org/qortal/api/ApiError.java +++ b/src/main/java/org/qortal/api/ApiError.java @@ -15,7 +15,7 @@ public enum ApiError { // COMMON // UNKNOWN(0, 500), JSON(1, 400), - // NO_BALANCE(2, 422), + INSUFFICIENT_BALANCE(2, 402), // NOT_YET_RELEASED(3, 422), UNAUTHORIZED(4, 403), REPOSITORY_ISSUE(5, 500), @@ -126,10 +126,10 @@ public enum ApiError { // Groups GROUP_UNKNOWN(1101, 404), - // Bitcoin - BTC_NETWORK_ISSUE(1201, 500), - BTC_BALANCE_ISSUE(1202, 402), - BTC_TOO_SOON(1203, 408); + // Foreign blockchain + FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500), + FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402), + FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408); private static final Map map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index 25966fa6..5baf2c5d 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -43,6 +43,7 @@ import org.qortal.api.websocket.ActiveChatsWebSocket; import org.qortal.api.websocket.AdminStatusWebSocket; import org.qortal.api.websocket.BlocksWebSocket; import org.qortal.api.websocket.ChatMessagesWebSocket; +import org.qortal.api.websocket.PresenceWebSocket; import org.qortal.api.websocket.TradeBotWebSocket; import org.qortal.api.websocket.TradeOffersWebSocket; import org.qortal.settings.Settings; @@ -200,6 +201,7 @@ public class ApiService { context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages"); context.addServlet(TradeOffersWebSocket.class, "/websockets/crosschain/tradeoffers"); context.addServlet(TradeBotWebSocket.class, "/websockets/crosschain/tradebot"); + context.addServlet(PresenceWebSocket.class, "/websockets/presence"); // Start server this.server.start(); diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinP2SHStatus.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinP2SHStatus.java deleted file mode 100644 index ff986e86..00000000 --- a/src/main/java/org/qortal/api/model/CrossChainBitcoinP2SHStatus.java +++ /dev/null @@ -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() { - } - -} diff --git a/src/main/java/org/qortal/api/model/CrossChainBitcoinyHTLCStatus.java b/src/main/java/org/qortal/api/model/CrossChainBitcoinyHTLCStatus.java new file mode 100644 index 00000000..2772eae1 --- /dev/null +++ b/src/main/java/org/qortal/api/model/CrossChainBitcoinyHTLCStatus.java @@ -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() { + } + +} diff --git a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java index 7f17e02a..bf71c2d2 100644 --- a/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java +++ b/src/main/java/org/qortal/api/model/CrossChainOfferSummary.java @@ -4,7 +4,7 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.AcctMode; import org.qortal.data.crosschain.CrossChainTradeData; import io.swagger.v3.oas.annotations.media.Schema; @@ -16,26 +16,41 @@ public class CrossChainOfferSummary { // Properties @Schema(description = "AT's Qortal address") - public String qortalAtAddress; + private String qortalAtAddress; @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) private long qortAmount; + @Schema(description = "Bitcoin amount - DEPRECATED: use foreignAmount") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + @Deprecated 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") private int tradeTimeout; - private BTCACCT.Mode mode; + @Schema(description = "Current AT execution mode") + private AcctMode mode; private long timestamp; + @Schema(description = "Trade partner's Qortal receiving address") private String partnerQortalReceivingAddress; + private String foreignBlockchain; + + private String acctName; + protected CrossChainOfferSummary() { /* For JAXB */ } @@ -43,12 +58,16 @@ public class CrossChainOfferSummary { public CrossChainOfferSummary(CrossChainTradeData crossChainTradeData, long timestamp) { this.qortalAtAddress = crossChainTradeData.qortalAtAddress; this.qortalCreator = crossChainTradeData.qortalCreator; + this.qortalCreatorTradeAddress = crossChainTradeData.qortalCreatorTradeAddress; 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.mode = crossChainTradeData.mode; this.timestamp = timestamp; this.partnerQortalReceivingAddress = crossChainTradeData.qortalPartnerReceivingAddress; + this.foreignBlockchain = crossChainTradeData.foreignBlockchain; + this.acctName = crossChainTradeData.acctName; } public String getQortalAtAddress() { @@ -59,6 +78,10 @@ public class CrossChainOfferSummary { return this.qortalCreator; } + public String getQortalCreatorTradeAddress() { + return this.qortalCreatorTradeAddress; + } + public long getQortAmount() { return this.qortAmount; } @@ -67,11 +90,15 @@ public class CrossChainOfferSummary { return this.btcAmount; } + public long getForeignAmount() { + return this.foreignAmount; + } + public int getTradeTimeout() { return this.tradeTimeout; } - public BTCACCT.Mode getMode() { + public AcctMode getMode() { return this.mode; } @@ -83,10 +110,18 @@ public class CrossChainOfferSummary { return this.partnerQortalReceivingAddress; } + public String getForeignBlockchain() { + return this.foreignBlockchain; + } + + public String getAcctName() { + return this.acctName; + } + // For debugging mostly public String toString() { - return String.format("%s: %s", this.qortalAtAddress, this.mode.name()); + return String.format("%s: %s", this.qortalAtAddress, this.mode); } } diff --git a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java index 52ac7de3..274dd818 100644 --- a/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java +++ b/src/main/java/org/qortal/api/model/CrossChainTradeSummary.java @@ -6,6 +6,8 @@ import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import org.qortal.data.crosschain.CrossChainTradeData; +import io.swagger.v3.oas.annotations.media.Schema; + // All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) public class CrossChainTradeSummary { @@ -15,9 +17,14 @@ public class CrossChainTradeSummary { @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private long qortAmount; + @Deprecated + @Schema(description = "DEPRECATED: use foreignAmount instead") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) private long btcAmount; + @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) + private long foreignAmount; + protected CrossChainTradeSummary() { /* For JAXB */ } @@ -25,7 +32,8 @@ public class CrossChainTradeSummary { public CrossChainTradeSummary(CrossChainTradeData crossChainTradeData, long timestamp) { this.tradeTimestamp = timestamp; this.qortAmount = crossChainTradeData.qortAmount; - this.btcAmount = crossChainTradeData.expectedBitcoin; + this.foreignAmount = crossChainTradeData.expectedForeignAmount; + this.btcAmount = this.foreignAmount; } public long getTradeTimestamp() { @@ -40,4 +48,7 @@ public class CrossChainTradeSummary { return this.btcAmount; } + public long getForeignAmount() { + return this.foreignAmount; + } } diff --git a/src/main/java/org/qortal/api/model/SimpleForeignTransaction.java b/src/main/java/org/qortal/api/model/SimpleForeignTransaction.java new file mode 100644 index 00000000..acc1120f --- /dev/null +++ b/src/main/java/org/qortal/api/model/SimpleForeignTransaction.java @@ -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 inputs; + + public static class Output { + public final List addresses; + public final long amount; + + protected Output() { + /* For JAXB */ + this.addresses = null; + this.amount = 0; + } + + public Output(List addresses, long amount) { + this.addresses = addresses; + this.amount = amount; + } + } + private List 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 getInputs() { + return this.inputs; + } + + public List 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 inputs = new ArrayList<>(); + private List 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 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); + } + } + +} diff --git a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java deleted file mode 100644 index 129b6c7e..00000000 --- a/src/main/java/org/qortal/api/model/TradeBotRespondRequest.java +++ /dev/null @@ -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() { - } - -} diff --git a/src/main/java/org/qortal/api/model/BitcoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java similarity index 58% rename from src/main/java/org/qortal/api/model/BitcoinSendRequest.java rename to src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java index f169fe33..86d3d7c8 100644 --- a/src/main/java/org/qortal/api/model/BitcoinSendRequest.java +++ b/src/main/java/org/qortal/api/model/crosschain/BitcoinSendRequest.java @@ -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.XmlAccessorType; @@ -9,16 +9,20 @@ import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) public class BitcoinSendRequest { - @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ") + @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________") public String xprv58; @Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE") 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) 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() { } diff --git a/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java b/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java new file mode 100644 index 00000000..5f215740 --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/LitecoinSendRequest.java @@ -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() { + } + +} diff --git a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java similarity index 52% rename from src/main/java/org/qortal/api/model/TradeBotCreateRequest.java rename to src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java index 622262b0..1f96488e 100644 --- a/src/main/java/org/qortal/api/model/TradeBotCreateRequest.java +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotCreateRequest.java @@ -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.XmlAccessorType; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; +import org.qortal.crosschain.SupportedBlockchain; + import io.swagger.v3.oas.annotations.media.Schema; @XmlAccessorType(XmlAccessType.FIELD) @@ -12,22 +14,30 @@ public class TradeBotCreateRequest { @Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB") public byte[] creatorPublicKey; - @Schema(description = "QORT amount paid out on successful trade", example = "80.40200000") + @Schema(description = "QORT amount paid out on successful trade", example = "80.40000000", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) 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) 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) - 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") public int tradeTimeout; - @Schema(description = "Bitcoin address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE") + @Schema(description = "Foreign blockchain address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE") public String receivingAddress; public TradeBotCreateRequest() { diff --git a/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java new file mode 100644 index 00000000..ecc8ed6f --- /dev/null +++ b/src/main/java/org/qortal/api/model/crosschain/TradeBotRespondRequest.java @@ -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() { + } + +} diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java new file mode 100644 index 00000000..6125974f --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinACCTv1Resource.java @@ -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.
" + + "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + 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.
" + + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" + + "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + 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); + } + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java new file mode 100644 index 00000000..445d853e --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainBitcoinResource.java @@ -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 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(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java new file mode 100644 index 00000000..8bd2dc8b --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainHtlcResource.java @@ -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 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 + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java new file mode 100644 index 00000000..9c841045 --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainLitecoinResource.java @@ -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 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(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/CrossChainResource.java b/src/main/java/org/qortal/api/resource/CrossChainResource.java index 9e46b245..005b1ff1 100644 --- a/src/main/java/org/qortal/api/resource/CrossChainResource.java +++ b/src/main/java/org/qortal/api/resource/CrossChainResource.java @@ -10,79 +10,51 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; -import java.math.BigDecimal; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Random; -import java.util.function.Function; -import java.util.function.ToIntFunction; +import java.util.function.Supplier; 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.PathParam; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; -import org.bitcoinj.core.Address; -import org.bitcoinj.core.AddressFormatException; -import org.bitcoinj.core.Coin; -import org.bitcoinj.core.ECKey; -import org.bitcoinj.core.LegacyAddress; -import org.bitcoinj.core.NetworkParameters; -import org.bitcoinj.core.TransactionOutput; -import org.bitcoinj.script.Script.ScriptType; -import org.qortal.account.Account; -import org.qortal.account.PublicKeyAccount; import org.qortal.api.ApiError; import org.qortal.api.ApiErrors; import org.qortal.api.ApiExceptionFactory; import org.qortal.api.Security; import org.qortal.api.model.CrossChainCancelRequest; -import org.qortal.api.model.CrossChainSecretRequest; -import org.qortal.api.model.CrossChainTradeRequest; import org.qortal.api.model.CrossChainTradeSummary; -import org.qortal.api.model.TradeBotCreateRequest; -import org.qortal.api.model.TradeBotRespondRequest; -import org.qortal.api.model.BitcoinSendRequest; -import org.qortal.api.model.CrossChainBitcoinP2SHStatus; -import org.qortal.api.model.CrossChainBitcoinRedeemRequest; -import org.qortal.api.model.CrossChainBitcoinRefundRequest; -import org.qortal.api.model.CrossChainBitcoinTemplateRequest; -import org.qortal.api.model.CrossChainBuildRequest; -import org.qortal.asset.Asset; -import org.qortal.controller.TradeBot; -import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; -import org.qortal.crosschain.BTCP2SH; -import org.qortal.crosschain.BitcoinException; +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.at.ATStateData; 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.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.Amounts; import org.qortal.utils.Base58; +import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; @Path("/crosschain") @@ -110,6 +82,11 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public List getTradeOffers( + @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain, @Parameter( ref = "limit") @QueryParam("limit") Integer limit, @Parameter( ref = "offset" ) @QueryParam("offset") Integer offset, @Parameter( ref = "reverse" ) @QueryParam("reverse") Boolean reverse) { @@ -117,16 +94,22 @@ public class CrossChainResource { if (limit != null && limit > 100) throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - byte[] codeHash = BTCACCT.CODE_BYTES_HASH; - boolean isExecutable = true; + final boolean isExecutable = true; + List crossChainTradesData = new ArrayList<>(); try (final Repository repository = RepositoryManager.getRepository()) { - List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); - List crossChainTradesData = new ArrayList<>(); - for (ATData atData : atsData) { - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - crossChainTradesData.add(crossChainTradeData); + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atsData = repository.getATRepository().getATsByFunctionality(codeHash, isExecutable, limit, offset, reverse); + + for (ATData atData : atsData) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + crossChainTradesData.add(crossChainTradeData); + } } return crossChainTradesData; @@ -135,1097 +118,6 @@ public class CrossChainResource { } } - @POST - @Path("/build") - @Operation( - summary = "Build 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}) - @SecurityRequirement(name = "apiKey") - 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 != BTC.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 = BTCACCT.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("/tradeoffer/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.
" - + "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" - + "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.", - requestBody = @RequestBody( - required = true, - 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}) - @SecurityRequirement(name = "apiKey") - 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 = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - // Does supplied public key match trade public key? - if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature); - if (transactionData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN); - - if (transactionData.getType() != TransactionType.MESSAGE) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); - - MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData; - byte[] messageData = messageTransactionData.getData(); - BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); - if (offerMessageData == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID); - - // Good to make MESSAGE - - byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH; - byte[] hashOfSecretA = offerMessageData.hashOfSecretA; - int lockTimeA = (int) offerMessageData.lockTimeA; - - String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); - int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); - - byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); - byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData); - - return Base58.encode(messageTransactionBytes); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/tradeoffer/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.
" - + "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!
" - + "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.", - requestBody = @RequestBody( - required = true, - content = @Content( - 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}) - @SecurityRequirement(name = "apiKey") - 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 != BTCACCT.SECRET_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (secretRequest.secretB == null || secretRequest.secretB.length != BTCACCT.SECRET_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != BTCACCT.Mode.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 = BTCACCT.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); - } - } - - @DELETE - @Path("/tradeoffer") - @Operation( - summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer", - description = "Specify address of cross-chain AT that needs to be cancelled.
" - + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!
" - + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainCancelRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content( - schema = @Schema( - type = "string" - ) - ) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String buildCancelMessage(CrossChainCancelRequest cancelRequest) { - Security.checkApiCallAllowed(request); - - byte[] creatorPublicKey = cancelRequest.creatorPublicKey; - - if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - // Does supplied public key match AT creator's public key? - if (!Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - // Good to make MESSAGE - - String atCreatorAddress = Crypto.toAddress(creatorPublicKey); - byte[] messageData = BTCACCT.buildCancelMessage(atCreatorAddress); - - byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.atAddress, messageData); - - return Base58.encode(messageTransactionBytes); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/p2sh/a") - @Operation( - summary = "Returns Bitcoin P2SH-A address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String deriveP2shA(CrossChainBitcoinTemplateRequest templateRequest) { - Security.checkApiCallAllowed(request); - - return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); - } - - @POST - @Path("/p2sh/b") - @Operation( - summary = "Returns Bitcoin P2SH-B address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String deriveP2shB(CrossChainBitcoinTemplateRequest templateRequest) { - Security.checkApiCallAllowed(request); - - return deriveP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); - } - - private String deriveP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - BTC btc = BTC.getInstance(); - NetworkParameters params = btc.getNetworkParameters(); - - if (templateRequest.refundPublicKeyHash == null || templateRequest.refundPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.redeemPublicKeyHash == null || templateRequest.redeemPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTimeFn.applyAsInt(crossChainTradeData), templateRequest.redeemPublicKeyHash, hashOfSecretFn.apply(crossChainTradeData)); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - return p2shAddress.toString(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/p2sh/a/check") - @Operation( - summary = "Checks Bitcoin P2SH-A address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class)) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public CrossChainBitcoinP2SHStatus checkP2shA(CrossChainBitcoinTemplateRequest templateRequest) { - Security.checkApiCallAllowed(request); - - return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); - } - - @POST - @Path("/p2sh/b/check") - @Operation( - summary = "Checks Bitcoin P2SH-B address based on trade info", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinTemplateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinP2SHStatus.class)) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public CrossChainBitcoinP2SHStatus checkP2shB(CrossChainBitcoinTemplateRequest templateRequest) { - Security.checkApiCallAllowed(request); - - return checkP2sh(templateRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); - } - - private CrossChainBitcoinP2SHStatus checkP2sh(CrossChainBitcoinTemplateRequest templateRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - BTC btc = BTC.getInstance(); - NetworkParameters params = btc.getNetworkParameters(); - - if (templateRequest.refundPublicKeyHash == null || templateRequest.refundPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.redeemPublicKeyHash == null || templateRequest.redeemPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (templateRequest.atAddress == null || !Crypto.isValidAtAddress(templateRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, templateRequest.atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); - byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); - - byte[] redeemScriptBytes = BTCP2SH.buildScript(templateRequest.refundPublicKeyHash, lockTime, templateRequest.redeemPublicKeyHash, hashOfSecret); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - - int medianBlockTime = BTC.getInstance().getMedianBlockTime(); - - long now = NTP.getTime(); - - // Check P2SH is funded - long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); - - CrossChainBitcoinP2SHStatus p2shStatus = new CrossChainBitcoinP2SHStatus(); - p2shStatus.bitcoinP2shAddress = p2shAddress.toString(); - p2shStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8); - - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - - if (p2shBalance >= crossChainTradeData.expectedBitcoin && !fundingOutputs.isEmpty()) { - p2shStatus.canRedeem = now >= medianBlockTime * 1000L; - p2shStatus.canRefund = now >= lockTime * 1000L; - } - - if (now >= medianBlockTime * 1000L) { - // See if we can extract secret - List rawTransactions = BTC.getInstance().getAddressTransactions(p2shStatus.bitcoinP2shAddress); - p2shStatus.secret = BTCP2SH.findP2shSecret(p2shStatus.bitcoinP2shAddress, rawTransactions); - } - - return p2shStatus; - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } catch (BitcoinException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - } - - @POST - @Path("/p2sh/a/refund") - @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-A address", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRefundRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, - ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String refundP2shA(CrossChainBitcoinRefundRequest refundRequest) { - Security.checkApiCallAllowed(request); - - return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); - } - - @POST - @Path("/p2sh/b/refund") - @Operation( - summary = "Returns serialized Bitcoin transaction attempting refund from P2SH-B address", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRefundRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, - ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String refundP2shB(CrossChainBitcoinRefundRequest refundRequest) { - Security.checkApiCallAllowed(request); - - return refundP2sh(refundRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); - } - - private String refundP2sh(CrossChainBitcoinRefundRequest refundRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - BTC btc = BTC.getInstance(); - NetworkParameters params = btc.getNetworkParameters(); - - byte[] refundPrivateKey = refundRequest.refundPrivateKey; - if (refundPrivateKey == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - ECKey refundKey = null; - - try { - // Auto-trim - if (refundPrivateKey.length >= 37 && refundPrivateKey.length <= 38) - refundPrivateKey = Arrays.copyOfRange(refundPrivateKey, 1, 33); - if (refundPrivateKey.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - refundKey = ECKey.fromPrivate(refundPrivateKey); - } catch (IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } - - if (refundRequest.redeemPublicKeyHash == null || refundRequest.redeemPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (refundRequest.atAddress == null || !Crypto.isValidAtAddress(refundRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (refundRequest.receivingAccountInfo == null) - refundRequest.receivingAccountInfo = refundKey.getPubKeyHash(); - - if (refundRequest.receivingAccountInfo.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, refundRequest.atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); - byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); - - byte[] redeemScriptBytes = BTCP2SH.buildScript(refundKey.getPubKeyHash(), lockTime, refundRequest.redeemPublicKeyHash, hashOfSecret); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - - long now = NTP.getTime(); - - // Check P2SH is funded - - long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); - - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - if (fundingOutputs.isEmpty()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - boolean canRefund = now >= lockTime * 1000L; - if (!canRefund) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - - if (p2shBalance < crossChainTradeData.expectedBitcoin) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - Coin refundAmount = Coin.valueOf(p2shBalance - refundRequest.bitcoinMinerFee.unscaledValue().longValue()); - - org.bitcoinj.core.Transaction refundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptBytes, lockTime, refundRequest.receivingAccountInfo); - BTC.getInstance().broadcastTransaction(refundTransaction); - - - return refundTransaction.getTxId().toString(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } catch (BitcoinException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - } - - @POST - @Path("/p2sh/a/redeem") - @Operation( - summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-A address", - description = "Secret payload needs to be secret-A (64 bytes)", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRedeemRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, - ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String redeemP2shA(CrossChainBitcoinRedeemRequest redeemRequest) { - Security.checkApiCallAllowed(request); - - return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeA, (crossChainTradeData) -> crossChainTradeData.hashOfSecretA); - } - - @POST - @Path("/p2sh/b/redeem") - @Operation( - summary = "Returns serialized Bitcoin transaction attempting redeem from P2SH-B address", - description = "Secret payload needs to be secret-B (32 bytes)", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = CrossChainBitcoinRedeemRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN, - ApiError.BTC_TOO_SOON, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String redeemP2shB(CrossChainBitcoinRedeemRequest redeemRequest) { - Security.checkApiCallAllowed(request); - - return redeemP2sh(redeemRequest, (crossChainTradeData) -> crossChainTradeData.lockTimeB, (crossChainTradeData) -> crossChainTradeData.hashOfSecretB); - } - - private String redeemP2sh(CrossChainBitcoinRedeemRequest redeemRequest, ToIntFunction lockTimeFn, Function hashOfSecretFn) { - BTC btc = BTC.getInstance(); - NetworkParameters params = btc.getNetworkParameters(); - - byte[] redeemPrivateKey = redeemRequest.redeemPrivateKey; - if (redeemPrivateKey == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - ECKey redeemKey = null; - - try { - // Auto-trim - if (redeemPrivateKey.length >= 37 && redeemPrivateKey.length <= 38) - redeemPrivateKey = Arrays.copyOfRange(redeemPrivateKey, 1, 33); - if (redeemPrivateKey.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - redeemKey = ECKey.fromPrivate(redeemPrivateKey); - } catch (IllegalArgumentException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } - - if (redeemRequest.refundPublicKeyHash == null || redeemRequest.refundPublicKeyHash.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - if (redeemRequest.atAddress == null || !Crypto.isValidAtAddress(redeemRequest.atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (redeemRequest.secret == null || redeemRequest.secret.length != BTCACCT.SECRET_LENGTH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA); - - if (redeemRequest.receivingAccountInfo == null) - redeemRequest.receivingAccountInfo = redeemKey.getPubKeyHash(); - - if (redeemRequest.receivingAccountInfo.length != 20) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, redeemRequest.atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode == BTCACCT.Mode.OFFERING || crossChainTradeData.mode == BTCACCT.Mode.CANCELLED) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - int lockTime = lockTimeFn.applyAsInt(crossChainTradeData); - byte[] hashOfSecret = hashOfSecretFn.apply(crossChainTradeData); - - byte[] redeemScriptBytes = BTCP2SH.buildScript(redeemRequest.refundPublicKeyHash, lockTime, redeemKey.getPubKeyHash(), hashOfSecret); - byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); - - Address p2shAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); - - int medianBlockTime = BTC.getInstance().getMedianBlockTime(); - - long now = NTP.getTime(); - - // Check P2SH is funded - long p2shBalance = BTC.getInstance().getConfirmedBalance(p2shAddress.toString()); - - if (p2shBalance < crossChainTradeData.expectedBitcoin) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddress.toString()); - if (fundingOutputs.isEmpty()) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - boolean canRedeem = now >= medianBlockTime * 1000L; - if (!canRedeem) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_TOO_SOON); - - Coin redeemAmount = Coin.valueOf(p2shBalance - redeemRequest.bitcoinMinerFee.unscaledValue().longValue()); - - org.bitcoinj.core.Transaction redeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptBytes, redeemRequest.secret, redeemRequest.receivingAccountInfo); - - BTC.getInstance().broadcastTransaction(redeemTransaction); - - return redeemTransaction.getTxId().toString(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } catch (BitcoinException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - } - - @POST - @Path("/btc/walletbalance") - @Operation( - summary = "Returns BTC balance for BIP32 wallet", - description = "Supply BIP32 'm' private key in base58, starting with 'xprv' for mainnet, 'tprv' for testnet", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string", - description = "BIP32 'm' private key in base58", - example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ" - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PRIVATE_KEY}) - @SecurityRequirement(name = "apiKey") - public String getBitcoinWalletBalance(String xprv58) { - Security.checkApiCallAllowed(request); - - if (!BTC.getInstance().isValidXprv(xprv58)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - Long balance = BTC.getInstance().getWalletBalance(xprv58); - if (balance == null) - return "null"; - - return balance.toString(); - } - - @POST - @Path("/btc/send") - @Operation( - summary = "Sends BTC from 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")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String sendBitcoin(BitcoinSendRequest bitcoinSendRequest) { - Security.checkApiCallAllowed(request); - - if (bitcoinSendRequest.bitcoinAmount <= 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - Address receivingAddress; - try { - receivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), bitcoinSendRequest.receivingAddress); - } catch (AddressFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - } - - // We only support P2PKH addresses at this time - if (receivingAddress.getOutputScriptType() != ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (!BTC.getInstance().isValidXprv(bitcoinSendRequest.xprv58)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - org.bitcoinj.core.Transaction spendTransaction = BTC.getInstance().buildSpend(bitcoinSendRequest.xprv58, bitcoinSendRequest.receivingAddress, bitcoinSendRequest.bitcoinAmount); - if (spendTransaction == null) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - try { - BTC.getInstance().broadcastTransaction(spendTransaction); - } catch (BitcoinException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - } - - return "true"; - } - - @GET - @Path("/tradebot") - @Operation( - summary = "List current trade-bot states", - responses = { - @ApiResponse( - content = @Content( - array = @ArraySchema( - schema = @Schema( - implementation = TradeBotData.class - ) - ) - ) - ) - } - ) - @ApiErrors({ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public List getTradeBotStates() { - Security.checkApiCallAllowed(request); - - try (final Repository repository = RepositoryManager.getRepository()) { - return repository.getCrossChainRepository().getAllTradeBotData(); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/tradebot/create") - @Operation( - summary = "Create a trade offer", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = TradeBotCreateRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String tradeBotCreator(TradeBotCreateRequest tradeBotCreateRequest) { - Security.checkApiCallAllowed(request); - - Address receivingAddress; - try { - receivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); - } catch (AddressFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - } - - // We only support P2PKH addresses at this time - if (receivingAddress.getOutputScriptType() != ScriptType.P2PKH) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (tradeBotCreateRequest.tradeTimeout < 60) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - if (tradeBotCreateRequest.bitcoinAmount <= 0 || tradeBotCreateRequest.qortAmount <= 0 || tradeBotCreateRequest.fundingQortAmount <= 0) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - try (final Repository repository = RepositoryManager.getRepository()) { - // Do some simple checking first - Account creator = new PublicKeyAccount(repository, tradeBotCreateRequest.creatorPublicKey); - if (creator.getConfirmedBalance(Asset.QORT) < tradeBotCreateRequest.fundingQortAmount) - throw TransactionsResource.createTransactionInvalidException(request, ValidationResult.NO_BALANCE); - - byte[] unsignedBytes = TradeBot.createTrade(repository, tradeBotCreateRequest); - - return Base58.encode(unsignedBytes); - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @POST - @Path("/tradebot/respond") - @Operation( - summary = "Respond to a trade offer (WILL SPEND BITCOIN!)", - description = "Start a new trade-bot entry to respond to chosen trade offer. Trade-bot starts by funding Bitcoin side of trade!", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.APPLICATION_JSON, - schema = @Schema( - implementation = TradeBotRespondRequest.class - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.BTC_BALANCE_ISSUE, ApiError.BTC_NETWORK_ISSUE, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String tradeBotResponder(TradeBotRespondRequest tradeBotRespondRequest) { - Security.checkApiCallAllowed(request); - - final String atAddress = tradeBotRespondRequest.atAddress; - - if (atAddress == null || !Crypto.isValidAtAddress(atAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - if (!BTC.getInstance().isValidXprv(tradeBotRespondRequest.xprv58)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - - if (tradeBotRespondRequest.receivingAddress == null || !Crypto.isValidAddress(tradeBotRespondRequest.receivingAddress)) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); - - // Extract data from cross-chain trading AT - try (final Repository repository = RepositoryManager.getRepository()) { - ATData atData = fetchAtDataWithChecking(repository, atAddress); - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - - if (crossChainTradeData.mode != BTCACCT.Mode.OFFERING) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); - - TradeBot.ResponseResult result = TradeBot.startResponse(repository, crossChainTradeData, tradeBotRespondRequest.xprv58, tradeBotRespondRequest.receivingAddress); - - switch (result) { - case OK: - return "true"; - - case INSUFFICIENT_FUNDS: - case BTC_BALANCE_ISSUE: - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_BALANCE_ISSUE); - - case BTC_NETWORK_ISSUE: - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.BTC_NETWORK_ISSUE); - - default: - return "false"; - } - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - - @DELETE - @Path("/tradebot/trade") - @Operation( - summary = "Delete completed trade", - requestBody = @RequestBody( - required = true, - content = @Content( - mediaType = MediaType.TEXT_PLAIN, - schema = @Schema( - type = "string", - example = "Au6kioR6XT2CPxT6qsyQ1WjS9zNYg7tpwSrFeVqCDdMR" - ) - ) - ), - responses = { - @ApiResponse( - content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string")) - ) - } - ) - @ApiErrors({ApiError.INVALID_ADDRESS, ApiError.REPOSITORY_ISSUE}) - @SecurityRequirement(name = "apiKey") - public String tradeBotDelete(String tradePrivateKey58) { - Security.checkApiCallAllowed(request); - - final byte[] tradePrivateKey; - try { - tradePrivateKey = Base58.decode(tradePrivateKey58); - - if (tradePrivateKey.length != 32) - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } catch (NumberFormatException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY); - } - - try (final Repository repository = RepositoryManager.getRepository()) { - TradeBotData tradeBotData = repository.getCrossChainRepository().getTradeBotData(tradePrivateKey); - if (tradeBotData == null) - return "false"; - - switch (tradeBotData.getState()) { - case BOB_WAITING_FOR_AT_CONFIRM: - case ALICE_DONE: - case BOB_DONE: - case ALICE_REFUNDED: - case BOB_REFUNDED: - break; - - default: - return "false"; - } - - repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); - repository.saveChanges(); - - return "true"; - } catch (DataException e) { - throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); - } - } - @GET @Path("/trades") @Operation( @@ -1245,6 +137,11 @@ public class CrossChainResource { ) @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) public List getCompletedTrades( + @Parameter( + description = "Limit to specific blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @QueryParam("foreignBlockchain") SupportedBlockchain foreignBlockchain, @Parameter( description = "Only return trades that completed on/after this timestamp (milliseconds since epoch)", example = "1597310000000" @@ -1277,21 +174,27 @@ public class CrossChainResource { minimumFinalHeight++; } - List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, - isFinished, - BTCACCT.MODE_BYTE_OFFSET, (long) BTCACCT.Mode.REDEEMED.value, - minimumFinalHeight, - limit, offset, reverse); - List crossChainTrades = new ArrayList<>(); - for (ATStateData atState : atStates) { - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState); - // We also need block timestamp for use as trade timestamp - long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); - CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp); - crossChainTrades.add(crossChainTradeSummary); + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumFinalHeight, + limit, offset, reverse); + + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + + // We also need block timestamp for use as trade timestamp + long timestamp = repository.getBlockRepository().getTimestampFromHeight(atState.getHeight()); + + CrossChainTradeSummary crossChainTradeSummary = new CrossChainTradeSummary(crossChainTradeData, timestamp); + crossChainTrades.add(crossChainTradeSummary); + } } return crossChainTrades; @@ -1300,15 +203,137 @@ public class CrossChainResource { } } + @GET + @Path("/price/{blockchain}") + @Operation( + summary = "Request current estimated trading price", + description = "Returns price based on most recent completed trades. Price is expressed in terms of QORT per unit foreign currency.", + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "number" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + public long getTradePriceEstimate( + @Parameter( + description = "foreign blockchain", + example = "LITECOIN", + schema = @Schema(implementation = SupportedBlockchain.class) + ) @PathParam("blockchain") SupportedBlockchain foreignBlockchain) { + // foreignBlockchain is required + if (foreignBlockchain == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // We want both a minimum of 5 trades and enough trades to span at least 4 hours + int minimumCount = 5; + long minimumPeriod = 4 * 60 * 60 * 1000L; // ms + Boolean isFinished = Boolean.TRUE; + + try (final Repository repository = RepositoryManager.getRepository()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(foreignBlockchain); + + long totalForeign = 0; + long totalQort = 0; + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List atStates = repository.getATRepository().getMatchingFinalATStatesQuorum(codeHash, + isFinished, acct.getModeByteOffset(), (long) AcctMode.REDEEMED.value, minimumCount, minimumPeriod); + + for (ATStateData atState : atStates) { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); + totalForeign += crossChainTradeData.expectedForeignAmount; + totalQort += crossChainTradeData.qortAmount; + } + } + + return Amounts.scaledDivide(totalQort, totalForeign); + } catch (DataException e) { + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); + } + } + + @DELETE + @Path("/tradeoffer") + @Operation( + summary = "Builds raw, unsigned 'cancel' MESSAGE transaction that cancels cross-chain trade offer", + description = "Specify address of cross-chain AT that needs to be cancelled.
" + + "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored.
" + + "Performs MESSAGE proof-of-work.
" + + "You need to sign output with AT creator's private key otherwise the MESSAGE transaction will be invalid.", + requestBody = @RequestBody( + required = true, + content = @Content( + mediaType = MediaType.APPLICATION_JSON, + schema = @Schema( + implementation = CrossChainCancelRequest.class + ) + ) + ), + responses = { + @ApiResponse( + content = @Content( + schema = @Schema( + type = "string" + ) + ) + ) + } + ) + @ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE}) + @SecurityRequirement(name = "apiKey") + public String cancelTrade(CrossChainCancelRequest cancelRequest) { + Security.checkApiCallAllowed(request); + + byte[] creatorPublicKey = cancelRequest.creatorPublicKey; + + if (creatorPublicKey == null || creatorPublicKey.length != Transformer.PUBLIC_KEY_LENGTH) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + if (cancelRequest.atAddress == null || !Crypto.isValidAtAddress(cancelRequest.atAddress)) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + try (final Repository repository = RepositoryManager.getRepository()) { + ATData atData = fetchAtDataWithChecking(repository, cancelRequest.atAddress); + + ACCT acct = SupportedBlockchain.getAcctByCodeHash(atData.getCodeHash()); + if (acct == null) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS); + + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atData); + + if (crossChainTradeData.mode != AcctMode.OFFERING) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA); + + // Does supplied public key match AT creator's public key? + if (!Arrays.equals(creatorPublicKey, atData.getCreatorPublicKey())) + throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY); + + // Good to make MESSAGE + + String atCreatorAddress = Crypto.toAddress(creatorPublicKey); + byte[] messageData = acct.buildCancelMessage(atCreatorAddress); + + byte[] messageTransactionBytes = buildAtMessage(repository, creatorPublicKey, cancelRequest.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(), BTCACCT.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); diff --git a/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java new file mode 100644 index 00000000..cd8766ca --- /dev/null +++ b/src/main/java/org/qortal/api/resource/CrossChainTradeBotResource.java @@ -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 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 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; + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/api/resource/TransactionsResource.java b/src/main/java/org/qortal/api/resource/TransactionsResource.java index 77218a69..585dac0b 100644 --- a/src/main/java/org/qortal/api/resource/TransactionsResource.java +++ b/src/main/java/org/qortal/api/resource/TransactionsResource.java @@ -510,14 +510,19 @@ public class TransactionsResource { if (!Controller.getInstance().isUpToDate()) 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()) { - 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); if (!transaction.isSignatureValid()) @@ -535,16 +540,9 @@ public class TransactionsResource { blockchainLock.unlock(); } - // Notify controller of new transaction - Controller.getInstance().onNewTransaction(transactionData, null); - return "true"; } catch (NumberFormatException 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) { throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e); } catch (InterruptedException e) { diff --git a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java index 87ee16cd..f6a439ea 100644 --- a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java @@ -107,7 +107,9 @@ abstract class ApiWebSocket extends WebSocketServlet { public void onWebSocketClose(Session session, int statusCode, String reason) { synchronized (SESSIONS_BY_CLASS) { - SESSIONS_BY_CLASS.get(this.getClass()).remove(session); + List sessions = SESSIONS_BY_CLASS.get(this.getClass()); + if (sessions != null) + sessions.remove(session); } } diff --git a/src/main/java/org/qortal/api/websocket/PresenceWebSocket.java b/src/main/java/org/qortal/api/websocket/PresenceWebSocket.java new file mode 100644 index 00000000..26d131c4 --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/PresenceWebSocket.java @@ -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> currentEntries = Collections.synchronizedMap(new EnumMap<>(PresenceType.class)); + + /** (Optional) PresenceType used for filtering by that Session. */ + private static final Map 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 = 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> queryParams = session.getUpgradeRequest().getParameterMap(); + List 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; + + 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) { + 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 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 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); + }); + } + +} diff --git a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java index a52b7d8b..55969c6b 100644 --- a/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeBotWebSocket.java @@ -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.WebSocket; 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.event.Event; import org.qortal.event.EventBus; @@ -30,7 +31,9 @@ import org.qortal.utils.Base58; public class TradeBotWebSocket extends ApiWebSocket implements Listener { /** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */ - private static final Map PREVIOUS_STATES = new HashMap<>(); + private static final Map PREVIOUS_STATES = new HashMap<>(); + + private static final Map sessionBlockchain = Collections.synchronizedMap(new HashMap<>()); @Override public void configure(WebSocketServletFactory factory) { @@ -42,7 +45,7 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { // How do we properly fail here? 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) { // No output this time } @@ -59,35 +62,59 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey()); synchronized (PREVIOUS_STATES) { - if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState()) + Integer previousStateValue = PREVIOUS_STATES.get(tradePrivateKey58); + if (previousStateValue != null && previousStateValue == tradeBotData.getStateValue()) // Not changed return; - PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState()); + PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue()); } List 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 @Override public void onWebSocketConnect(Session session) { + Map> queryParams = session.getUpgradeRequest().getParameterMap(); + + List 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 try (final Repository repository = RepositoryManager.getRepository()) { List tradeBotEntries = repository.getCrossChainRepository().getAllTradeBotData(); - if (tradeBotEntries == null) { - session.close(4001, "repository issue fetching trade-bot entries"); - return; - } + + // Optional filtering + if (foreignBlockchain != null) + tradeBotEntries = tradeBotEntries.stream() + .filter(tradeBotData -> tradeBotData.getForeignBlockchain().equals(foreignBlockchain)) + .collect(Collectors.toList()); if (!sendEntries(session, tradeBotEntries)) { session.close(4002, "websocket issue"); return; } } catch (DataException e) { - // No output this time + session.close(4001, "repository issue fetching trade-bot entries"); + return; } super.onWebSocketConnect(session); @@ -96,6 +123,9 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener { @OnWebSocketClose @Override public void onWebSocketClose(Session session, int statusCode, String reason) { + // clean up + sessionBlockchain.remove(session); + super.onWebSocketClose(session, statusCode, reason); } diff --git a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java index a2cf3cac..186f79e3 100644 --- a/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/TradeOffersWebSocket.java @@ -3,10 +3,13 @@ package org.qortal.api.websocket; import java.io.IOException; import java.io.StringWriter; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Predicate; +import java.util.function.Supplier; import java.util.stream.Collectors; 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.qortal.api.model.CrossChainOfferSummary; 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.block.BlockData; import org.qortal.data.crosschain.CrossChainTradeData; @@ -30,6 +35,7 @@ import org.qortal.event.Listener; import org.qortal.repository.DataException; import org.qortal.repository.Repository; import org.qortal.repository.RepositoryManager; +import org.qortal.utils.ByteArray; import org.qortal.utils.NTP; @WebSocket @@ -38,18 +44,23 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { private static final Logger LOGGER = LogManager.getLogger(TradeOffersWebSocket.class); - private static final Map previousAtModes = new HashMap<>(); + private static class CachedOfferInfo { + public final Map previousAtModes = new HashMap<>(); - // OFFERING - private static final Map currentSummaries = new HashMap<>(); - // REDEEMED/REFUNDED/CANCELLED - private static final Map historicSummaries = new HashMap<>(); + // OFFERING + public final Map currentSummaries = new HashMap<>(); + // REDEEMED/REFUNDED/CANCELLED + public final Map historicSummaries = new HashMap<>(); + } + // Manual synchronization + private static final Map cachedInfoByBlockchain = new HashMap<>(); private static final Predicate isHistoric = offerSummary - -> offerSummary.getMode() == BTCACCT.Mode.REDEEMED - || offerSummary.getMode() == BTCACCT.Mode.REFUNDED - || offerSummary.getMode() == BTCACCT.Mode.CANCELLED; + -> offerSummary.getMode() == AcctMode.REDEEMED + || offerSummary.getMode() == AcctMode.REFUNDED + || offerSummary.getMode() == AcctMode.CANCELLED; + private static final Map sessionBlockchain = Collections.synchronizedMap(new HashMap<>()); @Override public void configure(WebSocketServletFactory factory) { @@ -75,7 +86,6 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { BlockData blockData = ((Controller.NewBlockEvent) event).getBlockData(); // Process any new info - List crossChainOfferSummaries; try (final Repository repository = RepositoryManager.getRepository()) { // 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 Integer minimumFinalHeight = blockData.getHeight(); - List atStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, - isFinished, dataByteOffset, expectedValue, minimumFinalHeight, - null, null, null); + for (SupportedBlockchain blockchain : SupportedBlockchain.values()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain); - if (atStates == null) - return; + List crossChainOfferSummaries = new ArrayList<>(); - crossChainOfferSummaries = produceSummaries(repository, atStates, blockData.getTimestamp()); + synchronized (cachedInfoByBlockchain) { + CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo()); + + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); + + List 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) { // 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 @@ -146,13 +173,36 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { Map> queryParams = session.getUpgradeRequest().getParameterMap(); final boolean includeHistoric = queryParams.get("includeHistoric") != null; + List 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 crossChainOfferSummaries = new ArrayList<>(); - synchronized (previousAtModes) { - crossChainOfferSummaries.addAll(currentSummaries.values()); + synchronized (cachedInfoByBlockchain) { + Collection cachedInfos; - if (includeHistoric) - crossChainOfferSummaries.addAll(historicSummaries.values()); + if (foreignBlockchain == null) + // 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)) { @@ -166,6 +216,9 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { @OnWebSocketClose @Override public void onWebSocketClose(Session session, int statusCode, String reason) { + // clean up + sessionBlockchain.remove(session); + super.onWebSocketClose(session, statusCode, reason); } @@ -197,22 +250,34 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { private static void populateCurrentSummaries(Repository repository) throws DataException { // We want ALL OFFERING trades Boolean isFinished = Boolean.FALSE; - Integer dataByteOffset = BTCACCT.MODE_BYTE_OFFSET; - Long expectedValue = (long) BTCACCT.Mode.OFFERING.value; + Long expectedValue = (long) AcctMode.OFFERING.value; Integer minimumFinalHeight = null; - List initialAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, - isFinished, dataByteOffset, expectedValue, minimumFinalHeight, - null, null, null); + for (SupportedBlockchain blockchain : SupportedBlockchain.values()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain); - if (initialAtStates == null) - throw new DataException("Couldn't fetch current trades from repository"); + CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo()); - // Save initial AT modes - previousAtModes.putAll(initialAtStates.stream().collect(Collectors.toMap(ATStateData::getATAddress, atState -> BTCACCT.Mode.OFFERING))); + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); - // Convert to offer summaries - currentSummaries.putAll(produceSummaries(repository, initialAtStates, null).stream().collect(Collectors.toMap(CrossChainOfferSummary::getQortalAtAddress, offerSummary -> offerSummary))); + Integer dataByteOffset = acct.getModeByteOffset(); + List 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 { @@ -228,33 +293,44 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { Long expectedValue = null; ++minimumFinalHeight; // because height is just *before* timestamp - List historicAtStates = repository.getATRepository().getMatchingFinalATStates(BTCACCT.CODE_BYTES_HASH, - isFinished, dataByteOffset, expectedValue, minimumFinalHeight, - null, null, null); + for (SupportedBlockchain blockchain : SupportedBlockchain.values()) { + Map> acctsByCodeHash = SupportedBlockchain.getFilteredAcctMap(blockchain); - if (historicAtStates == null) - throw new DataException("Couldn't fetch historic trades from repository"); + CachedOfferInfo cachedInfo = cachedInfoByBlockchain.computeIfAbsent(blockchain.name(), k -> new CachedOfferInfo()); - for (ATStateData historicAtState : historicAtStates) { - CrossChainOfferSummary historicOfferSummary = produceSummary(repository, historicAtState, null); + for (Map.Entry> acctInfo : acctsByCodeHash.entrySet()) { + byte[] codeHash = acctInfo.getKey().value; + ACCT acct = acctInfo.getValue().get(); - if (!isHistoric.test(historicOfferSummary)) - continue; + List historicAtStates = repository.getATRepository().getMatchingFinalATStates(codeHash, + isFinished, dataByteOffset, expectedValue, minimumFinalHeight, + null, null, null); - // Add summary to initial burst - historicSummaries.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary); + if (historicAtStates == null) + throw new DataException("Couldn't fetch historic trades from repository"); - // Save initial AT mode - previousAtModes.put(historicOfferSummary.getQortalAtAddress(), historicOfferSummary.getMode()); + for (ATStateData historicAtState : historicAtStates) { + 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 { - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atState); + private static CrossChainOfferSummary produceSummary(Repository repository, ACCT acct, ATStateData atState, Long timestamp) throws DataException { + CrossChainTradeData crossChainTradeData = acct.populateTradeData(repository, atState); 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 atStateTimestamp = crossChainTradeData.creationTimestamp; else @@ -263,11 +339,11 @@ public class TradeOffersWebSocket extends ApiWebSocket implements Listener { return new CrossChainOfferSummary(crossChainTradeData, atStateTimestamp); } - private static List produceSummaries(Repository repository, List atStates, Long timestamp) throws DataException { + private static List produceSummaries(Repository repository, ACCT acct, List atStates, Long timestamp) throws DataException { List offerSummaries = new ArrayList<>(); for (ATStateData atState : atStates) - offerSummaries.add(produceSummary(repository, atState, timestamp)); + offerSummaries.add(produceSummary(repository, acct, atState, timestamp)); return offerSummaries; } diff --git a/src/main/java/org/qortal/at/QortalFunctionCode.java b/src/main/java/org/qortal/at/QortalFunctionCode.java index 67ab5b98..0d11e488 100644 --- a/src/main/java/org/qortal/at/QortalFunctionCode.java +++ b/src/main/java/org/qortal/at/QortalFunctionCode.java @@ -10,7 +10,7 @@ import org.ciyam.at.ExecutionException; import org.ciyam.at.FunctionData; import org.ciyam.at.IllegalFunctionCodeException; import org.ciyam.at.MachineState; -import org.qortal.crosschain.BTC; +import org.qortal.crosschain.Bitcoin; import org.qortal.crypto.Crypto; import org.qortal.data.transaction.TransactionData; import org.qortal.settings.Settings; @@ -108,7 +108,7 @@ public enum QortalFunctionCode { CONVERT_B_TO_P2SH(0x0511, 0, false) { @Override 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); } diff --git a/src/main/java/org/qortal/controller/BlockMinter.java b/src/main/java/org/qortal/controller/BlockMinter.java index 46a29cf9..f804456f 100644 --- a/src/main/java/org/qortal/controller/BlockMinter.java +++ b/src/main/java/org/qortal/controller/BlockMinter.java @@ -294,8 +294,12 @@ public class BlockMinter extends Thread { newBlock.getMinter().getAddress())); } - // Notify controller after we're released blockchain lock + // Notify network after we're released blockchain lock newBlockMinted = true; + + // Notify Controller + repository.discardChanges(); // clear transaction status to prevent deadlocks + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } catch (DataException e) { // Unable to process block - report and discard LOGGER.error("Unable to process newly minted block?", e); @@ -306,12 +310,9 @@ public class BlockMinter extends Thread { } if (newBlockMinted) { - // Notify Controller and broadcast our new chain to network + // Broadcast our new chain to network BlockData newBlockData = newBlock.getBlockData(); - repository.discardChanges(); // clear transaction status to prevent deadlocks - Controller.getInstance().onNewBlock(newBlockData); - Network network = Network.getInstance(); network.broadcast(broadcastPeer -> network.buildHeightMessage(broadcastPeer, newBlockData)); } diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 77f20caf..08a1e6a8 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -46,6 +46,7 @@ import org.qortal.block.Block; import org.qortal.block.BlockChain; import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.controller.Synchronizer.SynchronizationResult; +import org.qortal.controller.tradebot.TradeBot; import org.qortal.crypto.Crypto; import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.RewardShareData; @@ -799,11 +800,14 @@ public class Controller extends Thread { List transactions = repository.getTransactionRepository().getUnconfirmedTransactions(); - for (TransactionData transactionData : transactions) - if (now >= Transaction.getDeadline(transactionData)) { - LOGGER.info(String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature()))); + for (TransactionData transactionData : transactions) { + Transaction transaction = Transaction.fromData(repository, transactionData); + + if (now >= transaction.getDeadline()) { + LOGGER.info(() -> String.format("Deleting expired, unconfirmed transaction %s", Base58.encode(transactionData.getSignature()))); repository.getTransactionRepository().delete(transactionData); } + } repository.saveChanges(); } 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 void onNewTransaction(TransactionData transactionData, Peer peer) { + public static class NewTransactionEvent implements Event { + 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. + *

+ * @implSpec performs actions in a new thread + */ + public void onNewTransaction(TransactionData transactionData) { this.callbackExecutor.execute(() -> { - // Notify all peers (except maybe peer that sent it to us if applicable) - Network.getInstance().broadcast(broadcastPeer -> broadcastPeer == peer ? null : new TransactionSignaturesMessage(Arrays.asList(transactionData.getSignature()))); + // Notify all peers + 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 (transactionData.getType() == TransactionType.CHAT) @@ -1215,9 +1239,6 @@ public class Controller extends Thread { } catch (DataException 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) { diff --git a/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java new file mode 100644 index 00000000..51b2b075 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/AcctTradeBot.java @@ -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 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; + +} diff --git a/src/main/java/org/qortal/controller/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java similarity index 53% rename from src/main/java/org/qortal/controller/TradeBot.java rename to src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java index e5494675..fe0f41c1 100644 --- a/src/main/java/org/qortal/controller/TradeBot.java +++ b/src/main/java/org/qortal/controller/tradebot/BitcoinACCTv1TradeBot.java @@ -1,14 +1,15 @@ -package org.qortal.controller; +package org.qortal.controller.tradebot; + +import static java.util.Arrays.stream; +import static java.util.stream.Collectors.toMap; -import java.awt.TrayIcon.MessageType; -import java.security.SecureRandom; import java.util.Arrays; import java.util.List; -import java.util.Random; +import java.util.Map; +import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.util.Supplier; import org.bitcoinj.core.Address; import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; @@ -18,35 +19,30 @@ import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.script.Script.ScriptType; import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PublicKeyAccount; -import org.qortal.api.model.TradeBotCreateRequest; +import org.qortal.api.model.crosschain.TradeBotCreateRequest; import org.qortal.asset.Asset; -import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCACCT; -import org.qortal.crosschain.BTCP2SH; -import org.qortal.crosschain.BitcoinException; +import org.qortal.crosschain.ACCT; +import org.qortal.crosschain.AcctMode; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.SupportedBlockchain; +import org.qortal.crosschain.BitcoinyHTLC; import org.qortal.crypto.Crypto; -import org.qortal.data.account.AccountBalanceData; import org.qortal.data.at.ATData; import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.MessageTransactionData; -import org.qortal.event.Event; -import org.qortal.event.EventBus; -import org.qortal.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.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.Amounts; import org.qortal.utils.Base58; import org.qortal.utils.NTP; @@ -56,47 +52,84 @@ import org.qortal.utils.NTP; * We deal with three different independent state-spaces here: *

    *
  • Qortal blockchain
  • - *
  • Bitcoin blockchain
  • + *
  • Foreign blockchain
  • *
  • Trade-bot entries
  • *
*/ -public class TradeBot implements Listener { +public class BitcoinACCTv1TradeBot implements AcctTradeBot { - public enum ResponseResult { OK, INSUFFICIENT_FUNDS, BTC_BALANCE_ISSUE, BTC_NETWORK_ISSUE } + private static final Logger LOGGER = LogManager.getLogger(BitcoinACCTv1TradeBot.class); - public static class StateChangeEvent implements Event { - private final TradeBotData tradeBotData; + public enum State implements TradeBot.StateNameAndValueSupplier { + BOB_WAITING_FOR_AT_CONFIRM(10, false, false), + BOB_WAITING_FOR_MESSAGE(15, true, true), + BOB_WAITING_FOR_P2SH_B(20, true, true), + BOB_WAITING_FOR_AT_REDEEM(25, true, true), + BOB_DONE(30, false, false), + BOB_REFUNDED(35, false, false), - public StateChangeEvent(TradeBotData tradeBotData) { - this.tradeBotData = tradeBotData; + ALICE_WAITING_FOR_P2SH_A(80, true, true), + ALICE_WAITING_FOR_AT_LOCK(85, true, true), + ALICE_WATCH_P2SH_B(90, true, true), + ALICE_DONE(95, false, false), + ALICE_REFUNDING_B(100, true, true), + ALICE_REFUNDING_A(105, true, true), + ALICE_REFUNDED(110, false, false); + + private static final Map 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 TradeBotData getTradeBotData() { - return this.tradeBotData; + public static State valueOf(int value) { + return map.get(value); + } + + @Override + public String getState() { + return this.name(); + } + + @Override + public int getStateValue() { + return this.value; } } - private static final Logger LOGGER = LogManager.getLogger(TradeBot.class); - private static final Random RANDOM = new SecureRandom(); - - /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. */ + /** 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 final long P2SH_B_OUTPUT_AMOUNT = 1000L; // P2SH-B output amount needs to be higher than the dust threshold (3000 sats/kB). + /** P2SH-B output amount to avoid dust threshold (3000 sats/kB). */ + private static final long P2SH_B_OUTPUT_AMOUNT = 1000L; - private static TradeBot instance; + private static BitcoinACCTv1TradeBot instance; - private TradeBot() { - EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event)); + private final List endStates = Arrays.asList(State.BOB_DONE, State.BOB_REFUNDED, State.ALICE_DONE, State.ALICE_REFUNDING_A, State.ALICE_REFUNDING_B, State.ALICE_REFUNDED).stream() + .map(State::name) + .collect(Collectors.toUnmodifiableList()); + + private BitcoinACCTv1TradeBot() { } - public static synchronized TradeBot getInstance() { + public static synchronized BitcoinACCTv1TradeBot getInstance() { if (instance == null) - instance = new TradeBot(); + instance = new BitcoinACCTv1TradeBot(); return instance; } + @Override + public List getEndStates() { + return this.endStates; + } + /** * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for BTC. *

@@ -129,22 +162,22 @@ public class TradeBot implements Listener { * @return raw, unsigned DEPLOY_AT transaction * @throws DataException */ - public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { - byte[] tradePrivateKey = generateTradePrivateKey(); - byte[] secretB = generateSecret(); + public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { + byte[] tradePrivateKey = TradeBot.generateTradePrivateKey(); + byte[] secretB = TradeBot.generateSecret(); byte[] hashOfSecretB = Crypto.hash160(secretB); - byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); - byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time) Address bitcoinReceivingAddress; try { - bitcoinReceivingAddress = Address.fromString(BTC.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); + bitcoinReceivingAddress = Address.fromString(Bitcoin.getInstance().getNetworkParameters(), tradeBotCreateRequest.receivingAddress); } catch (AddressFormatException e) { throw new DataException("Unsupported Bitcoin receiving address: " + tradeBotCreateRequest.receivingAddress); } @@ -166,8 +199,8 @@ public class TradeBot implements Listener { String description = "QORT/BTC cross-chain trade"; String aTType = "ACCT"; String tags = "ACCT QORT BTC"; - byte[] creationBytes = BTCACCT.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, - tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, + tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout); long amount = tradeBotCreateRequest.fundingQortAmount; DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); @@ -179,15 +212,16 @@ public class TradeBot implements Listener { DeployAtTransaction.ensureATAddress(deployAtTransactionData); String atAddress = deployAtTransactionData.getAtAddress(); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.BOB_WAITING_FOR_AT_CONFIRM, + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME, + State.BOB_WAITING_FOR_AT_CONFIRM.name(), State.BOB_WAITING_FOR_AT_CONFIRM.value, creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretB, hashOfSecretB, + SupportedBlockchain.BITCOIN.name(), tradeForeignPublicKey, tradeForeignPublicKeyHash, - tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo); + tradeBotCreateRequest.foreignAmount, null, null, null, bitcoinReceivingAccountInfo); - updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), - () -> String.format("Built AT %s. Waiting for deployment", atAddress)); + 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 { @@ -235,168 +269,180 @@ public class TradeBot implements Listener { * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise * @throws DataException */ - public static ResponseResult startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { - byte[] tradePrivateKey = generateTradePrivateKey(); - byte[] secretA = generateSecret(); + 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 = deriveTradeNativePublicKey(tradePrivateKey); + byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); - byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); + 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 - int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); + long now = NTP.getTime(); + int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (now / 1000L); - TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, - receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount, + TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME, + State.ALICE_WAITING_FOR_P2SH_A.name(), State.ALICE_WAITING_FOR_P2SH_A.value, + receivingAddress, crossChainTradeData.qortalAtAddress, now, crossChainTradeData.qortAmount, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secretA, hashOfSecretA, + SupportedBlockchain.BITCOIN.name(), tradeForeignPublicKey, tradeForeignPublicKeyHash, - crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash); + crossChainTradeData.expectedForeignAmount, xprv58, null, lockTimeA, receivingPublicKeyHash); // Check we have enough funds via xprv58 to fund both P2SHs to cover expectedBitcoin - String tradeForeignAddress = BTC.getInstance().pkhToAddress(tradeForeignPublicKeyHash); + String tradeForeignAddress = Bitcoin.getInstance().pkhToAddress(tradeForeignPublicKeyHash); - long estimatedFee; + long p2shFee; try { - estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - } catch (BitcoinException e) { + p2shFee = Bitcoin.getInstance().getP2shFee(now); + } catch (ForeignBlockchainException e) { LOGGER.debug("Couldn't estimate Bitcoin fees?"); - return ResponseResult.BTC_NETWORK_ISSUE; + return ResponseResult.NETWORK_ISSUE; } // Fee for redeem/refund is subtracted from P2SH-A balance. - long fundsRequiredForP2shA = estimatedFee /*funding P2SH-A*/ + crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-A*/; - long fundsRequiredForP2shB = estimatedFee /*funding P2SH-B*/ + P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-B*/; + long fundsRequiredForP2shA = p2shFee /*funding P2SH-A*/ + crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/; + long fundsRequiredForP2shB = p2shFee /*funding P2SH-B*/ + P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-B*/; long totalFundsRequired = fundsRequiredForP2shA + fundsRequiredForP2shB; // As buildSpend also adds a fee, this is more pessimistic than required - Transaction fundingCheckTransaction = BTC.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); + Transaction fundingCheckTransaction = Bitcoin.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); if (fundingCheckTransaction == null) - return ResponseResult.INSUFFICIENT_FUNDS; + return ResponseResult.BALANCE_ISSUE; // P2SH-A to be funded - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); + byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorForeignPKH, hashOfSecretA); + String p2shAddress = Bitcoin.getInstance().deriveP2shAddress(redeemScriptBytes); // Fund P2SH-A // Do not include fee for funding transaction as this is covered by buildSpend() - long amountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-A*/; + long amountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/; - Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, amountA); + Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA); if (p2shFundingTransaction == null) { LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); - return ResponseResult.BTC_BALANCE_ISSUE; + return ResponseResult.BALANCE_ISSUE; } try { - BTC.getInstance().broadcastTransaction(p2shFundingTransaction); - } catch (BitcoinException e) { + Bitcoin.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.BTC_NETWORK_ISSUE; + return ResponseResult.NETWORK_ISSUE; } - updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), - () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); + TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress)); return ResponseResult.OK; } - private 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(); - } + @Override + public boolean canDelete(Repository repository, TradeBotData tradeBotData) { + State tradeBotState = State.valueOf(tradeBotData.getStateValue()); + if (tradeBotState == null) + return true; - private static byte[] deriveTradeNativePublicKey(byte[] privateKey) { - return PrivateKeyAccount.toPublicKey(privateKey); - } + switch (tradeBotState) { + case BOB_WAITING_FOR_AT_CONFIRM: + case ALICE_DONE: + case BOB_DONE: + case ALICE_REFUNDED: + case BOB_REFUNDED: + return true; - private static byte[] deriveTradeForeignPublicKey(byte[] privateKey) { - return ECKey.fromPrivate(privateKey).getPubKey(); - } - - private static byte[] generateSecret() { - byte[] secret = new byte[32]; - RANDOM.nextBytes(secret); - return secret; + default: + return false; + } } @Override - public void listen(Event event) { - if (!(event instanceof Controller.NewBlockEvent)) + 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; + } - synchronized (this) { - // Get repo for trade situations - try (final Repository repository = RepositoryManager.getRepository()) { - List allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData(); + ATData atData = null; + CrossChainTradeData tradeData = null; - for (TradeBotData tradeBotData : allTradeBotData) { - repository.discardChanges(); - - try { - switch (tradeBotData.getState()) { - case BOB_WAITING_FOR_AT_CONFIRM: - handleBobWaitingForAtConfirm(repository, tradeBotData); - break; - - case ALICE_WAITING_FOR_P2SH_A: - handleAliceWaitingForP2shA(repository, tradeBotData); - break; - - case BOB_WAITING_FOR_MESSAGE: - handleBobWaitingForMessage(repository, tradeBotData); - break; - - case ALICE_WAITING_FOR_AT_LOCK: - handleAliceWaitingForAtLock(repository, tradeBotData); - break; - - case BOB_WAITING_FOR_P2SH_B: - handleBobWaitingForP2shB(repository, tradeBotData); - break; - - case ALICE_WATCH_P2SH_B: - handleAliceWatchingP2shB(repository, tradeBotData); - break; - - case BOB_WAITING_FOR_AT_REDEEM: - handleBobWaitingForAtRedeem(repository, tradeBotData); - break; - - case ALICE_DONE: - case BOB_DONE: - break; - - case ALICE_REFUNDING_B: - handleAliceRefundingP2shB(repository, tradeBotData); - break; - - case ALICE_REFUNDING_A: - handleAliceRefundingP2shA(repository, tradeBotData); - break; - - case ALICE_REFUNDED: - case BOB_REFUNDED: - break; - - default: - LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name())); - } - } catch (BitcoinException e) { - LOGGER.warn(() -> String.format("Bitcoin issue processing %s: %s", tradeBotData.getAtAddress(), e.getMessage())); - } - } - } catch (DataException e) { - LOGGER.error("Couldn't run trade bot due to repository issue", e); + 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 = BitcoinACCTv1.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 ALICE_WAITING_FOR_P2SH_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWaitingForP2shA(repository, tradeBotData, atData, tradeData); + 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_P2SH_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleBobWaitingForP2shB(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_WATCH_P2SH_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceWatchingP2shB(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_B: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceRefundingP2shB(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDING_A: + TradeBot.getInstance().updatePresence(repository, tradeBotData, tradeData); + handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData); + break; + + case ALICE_REFUNDED: + case BOB_REFUNDED: + break; } } @@ -412,18 +458,19 @@ public class TradeBot implements Listener { // 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(TradeBotData.State.BOB_REFUNDED); + 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())); - notifyStateChange(tradeBotData); + TradeBot.notifyStateChange(tradeBotData); return; } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_MESSAGE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_MESSAGE, () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress())); } @@ -442,32 +489,25 @@ public class TradeBot implements Listener { *

  • lockTime of P2SH-A - also used to derive P2SH-A address, but also for other use later in the trading process
  • * * If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); + Bitcoin bitcoin = Bitcoin.getInstance(); - // If AT has finished then maybe Bob cancelled his trade offer - if (atData.getIsFinished()) { - // No point sending MESSAGE - might as well wait for refund - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, - () -> String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA)); - return; - } + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); // Fee for redeem/refund is subtracted from P2SH-A balance. - long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; - BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); + long feeTimestampA = calcP2shAFeeTimestamp(tradeBotData.getLockTimeA(), crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - switch (p2shStatus) { + switch (htlcStatusA) { case UNFUNDED: case FUNDING_IN_PROGRESS: return; @@ -475,13 +515,13 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively check P2SH-B in case we haven't redeemed the AT - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); return; case REFUND_IN_PROGRESS: case REFUNDED: - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); return; @@ -493,7 +533,7 @@ public class TradeBot implements Listener { // P2SH-A funding confirmed // Attempt to send MESSAGE to Bob's Qortal trade address - byte[] messageData = BTCACCT.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); + byte[] messageData = BitcoinACCTv1.buildOfferMessage(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret(), tradeBotData.getLockTimeA()); String messageRecipient = crossChainTradeData.qortalCreatorTradeAddress; boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -514,7 +554,7 @@ public class TradeBot implements Listener { } } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WAITING_FOR_AT_LOCK, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_AT_LOCK, () -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us", p2shAddressA, messageRecipient, tradeBotData.getAtAddress())); } @@ -536,23 +576,19 @@ public class TradeBot implements Listener { *

    * Trade-bot's next step is to wait for P2SH-B, which will allow Bob to reveal his secret-B, * needed by Alice to progress her side of the trade. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - // Fetch AT so we can determine trade start timestamp - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); - return; - } - + 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()) { - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress())); return; } + Bitcoin bitcoin = Bitcoin.getInstance(); + String address = tradeBotData.getTradeNativeAddress(); List messageTransactionsData = repository.getMessageRepository().getMessagesByParticipants(null, address, null, null, null); @@ -575,7 +611,7 @@ public class TradeBot implements Listener { // We're expecting: HASH160(secret-A), Alice's Bitcoin pubkeyhash and lockTime-A byte[] messageData = messageTransactionData.getData(); - BTCACCT.OfferMessageData offerMessageData = BTCACCT.extractOfferMessageData(messageData); + BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData); if (offerMessageData == null) continue; @@ -584,14 +620,16 @@ public class TradeBot implements Listener { int lockTimeA = (int) offerMessageData.lockTimeA; // Determine P2SH-A address and confirm funded - byte[] redeemScriptA = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); - String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); + byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); - final long minimumAmountA = tradeBotData.getBitcoinAmount() - P2SH_B_OUTPUT_AMOUNT; + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + final long minimumAmountA = tradeBotData.getForeignAmount() - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; - BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - switch (p2shStatus) { + switch (htlcStatusA) { case UNFUNDED: case FUNDING_IN_PROGRESS: // There might be another MESSAGE from someone else with an actually funded P2SH-A... @@ -600,7 +638,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B, () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); return; @@ -617,10 +655,10 @@ public class TradeBot implements Listener { // Good to go - send MESSAGE to AT String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey()); - int lockTimeB = BTCACCT.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA); // Build outgoing message, padding each part to 32 bytes to make it easier for AT to consume - byte[] outgoingMessageData = BTCACCT.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); + byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB); String messageRecipient = tradeBotData.getAtAddress(); boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, outgoingMessageData); @@ -641,10 +679,10 @@ public class TradeBot implements Listener { } } - byte[] redeemScriptB = BTCP2SH.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); - String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + byte[] redeemScriptB = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_P2SH_B, () -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shAddressB)); return; @@ -652,7 +690,7 @@ public class TradeBot implements Listener { // Don't resave/notify if we don't need to if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) - updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), null); + TradeBot.updateTradeBotState(repository, tradeBotData, null); } /** @@ -668,42 +706,44 @@ public class TradeBot implements Listener { *

    * If P2SH-B funding transaction is successfully broadcast to the Bitcoin network, trade-bot's next * step is to watch for Bob revealing secret-B by redeeming P2SH-B. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - // Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A - if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { - byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); + Bitcoin bitcoin = Bitcoin.getInstance(); + int lockTimeA = tradeBotData.getLockTimeA(); - long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; - BTCP2SH.Status p2shStatusA = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); + // Refund P2SH-A if we've passed lockTime-A + if (NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); - switch (p2shStatusA) { + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); + + switch (htlcStatusA) { case UNFUNDED: case FUNDING_IN_PROGRESS: // This shouldn't occur, but defensively revert back to waiting for P2SH-A - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WAITING_FOR_P2SH_A, () -> String.format("P2SH-A %s no longer funded? Defensively checking P2SH-A next", p2shAddressA)); return; case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA)); return; case REFUND_IN_PROGRESS: case REFUNDED: - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA)); return; @@ -712,7 +752,7 @@ public class TradeBot implements Listener { break; } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + 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)); @@ -721,25 +761,10 @@ public class TradeBot implements Listener { } // We're waiting for AT to be in TRADE mode - if (crossChainTradeData.mode != BTCACCT.Mode.TRADING) + if (crossChainTradeData.mode != AcctMode.TRADING) return; - // We're expecting AT to be locked to our native trade address - if (!crossChainTradeData.qortalPartnerAddress.equals(tradeBotData.getTradeNativeAddress())) { - // AT locked to different address! We shouldn't continue but wait and refund. - - byte[] redeemScriptBytes = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddress = BTC.getInstance().deriveP2shAddress(redeemScriptBytes); - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, - () -> String.format("AT %s locked to %s, not us (%s). Refunding %s - aborting trade", - tradeBotData.getAtAddress(), - crossChainTradeData.qortalPartnerAddress, - tradeBotData.getTradeNativeAddress(), - p2shAddress)); - - return; - } + // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above // Alice needs to fund P2SH-B here @@ -752,8 +777,7 @@ public class TradeBot implements Listener { } long recipientMessageTimestamp = messageTransactionsData.get(0).getTimestamp(); - int lockTimeA = tradeBotData.getLockTimeA(); - int lockTimeB = BTCACCT.calcLockTimeB(recipientMessageTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(recipientMessageTimestamp, lockTimeA); // Our calculated lockTime-B should match AT's calculated lockTime-B if (lockTimeB != crossChainTradeData.lockTimeB) { @@ -762,18 +786,32 @@ public class TradeBot implements Listener { return; } - byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); - long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); + long feeTimestampB = calcP2shBFeeTimestamp(lockTimeA, lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); // Have we funded P2SH-B already? - final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; - BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); + + switch (htlcStatusB) { + case UNFUNDED: { + // Do not include fee for funding transaction as this is covered by buildSpend() + long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB /*redeeming/refunding P2SH-B*/; + + Transaction p2shFundingTransaction = bitcoin.buildSpend(tradeBotData.getForeignKey(), p2shAddressB, amountB); + if (p2shFundingTransaction == null) { + LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?"); + return; + } + + bitcoin.broadcastTransaction(p2shFundingTransaction); + break; + } - switch (p2shStatusB) { - case UNFUNDED: case FUNDING_IN_PROGRESS: case FUNDED: break; @@ -781,32 +819,19 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("P2SH-B %s already spent? Defensively checking P2SH-B next", p2shAddressB)); return; case REFUND_IN_PROGRESS: case REFUNDED: - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); return; } - if (p2shStatusB == BTCP2SH.Status.UNFUNDED) { - // Do not include fee for funding transaction as this is covered by buildSpend() - long amountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee /*redeeming/refunding P2SH-B*/; - - Transaction p2shFundingTransaction = BTC.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddressB, amountB); - if (p2shFundingTransaction == null) { - LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?"); - return; - } - - BTC.getInstance().broadcastTransaction(p2shFundingTransaction); - } - // P2SH-B funded, now we wait for Bob to redeem it - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_WATCH_P2SH_B, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_WATCH_P2SH_B, () -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B", tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddressB)); } @@ -820,19 +845,13 @@ public class TradeBot implements Listener { * Assuming P2SH-B is funded, trade-bot 'redeems' this P2SH using secret-B, thus revealing it to Alice. *

    * Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); - return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - + private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { // If we've passed AT refund timestamp then AT will have finished after auto-refunding if (atData.getIsFinished()) { - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_REFUNDED, () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress())); return; @@ -843,17 +862,19 @@ public class TradeBot implements Listener { // AT yet to process MESSAGE return; - byte[] redeemScriptB = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + Bitcoin bitcoin = Bitcoin.getInstance(); - int lockTimeA = crossChainTradeData.lockTimeA; - long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); + byte[] redeemScriptB = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, crossChainTradeData.lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); - final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, crossChainTradeData.lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); - BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; - switch (p2shStatusB) { + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); + + switch (htlcStatusB) { case UNFUNDED: case FUNDING_IN_PROGRESS: // Still waiting for P2SH-B to be funded... @@ -862,7 +883,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // This shouldn't occur, but defensively bump to next state - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, () -> String.format("P2SH-B %s already spent (exposing secret-B)? Checking AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); return; @@ -878,15 +899,16 @@ public class TradeBot implements Listener { // Redeem P2SH-B using secret-B Coin redeemAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. The real funds are in P2SH-A. ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressB); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptB, tradeBotData.getSecret(), receivingAccountInfo); + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptB, tradeBotData.getSecret(), receivingAccountInfo); - BTC.getInstance().broadcastTransaction(p2shRedeemTransaction); + bitcoin.broadcastTransaction(p2shRedeemTransaction); // P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_WAITING_FOR_AT_REDEEM, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_WAITING_FOR_AT_REDEEM, () -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress())); } @@ -905,35 +927,25 @@ public class TradeBot implements Listener { * In revealing a valid secret-A, Bob can then redeem the BTC funds from P2SH-A. *

    * If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); + private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData)) return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - // We check variable in AT that is set when Bob is refunded - if (atData.getIsFinished() && crossChainTradeData.mode == BTCACCT.Mode.REFUNDED) { - // Bob bailed out of trade so we must start refunding too - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_B, - () -> String.format("AT %s has auto-refunded, Attempting refund also", tradeBotData.getAtAddress())); + Bitcoin bitcoin = Bitcoin.getInstance(); - return; - } + byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); - byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, crossChainTradeData.lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; - int lockTimeA = crossChainTradeData.lockTimeA; - long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); - BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); - - switch (p2shStatusB) { + switch (htlcStatusB) { case UNFUNDED: case FUNDING_IN_PROGRESS: case FUNDED: @@ -948,14 +960,12 @@ public class TradeBot implements Listener { case REFUND_IN_PROGRESS: case REFUNDED: // We've refunded P2SH-B? Bump to refunding P2SH-A then - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB)); return; } - List p2shTransactions = BTC.getInstance().getAddressTransactions(p2shAddressB); - - byte[] secretB = BTCP2SH.findP2shSecret(p2shAddressB, p2shTransactions); + byte[] secretB = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddressB); if (secretB == null) // Secret not revealed at this time return; @@ -963,7 +973,7 @@ public class TradeBot implements Listener { // Send 'redeem' MESSAGE to AT using both secrets byte[] secretA = tradeBotData.getSecret(); String qortalReceivingAddress = Base58.encode(tradeBotData.getReceivingAccountInfo()); // Actually contains whole address, not just PKH - byte[] messageData = BTCACCT.buildRedeemMessage(secretA, secretB, qortalReceivingAddress); + byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretA, secretB, qortalReceivingAddress); String messageRecipient = tradeBotData.getAtAddress(); boolean isMessageAlreadySent = repository.getMessageRepository().exists(tradeBotData.getTradeNativePublicKey(), messageRecipient, messageData); @@ -984,7 +994,7 @@ public class TradeBot implements Listener { } } - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, () -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s", p2shAddressB, tradeBotData.getAtAddress(), qortalReceivingAddress)); } @@ -1001,38 +1011,25 @@ public class TradeBot implements Listener { * (This could potentially be 'improved' to send BTC to any address of Bob's choosing by changing the transaction output). *

    * If trade-bot successfully broadcasts the transaction, then this specific trade is done. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); - return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); - + 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's balance should be zero - AccountBalanceData atBalanceData = repository.getAccountRepository().getBalance(tradeBotData.getAtAddress(), Asset.QORT); - if (atBalanceData != null && atBalanceData.getBalance() > 0L) { - LOGGER.debug(() -> String.format("AT %s should have zero balance, not %s", tradeBotData.getAtAddress(), Amounts.prettyAmount(atBalanceData.getBalance()))); - return; - } - - // We check variable in AT that is set when trade successfully completes - if (crossChainTradeData.mode != BTCACCT.Mode.REDEEMED) { - // Not redeemed so must be refunded - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_REFUNDED, + // 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 = BTCACCT.findSecretA(repository, crossChainTradeData); + byte[] secretA = BitcoinACCTv1.findSecretA(repository, crossChainTradeData); if (secretA == null) { LOGGER.debug(() -> String.format("Unable to find secret-A from redeem message to AT %s?", tradeBotData.getAtAddress())); return; @@ -1040,15 +1037,20 @@ public class TradeBot implements Listener { // Use secret-A to redeem P2SH-A + Bitcoin bitcoin = Bitcoin.getInstance(); + int lockTimeA = crossChainTradeData.lockTimeA; + byte[] receivingAccountInfo = tradeBotData.getReceivingAccountInfo(); - byte[] redeemScriptA = BTCP2SH.buildScript(crossChainTradeData.partnerBitcoinPKH, crossChainTradeData.lockTimeA, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretA); - String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); + byte[] redeemScriptA = BitcoinyHTLC.buildScript(crossChainTradeData.partnerForeignPKH, lockTimeA, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretA); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); // Fee for redeem/refund is subtracted from P2SH-A balance. - long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; - BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - switch (p2shStatus) { + 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 @@ -1064,24 +1066,22 @@ public class TradeBot implements Listener { // Wait for AT to auto-refund return; - case FUNDED: - // Fall-through out of switch... + case FUNDED: { + Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT); + ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + + Transaction p2shRedeemTransaction = BitcoinyHTLC.buildRedeemTransaction(bitcoin.getNetworkParameters(), redeemAmount, redeemKey, + fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); + + bitcoin.broadcastTransaction(p2shRedeemTransaction); break; + } } - if (p2shStatus == BTCP2SH.Status.FUNDED) { - Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); - ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressA); + String receivingAddress = bitcoin.pkhToAddress(receivingAccountInfo); - Transaction p2shRedeemTransaction = BTCP2SH.buildRedeemTransaction(redeemAmount, redeemKey, fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); - - BTC.getInstance().broadcastTransaction(p2shRedeemTransaction); - } - - String receivingAddress = BTC.getInstance().pkhToAddress(receivingAccountInfo); - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.BOB_DONE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.BOB_DONE, () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress)); } @@ -1091,36 +1091,38 @@ public class TradeBot implements Listener { * We could potentially skip this step as P2SH-B is only funded with a token amount to cover the mining fee should Bob redeem P2SH-B. *

    * Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); - return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData, + ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException { + int lockTimeB = crossChainTradeData.lockTimeB; // We can't refund P2SH-B until lockTime-B has passed - if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L) + if (NTP.getTime() <= lockTimeB * 1000L) return; - // We can't refund P2SH-B until we've passed median block time - int medianBlockTime = BTC.getInstance().getMedianBlockTime(); - if (NTP.getTime() <= medianBlockTime * 1000L) + Bitcoin bitcoin = Bitcoin.getInstance(); + + // We can't refund P2SH-B until median block time has passed lockTime-B (see BIP113) + int medianBlockTime = bitcoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeB) return; - byte[] redeemScriptB = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), crossChainTradeData.lockTimeB, crossChainTradeData.creatorBitcoinPKH, crossChainTradeData.hashOfSecretB); - String p2shAddressB = BTC.getInstance().deriveP2shAddress(redeemScriptB); + byte[] redeemScriptB = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeB, crossChainTradeData.creatorForeignPKH, crossChainTradeData.hashOfSecretB); + String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); - int lockTimeA = crossChainTradeData.lockTimeA; - long estimatedFee = BTC.getInstance().estimateFee(lockTimeA * 1000L); - final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + estimatedFee; + long feeTimestampB = calcP2shBFeeTimestamp(crossChainTradeData.lockTimeA, lockTimeB); + long p2shFeeB = bitcoin.getP2shFee(feeTimestampB); + final long minimumAmountB = P2SH_B_OUTPUT_AMOUNT + p2shFeeB; - BTCP2SH.Status p2shStatusB = BTCP2SH.determineP2shStatus(p2shAddressB, minimumAmountB); + BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); - switch (p2shStatusB) { + switch (htlcStatusB) { case UNFUNDED: + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, + () -> String.format("P2SH-B %s never funded?. Refunding P2SH-A next", p2shAddressB)); + return; + case FUNDING_IN_PROGRESS: // Still waiting for P2SH-B to be funded... return; @@ -1128,7 +1130,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // We must be very close to trade timeout. Defensively try to refund P2SH-A - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("P2SH-B %s already spent?. Refunding P2SH-A next", p2shAddressB)); return; @@ -1136,57 +1138,56 @@ public class TradeBot implements Listener { case REFUNDED: break; - case FUNDED: + case FUNDED:{ + Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); + + // Determine receive address for refund + String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptB, lockTimeB, receiving.getHash()); + + bitcoin.broadcastTransaction(p2shRefundTransaction); break; + } } - if (p2shStatusB == BTCP2SH.Status.FUNDED) { - Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressB); - - // Determine receive address for refund - String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58()); - Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress); - - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptB, crossChainTradeData.lockTimeB, receiving.getHash()); - - BTC.getInstance().broadcastTransaction(p2shRefundTransaction); - } - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A, () -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddressB)); } /** * Trade-bot is attempting to refund P2SH-A. - * @throws BitcoinException + * @throws ForeignBlockchainException */ - private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, BitcoinException { - ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); - if (atData == null) { - LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress())); - return; - } - CrossChainTradeData crossChainTradeData = BTCACCT.populateTradeData(repository, atData); + 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() <= tradeBotData.getLockTimeA() * 1000L) + if (NTP.getTime() <= lockTimeA * 1000L) return; - // We can't refund P2SH-A until we've passed median block time - int medianBlockTime = BTC.getInstance().getMedianBlockTime(); - if (NTP.getTime() <= medianBlockTime * 1000L) + Bitcoin bitcoin = Bitcoin.getInstance(); + + // We can't refund P2SH-A until median block time has passed lockTime-A (see BIP113) + int medianBlockTime = bitcoin.getMedianBlockTime(); + if (medianBlockTime <= lockTimeA) return; - byte[] redeemScriptA = BTCP2SH.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); - String p2shAddressA = BTC.getInstance().deriveP2shAddress(redeemScriptA); + byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), lockTimeA, crossChainTradeData.creatorForeignPKH, tradeBotData.getHashOfSecret()); + String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); // Fee for redeem/refund is subtracted from P2SH-A balance. - long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; - BTCP2SH.Status p2shStatus = BTCP2SH.determineP2shStatus(p2shAddressA, minimumAmountA); + long feeTimestampA = calcP2shAFeeTimestamp(lockTimeA, crossChainTradeData.tradeTimeout); + long p2shFeeA = bitcoin.getP2shFee(feeTimestampA); + long minimumAmountA = crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT + p2shFeeA; + BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); - switch (p2shStatus) { + switch (htlcStatusA) { case UNFUNDED: case FUNDING_IN_PROGRESS: // Still waiting for P2SH-A to be funded... @@ -1195,7 +1196,7 @@ public class TradeBot implements Listener { case REDEEM_IN_PROGRESS: case REDEEMED: // Too late! - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE, () -> String.format("P2SH-A %s already spent!", p2shAddressA)); return; @@ -1203,50 +1204,66 @@ public class TradeBot implements Listener { case REFUNDED: break; - case FUNDED: - // Fall-through out of switch... + case FUNDED:{ + Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedForeignAmount - P2SH_B_OUTPUT_AMOUNT); + ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); + List fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); + + // Determine receive address for refund + String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey()); + Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); + + Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, + fundingOutputs, redeemScriptA, lockTimeA, receiving.getHash()); + + bitcoin.broadcastTransaction(p2shRefundTransaction); break; + } } - if (p2shStatus == BTCP2SH.Status.FUNDED) { - Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); - ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); - List fundingOutputs = BTC.getInstance().getUnspentOutputs(p2shAddressA); - - // Determine receive address for refund - String receiveAddress = BTC.getInstance().getUnusedReceiveAddress(tradeBotData.getXprv58()); - Address receiving = Address.fromString(BTC.getInstance().getNetworkParameters(), receiveAddress); - - Transaction p2shRefundTransaction = BTCP2SH.buildRefundTransaction(refundAmount, refundKey, fundingOutputs, redeemScriptA, tradeBotData.getLockTimeA(), receiving.getHash()); - - BTC.getInstance().broadcastTransaction(p2shRefundTransaction); - } - - updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDED, + TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDED, () -> String.format("LockTime-A reached. Refunded P2SH-A %s. Trade aborted", p2shAddressA)); } - /** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */ - private static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, TradeBotData.State newState, Supplier logMessageSupplier) throws DataException { - tradeBotData.setState(newState); - tradeBotData.setTimestamp(NTP.getTime()); - repository.getCrossChainRepository().save(tradeBotData); - repository.saveChanges(); + /** + * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else. + *

    + * Will automatically update trade-bot state to ALICE_REFUNDING_B or ALICE_DONE 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; - if (Settings.getInstance().isTradebotSystrayEnabled()) - SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState.name()), MessageType.INFO); + boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress); - if (logMessageSupplier != null) - LOGGER.info(logMessageSupplier); + if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING && isAtLockedToUs) + return false; - LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState.name())); + 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_B, + () -> String.format("AT %s cancelled/refunded/redeemed by someone else/invalid state. Refunding & aborting trade", tradeBotData.getAtAddress())); + } - notifyStateChange(tradeBotData); + return true; } - private static void notifyStateChange(TradeBotData tradeBotData) { - StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData); - EventBus.INSTANCE.notify(stateChangeEvent); + private long calcP2shAFeeTimestamp(int lockTimeA, int tradeTimeout) { + return (lockTimeA - tradeTimeout * 60) * 1000L; + } + + private long calcP2shBFeeTimestamp(int lockTimeA, int lockTimeB) { + // lockTimeB is halfway between offerMessageTimestamp and lockTimeA + return (lockTimeA - (lockTimeA - lockTimeB) * 2) * 1000L; } } diff --git a/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java new file mode 100644 index 00000000..0da3f0ce --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/LitecoinACCTv1TradeBot.java @@ -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. + *

    + * We deal with three different independent state-spaces here: + *

      + *
    • Qortal blockchain
    • + *
    • Foreign blockchain
    • + *
    • Trade-bot entries
    • + *
    + */ +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 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 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 getEndStates() { + return this.endStates; + } + + /** + * Creates a new trade-bot entry from the "Bob" viewpoint, i.e. OFFERing QORT in exchange for LTC. + *

    + * Generates: + *

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

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

    + * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public 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. + *

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

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

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

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

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

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

    + * 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. + *

    + * The trade-bot entry is saved to the repository and the cross-chain trading process commences. + *

    + * @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. + *

    + * 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. + *

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

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

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

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

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

    + * Trade-bot's next step is to wait for 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 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. + *

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

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

    + * If all is well, trade-bot then redeems AT using Alice's secret-A, releasing Bob's QORT to Alice. + *

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

    + * @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 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. + *

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

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

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

    + * If trade-bot successfully broadcasts the transaction, then this specific trade is done. + * @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 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 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. + *

    + * Will automatically update trade-bot state to ALICE_REFUNDING_A or ALICE_DONE 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; + } + +} diff --git a/src/main/java/org/qortal/controller/tradebot/TradeBot.java b/src/main/java/org/qortal/controller/tradebot/TradeBot.java new file mode 100644 index 00000000..84e32125 --- /dev/null +++ b/src/main/java/org/qortal/controller/tradebot/TradeBot.java @@ -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. + *

    + * We deal with three different independent state-spaces here: + *

      + *
    • Qortal blockchain
    • + *
    • Foreign blockchain
    • + *
    • Trade-bot entries
    • + *
    + */ +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, Supplier> acctTradeBotSuppliers = new HashMap<>(); + static { + acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance); + acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance); + } + + private static TradeBot instance; + + private final Map 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. + *

    + * Generates: + *

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

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

    + * @param repository + * @param tradeBotCreateRequest + * @return raw, unsigned DEPLOY_AT transaction + * @throws DataException + */ + public 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. + *

    + * Requires a chosen trade offer from Bob, passed by crossChainTradeData + * and access to a foreign blockchain wallet via foreignKey. + *

    + * @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 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 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 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 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 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())); + } + +} diff --git a/src/main/java/org/qortal/crosschain/ACCT.java b/src/main/java/org/qortal/crosschain/ACCT.java new file mode 100644 index 00000000..e557a3e2 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ACCT.java @@ -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); + +} diff --git a/src/main/java/org/qortal/crosschain/AcctMode.java b/src/main/java/org/qortal/crosschain/AcctMode.java new file mode 100644 index 00000000..21496032 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/AcctMode.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/BTC.java b/src/main/java/org/qortal/crosschain/BTC.java deleted file mode 100644 index 06cfe000..00000000 --- a/src/main/java/org/qortal/crosschain/BTC.java +++ /dev/null @@ -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 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. - *

    - * @throws BitcoinException if error occurs - */ - public Integer getMedianBlockTime() throws BitcoinException { - int height = this.electrumX.getCurrentHeight(); - - // Grab latest 11 blocks - List blockHeaders = this.electrumX.getBlockHeaders(height - 11, 11); - if (blockHeaders.size() < 11) - throw new BitcoinException("Not enough blocks to determine median block time"); - - List 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. - *

    - * @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. - *

    - * @return list of unspent outputs, or empty list if address unknown - * @throws BitcoinException if there was an error. - */ - public List getUnspentOutputs(String base58Address) throws BitcoinException { - List unspentOutputs = this.electrumX.getUnspentOutputs(addressToScript(base58Address), false); - - List unspentTransactionOutputs = new ArrayList<>(); - for (UnspentOutput unspentOutput : unspentOutputs) { - List transactionOutputs = this.getOutputs(unspentOutput.hash); - - unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); - } - - return unspentTransactionOutputs; - } - - /** - * Returns list of outputs pertaining to passed transaction hash. - *

    - * @return list of outputs, or empty list if transaction unknown - * @throws BitcoinException if there was an error. - */ - public List 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. - *

    - * @return list of unspent outputs, or empty list if script unknown - * @throws BitcoinException if there was an error. - */ - public List getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws BitcoinException { - return this.electrumX.getAddressTransactions(addressToScript(base58Address), includeUnconfirmed); - } - - /** - * Returns list of raw, confirmed transactions involving given address. - *

    - * @throws BitcoinException if there was an error - */ - public List getAddressTransactions(String base58Address) throws BitcoinException { - List transactionHashes = this.electrumX.getAddressTransactions(addressToScript(base58Address), false); - - List 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. - *

    - * @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. - *

    - * @throws BitcoinException if error occurs - */ - public void broadcastTransaction(Transaction transaction) throws BitcoinException { - this.electrumX.broadcastTransaction(transaction.bitcoinSerialize()); - } - - /** - * Returns bitcoinj transaction sending amount to recipient. - * - * @param xprv58 BIP32 extended Bitcoin private key - * @param recipient P2PKH address - * @param amount unscaled amount - * @return transaction, or null if insufficient funds - */ - public Transaction buildSpend(String xprv58, String recipient, long amount) { - 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 keys = new ArrayList<>(keyChain.getLeafKeys()); - - int ki = 0; - do { - for (; ki < keys.size(); ++ki) { - DeterministicKey dKey = keys.get(ki); - List 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 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 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 allLeafKeys = keyChain.getLeafKeys(); - // Add only new keys onto our list of keys to search - List newKeys = allLeafKeys.subList(ki, allLeafKeys.size()); - keys.addAll(newKeys); - // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again - - // 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 getOpenTransactionOutputs(List keys) throws UTXOProviderException { - List 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 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 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 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 allLeafKeys = this.keyChain.getLeafKeys(); - // Add only new keys onto our list of keys to search - List newKeys = allLeafKeys.subList(ki, allLeafKeys.size()); - keys.addAll(newKeys); - // Fall-through to checking more keys as now 'ki' is smaller than 'keys.size()' again - } - - // If 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(); - } - -} diff --git a/src/main/java/org/qortal/crosschain/Bitcoin.java b/src/main/java/org/qortal/crosschain/Bitcoin.java new file mode 100644 index 00000000..a8c6469a --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Bitcoin.java @@ -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 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 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 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 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 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); + } + +} diff --git a/src/main/java/org/qortal/crosschain/BTCACCT.java b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java similarity index 95% rename from src/main/java/org/qortal/crosschain/BTCACCT.java rename to src/main/java/org/qortal/crosschain/BitcoinACCTv1.java index 1e803c52..5118e103 100644 --- a/src/main/java/org/qortal/crosschain/BTCACCT.java +++ b/src/main/java/org/qortal/crosschain/BitcoinACCTv1.java @@ -1,13 +1,10 @@ package org.qortal.crosschain; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; import static org.ciyam.at.OpCode.calcOffset; import java.nio.ByteBuffer; import java.util.Arrays; import java.util.List; -import java.util.Map; import org.ciyam.at.API; import org.ciyam.at.CompilationException; @@ -101,11 +98,12 @@ import com.google.common.primitives.Bytes; * * */ -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 MIN_LOCKTIME = 1500000000; - public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ private static final int MODE_VALUE_OFFSET = 68; @@ -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 CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; - public enum Mode { - OFFERING(0), TRADING(1), CANCELLED(2), REFUNDED(3), REDEEMED(4); + private static BitcoinACCTv1 instance; - public final int value; - private static final Map map = stream(Mode.values()).collect(toMap(mode -> mode.value, mode -> mode)); - - Mode(int value) { - this.value = value; - } - - public static Mode valueOf(int value) { - return map.get(value); - } + private BitcoinACCTv1() { } - 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 bitcoinAmount how much BTC the AT creator is expecting to trade * @param tradeTimeout suggested timeout for entire trade - * @return */ public static byte[] buildQortalAT(String creatorTradeAddress, byte[] bitcoinPublicKeyHash, byte[] hashOfSecretB, long qortAmount, long bitcoinAmount, int tradeTimeout) { // 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(addrMessageSender4, addrCreatorAddress4, calcOffset(codeByteBuffer, labelNotTradeNorCancelTxn))); // Partner address is AT creator's address, so cancel offer and finish. - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.CANCELLED.value)); + 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()); @@ -470,7 +476,7 @@ public class BTCACCT { 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, Mode.TRADING.value)); + codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, AcctMode.TRADING.value)); // Set restart position to after this opcode codeByteBuffer.put(OpCode.SET_PCS.compile()); @@ -568,7 +574,7 @@ public class BTCACCT { // Pay AT's balance to receiving address codeByteBuffer.put(OpCode.EXT_FUN_DAT.compile(FunctionCode.PAY_TO_ADDRESS_IN_B, addrQortAmount)); // Set redeemed mode - codeByteBuffer.put(OpCode.SET_VAL.compile(addrMode, Mode.REDEEMED.value)); + 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()); @@ -578,7 +584,7 @@ public class BTCACCT { labelRefund = codeByteBuffer.position(); // 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) codeByteBuffer.put(OpCode.FIN_IMD.compile()); } catch (CompilationException e) { @@ -591,7 +597,7 @@ public class BTCACCT { byte[] codeBytes = new byte[codeByteBuffer.limit()]; 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))); final short ciyamAtVersion = 2; @@ -604,41 +610,34 @@ public class BTCACCT { /** * 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()); return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** * 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()); return populateTradeData(repository, atData.getCreatorPublicKey(), atData.getCreation(), atStateData); } /** * 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 String atAddress = atStateData.getATAddress(); CrossChainTradeData tradeData = new CrossChainTradeData(); + tradeData.foreignBlockchain = SupportedBlockchain.BITCOIN.name(); + tradeData.acctName = NAME; + tradeData.qortalAtAddress = atAddress; tradeData.qortalCreator = Crypto.toAddress(creatorPublicKey); tradeData.creationTimestamp = creationTimestamp; @@ -658,9 +657,9 @@ public class BTCACCT { dataByteBuffer.position(dataByteBuffer.position() + 32 - addressBytes.length); // Creator's Bitcoin/foreign public key hash - tradeData.creatorBitcoinPKH = new byte[20]; - dataByteBuffer.get(tradeData.creatorBitcoinPKH); - dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorBitcoinPKH.length); // skip to 32 bytes + tradeData.creatorForeignPKH = new byte[20]; + dataByteBuffer.get(tradeData.creatorForeignPKH); + dataByteBuffer.position(dataByteBuffer.position() + 32 - tradeData.creatorForeignPKH.length); // skip to 32 bytes // Hash of secret B tradeData.hashOfSecretB = new byte[20]; @@ -671,7 +670,7 @@ public class BTCACCT { tradeData.qortAmount = dataByteBuffer.getLong(); // Expected BTC amount - tradeData.expectedBitcoin = dataByteBuffer.getLong(); + tradeData.expectedForeignAmount = dataByteBuffer.getLong(); // Trade timeout tradeData.tradeTimeout = (int) dataByteBuffer.getLong(); @@ -784,26 +783,28 @@ public class BTCACCT { // Trade AT's 'mode' long modeValue = dataByteBuffer.getLong(); - Mode mode = Mode.valueOf((int) (modeValue & 0xffL)); + AcctMode acctMode = AcctMode.valueOf((int) (modeValue & 0xffL)); /* End of variables */ - if (mode != null && mode != Mode.OFFERING) { - tradeData.mode = mode; + if (acctMode != null && acctMode != AcctMode.OFFERING) { + tradeData.mode = acctMode; tradeData.refundTimeout = refundTimeout; tradeData.tradeRefundHeight = new Timestamp(tradeRefundTimestamp).blockHeight; tradeData.qortalPartnerAddress = qortalRecipient; tradeData.hashOfSecretA = hashOfSecretA; - tradeData.partnerBitcoinPKH = partnerBitcoinPKH; + tradeData.partnerForeignPKH = partnerBitcoinPKH; tradeData.lockTimeA = lockTimeA; tradeData.lockTimeB = lockTimeB; - if (mode == Mode.REDEEMED) + if (acctMode == AcctMode.REDEEMED) tradeData.qortalPartnerReceivingAddress = Base58.encode(partnerReceivingAddress); } else { - tradeData.mode = Mode.OFFERING; + tradeData.mode = AcctMode.OFFERING; } + tradeData.duplicateDeprecated(); + return tradeData; } @@ -843,7 +844,8 @@ public class BTCACCT { } /** 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[] 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. */ 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); } diff --git a/src/main/java/org/qortal/crosschain/BitcoinException.java b/src/main/java/org/qortal/crosschain/BitcoinException.java deleted file mode 100644 index 01db9d49..00000000 --- a/src/main/java/org/qortal/crosschain/BitcoinException.java +++ /dev/null @@ -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); - } - } - -} diff --git a/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java b/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java deleted file mode 100644 index 0e22e27a..00000000 --- a/src/main/java/org/qortal/crosschain/BitcoinNetworkProvider.java +++ /dev/null @@ -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 startHeight (inclusive), up to count max. */ - List getRawBlockHeaders(int startHeight, int count) throws BitcoinException; - - /** Returns balance of address represented by scriptPubKey. */ - long getConfirmedBalance(byte[] scriptPubKey) throws BitcoinException; - - /** Returns raw, serialized, transaction bytes given txHash. */ - byte[] getRawTransaction(String txHash) throws BitcoinException; - - /** Returns unpacked transaction given txHash. */ - BitcoinTransaction getTransaction(String txHash) throws BitcoinException; - - /** Returns list of transaction hashes (and heights) for address represented by scriptPubKey, optionally including unconfirmed transactions. */ - List getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws BitcoinException; - - /** Returns list of unspent transaction outputs for address represented by scriptPubKey, optionally including unconfirmed transactions. */ - List getUnspentOutputs(byte[] scriptPubKey, boolean includeUnconfirmed) throws BitcoinException; - - /** Broadcasts raw, serialized, transaction bytes to network, returning success/failure. */ - boolean broadcastTransaction(byte[] rawTransaction) throws BitcoinException; - -} diff --git a/src/main/java/org/qortal/crosschain/Bitcoiny.java b/src/main/java/org/qortal/crosschain/Bitcoiny.java new file mode 100644 index 00000000..1201b363 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Bitcoiny.java @@ -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,
    + * i.e. keys with transactions but with no unspent outputs. */ + protected final Set 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. + *

    + * @throws ForeignBlockchainException if error occurs + */ + public int getMedianBlockTime() throws ForeignBlockchainException { + int height = this.blockchain.getCurrentHeight(); + + // Grab latest 11 blocks + List blockHeaders = this.blockchain.getRawBlockHeaders(height - 11, 11); + if (blockHeaders.size() < 11) + throw new ForeignBlockchainException("Not enough blocks to determine median block time"); + + List 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. + *

    + * @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. + *

    + * @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 getUnspentOutputs(String base58Address) throws ForeignBlockchainException { + List unspentOutputs = this.blockchain.getUnspentOutputs(addressToScriptPubKey(base58Address), false); + + List unspentTransactionOutputs = new ArrayList<>(); + for (UnspentOutput unspentOutput : unspentOutputs) { + List transactionOutputs = this.getOutputs(unspentOutput.hash); + + unspentTransactionOutputs.add(transactionOutputs.get(unspentOutput.index)); + } + + return unspentTransactionOutputs; + } + + /** + * Returns list of outputs pertaining to passed transaction hash. + *

    + * @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 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. + *

    + * @return list of unspent outputs, or empty list if script unknown + * @throws ForeignBlockchainException if there was an error. + */ + public List getAddressTransactions(String base58Address, boolean includeUnconfirmed) throws ForeignBlockchainException { + return this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), includeUnconfirmed); + } + + /** + * Returns list of raw, confirmed transactions involving given address. + *

    + * @throws ForeignBlockchainException if there was an error + */ + public List getAddressTransactions(String base58Address) throws ForeignBlockchainException { + List transactionHashes = this.blockchain.getAddressTransactions(addressToScriptPubKey(base58Address), false); + + List 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. + *

    + * @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. + *

    + * @throws ForeignBlockchainException if error occurs + */ + public void broadcastTransaction(Transaction transaction) throws ForeignBlockchainException { + this.blockchain.broadcastTransaction(transaction.bitcoinSerialize()); + } + + /** + * Returns bitcoinj transaction sending amount to recipient. + * + * @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 amount to recipient 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 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 keys = new ArrayList<>(keyChain.getLeafKeys()); + + Set 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 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 keys = new ArrayList<>(keyChain.getLeafKeys()); + + int ki = 0; + do { + for (; ki < keys.size(); ++ki) { + DeterministicKey dKey = keys.get(ki); + List 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 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 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 getOpenTransactionOutputs(List keys) throws UTXOProviderException { + List 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 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 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 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 simplifyWalletTransactions(List 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 subList = transactions.subList(fromIndex, toIndex); + + // Only if necessary + if (subList.size() > 1) { + // Quick index lookup + Map 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 simpleTransactions = new ArrayList<>(); + + // Quick lookup of txs in our wallet + Set 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 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 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); + } + +} diff --git a/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java new file mode 100644 index 00000000..7691efb1 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/BitcoinyBlockchainProvider.java @@ -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 startHeight (inclusive), up to count max. */ + public abstract List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException; + + /** Returns balance of address represented by scriptPubKey. */ + public abstract long getConfirmedBalance(byte[] scriptPubKey) throws ForeignBlockchainException; + + /** Returns raw, serialized, transaction bytes given txHash. */ + public abstract byte[] getRawTransaction(String txHash) throws ForeignBlockchainException; + + /** Returns raw, serialized, transaction bytes given txHash. */ + public abstract byte[] getRawTransaction(byte[] txHash) throws ForeignBlockchainException; + + /** Returns unpacked transaction given txHash. */ + public abstract BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException; + + /** Returns list of transaction hashes (and heights) for address represented by scriptPubKey, optionally including unconfirmed transactions. */ + public abstract List getAddressTransactions(byte[] scriptPubKey, boolean includeUnconfirmed) throws ForeignBlockchainException; + + /** Returns list of unspent transaction outputs for address represented by scriptPubKey, optionally including unconfirmed transactions. */ + public abstract List 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; + +} diff --git a/src/main/java/org/qortal/crosschain/BTCP2SH.java b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java similarity index 61% rename from src/main/java/org/qortal/crosschain/BTCP2SH.java rename to src/main/java/org/qortal/crosschain/BitcoinyHTLC.java index ef59ee4d..af93091f 100644 --- a/src/main/java/org/qortal/crosschain/BTCP2SH.java +++ b/src/main/java/org/qortal/crosschain/BitcoinyHTLC.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -29,7 +30,7 @@ import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; -public class BTCP2SH { +public class BitcoinyHTLC { public enum Status { 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 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 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 eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + private static final byte[] NO_SECRET_CACHE_ENTRY = new byte[0]; + + @SuppressWarnings("serial") + private static final Map 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 eldest) { + return size() > MAX_CACHE_ENTRIES; + } + }; + /* * OP_TUCK (to copy public key to before signature) * 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 /** - * Returns Bitcoin redeemScript used for cross-chain trading. + * Returns redeemScript used for cross-chain trading. *

    - * 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 lockTime seconds-since-epoch threshold, after which P2SH funder can claim refund * @param redeemerPubKeyHash 20-byte HASH160 of P2SH redeemer's public key - * @param secretHash 20-byte HASH160 of secret, used by P2SH redeemer to claim funds - * @return + * @param hashOfSecret 20-byte HASH160 of secret, used by P2SH redeemer to claim funds */ - 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)), - 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 spendKey key for signing transaction, and also where funds are 'sent' (output) * @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 scriptSigBuilder function for building scriptSig using transaction input signature * @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 fundingOutputs, byte[] redeemScriptBytes, + public static Transaction buildP2shTransaction(NetworkParameters params, Coin amount, ECKey spendKey, + List fundingOutputs, byte[] redeemScriptBytes, Long lockTime, Function scriptSigBuilder, byte[] outputPublicKeyHash) { - NetworkParameters params = BTC.getInstance().getNetworkParameters(); - Transaction transaction = new Transaction(params); transaction.setVersion(2); @@ -105,9 +133,9 @@ public class BTCP2SH { // Input (without scriptSig prior to signing) TransactionInput input = new TransactionInput(params, null, redeemScriptBytes, fundingOutput.getOutPointFor()); if (lockTime != null) - input.setSequenceNumber(BTC.LOCKTIME_NO_RBF_SEQUENCE); // Use max-value - 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 - 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); } @@ -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 refundKey key for signing transaction, and also where refund is 'sent' (output) - * @param fundingOutput output from transaction that funded P2SH address + * @param refundKey key for signing transaction + * @param fundingOutputs outputs from transaction that funded P2SH address * @param redeemScriptBytes the redeemScript itself, in byte[] form * @param lockTime transaction nLockTime - must be at least locktime used in redeemScript - * @param receivingAccountInfo Bitcoin PKH used for output - * @return Signed Bitcoin transaction for refunding P2SH + * @param receivingAccountInfo public-key-hash used for P2PKH output + * @return Signed transaction for refunding P2SH */ - public static Transaction buildRefundTransaction(Coin refundAmount, ECKey refundKey, List fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) { + public static Transaction buildRefundTransaction(NetworkParameters params, Coin refundAmount, ECKey refundKey, + List fundingOutputs, byte[] redeemScriptBytes, long lockTime, byte[] receivingAccountInfo) { Function refundSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... ScriptBuilder scriptBuilder = new ScriptBuilder(); @@ -163,21 +193,23 @@ public class BTCP2SH { }; // 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 redeemKey key for signing transaction, and also where funds are 'sent' (output) - * @param fundingOutput output from transaction that funded P2SH address + * @param redeemKey key for signing transaction + * @param fundingOutputs outputs from transaction that funded P2SH address * @param redeemScriptBytes the redeemScript itself, in byte[] form * @param secret actual 32-byte secret used when building redeemScript * @param receivingAccountInfo Bitcoin PKH used for output - * @return Signed Bitcoin transaction for redeeming P2SH + * @return Signed transaction for redeeming P2SH */ - public static Transaction buildRedeemTransaction(Coin redeemAmount, ECKey redeemKey, List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) { + public static Transaction buildRedeemTransaction(NetworkParameters params, Coin redeemAmount, ECKey redeemKey, + List fundingOutputs, byte[] redeemScriptBytes, byte[] secret, byte[] receivingAccountInfo) { Function redeemSigScriptBuilder = (txSigBytes) -> { // Build scriptSig with... ScriptBuilder scriptBuilder = new ScriptBuilder(); @@ -198,17 +230,28 @@ public class BTCP2SH { 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 rawTransactions) { - NetworkParameters params = BTC.getInstance().getNetworkParameters(); + /** + * Returns 'secret', if any, given HTLC's P2SH address. + *

    + * @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 rawTransactions = bitcoiny.getAddressTransactions(p2shAddress); for (byte[] rawTransaction : rawTransactions) { 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()) { Script scriptSig = input.getScriptSig(); List scriptChunks = scriptSig.getChunks(); @@ -230,92 +273,115 @@ public class BTCP2SH { Address inputAddress = LegacyAddress.fromScriptHash(params, redeemScriptHash); if (!inputAddress.toString().equals(p2shAddress)) - // Input isn't spending our P2SH + // Input isn't spending our HTLC continue; - byte[] secret = scriptChunks.get(0).data; - if (secret.length != BTCP2SH.SECRET_LENGTH) + secret = scriptChunks.get(0).data; + if (secret.length != BitcoinyHTLC.SECRET_LENGTH) continue; + // Cache secret for a while + SECRET_CACHE.put(compoundKey, secret); + return secret; } } + // Cache negative result + SECRET_CACHE.put(compoundKey, 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 { - final BTC btc = BTC.getInstance(); + /** + * Returns HTLC status, given P2SH address and expected redeem/refund amount + *

    + * @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 transactionHashes = btc.getAddressTransactions(p2shAddress, BTC.INCLUDE_UNCONFIRMED); + Status cachedStatus = STATUS_CACHE.getOrDefault(compoundKey, null); + if (cachedStatus != null) + return cachedStatus; + + byte[] ourScriptPubKey = addressToScriptPubKey(p2shAddress); + List transactionHashes = blockchain.getAddressTransactions(ourScriptPubKey, BitcoinyBlockchainProvider.INCLUDE_UNCONFIRMED); // Sort by confirmed first, followed by ascending height transactionHashes.sort(TransactionHash.CONFIRMED_FIRST.thenComparing(TransactionHash::getHeight)); // Transaction cache - Map transactionsByHash = new HashMap<>(); + Map transactionsByHash = new HashMap<>(); // HASH160(redeem script) for this 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 for (TransactionHash transactionInfo : transactionHashes) { - BitcoinTransaction bitcoinTransaction = btc.getTransaction(transactionInfo.txHash); + BitcoinyTransaction bitcoinyTransaction = blockchain.getTransaction(transactionInfo.txHash); // 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 - if (bitcoinTransaction.inputs.size() != 1) + if (bitcoinyTransaction.inputs.size() != 1) // Wrong number of inputs continue; - String scriptSig = bitcoinTransaction.inputs.get(0).scriptSig; + String scriptSig = bitcoinyTransaction.inputs.get(0).scriptSig; List scriptSigChunks = extractScriptSigChunks(HashCode.fromString(scriptSig).asBytes()); if (scriptSigChunks.size() < 3 || scriptSigChunks.size() > 4) - // Not spending one of these P2SH + // Not valid chunks for our form of HTLC continue; // Last chunk is redeem script byte[] redeemScriptBytes = scriptSigChunks.get(scriptSigChunks.size() - 1); byte[] redeemScriptHash = Crypto.hash160(redeemScriptBytes); if (!Arrays.equals(redeemScriptHash, ourRedeemScriptHash)) - // Not spending our specific P2SH + // Not spending our specific HTLC redeem script continue; - // If we have 4 chunks, then secret is present - return scriptSigChunks.size() == 4 - ? (transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED) - : (transactionInfo.height == 0 ? Status.REFUND_IN_PROGRESS : Status.REFUNDED); + if (scriptSigChunks.size() == 4) + // If we have 4 chunks, then secret is present, hence redeem + cachedStatus = transactionInfo.height == 0 ? Status.REDEEM_IN_PROGRESS : Status.REDEEMED; + 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 for (TransactionHash transactionInfo : transactionHashes) { - BitcoinTransaction bitcoinTransaction = transactionsByHash.get(transactionInfo.txHash); - if (bitcoinTransaction == null) + BitcoinyTransaction bitcoinyTransaction = transactionsByHash.get(transactionInfo.txHash); + if (bitcoinyTransaction == null) // 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 - for (BitcoinTransaction.Output output : bitcoinTransaction.outputs) { + for (BitcoinyTransaction.Output output : bitcoinyTransaction.outputs) { // Check amount if (output.value < minimumAmount) // Output amount too small (not taking fees into account) continue; - String scriptPubKey = output.scriptPubKey; - if (!scriptPubKey.equals(ourScriptPubKey)) + String scriptPubKeyHex = output.scriptPubKey; + if (!scriptPubKeyHex.equals(ourScriptPubKeyHex)) // Not funding our specific P2SH 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 extractScriptSigChunks(byte[] scriptSigBytes) { diff --git a/src/main/java/org/qortal/crosschain/BitcoinTransaction.java b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java similarity index 53% rename from src/main/java/org/qortal/crosschain/BitcoinTransaction.java rename to src/main/java/org/qortal/crosschain/BitcoinyTransaction.java index 05516bc4..caf0b36d 100644 --- a/src/main/java/org/qortal/crosschain/BitcoinTransaction.java +++ b/src/main/java/org/qortal/crosschain/BitcoinyTransaction.java @@ -3,20 +3,43 @@ package org.qortal.crosschain; import java.util.List; 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; + + @XmlTransient public final int size; + + @XmlTransient public final int locktime; + // Not present if transaction is unconfirmed public final Integer timestamp; public static class Input { + @XmlTransient public final String scriptSig; + + @XmlTransient public final int sequence; + public final String outputTxHash; + 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) { this.scriptSig = scriptSig; this.sequence = sequence; @@ -29,15 +52,34 @@ public class BitcoinTransaction { this.outputTxHash, this.outputVout, this.sequence, this.scriptSig); } } + @XmlTransient public final List inputs; public static class Output { + @XmlTransient public final String scriptPubKey; + public final long value; + public final List addresses; + + // For JAXB + protected Output() { + this.scriptPubKey = null; + this.value = 0; + this.addresses = null; + } + public Output(String scriptPubKey, long value) { this.scriptPubKey = scriptPubKey; this.value = value; + this.addresses = null; + } + + public Output(String scriptPubKey, long value, List addresses) { + this.scriptPubKey = scriptPubKey; + this.value = value; + this.addresses = addresses; } public String toString() { @@ -46,7 +88,20 @@ public class BitcoinTransaction { } public final List 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 inputs, List outputs) { this.txHash = txHash; this.size = size; @@ -54,6 +109,8 @@ public class BitcoinTransaction { this.timestamp = timestamp; this.inputs = inputs; this.outputs = outputs; + + this.totalAmount = outputs.stream().map(output -> output.value).reduce(0L, Long::sum); } public String toString() { @@ -67,4 +124,23 @@ public class BitcoinTransaction { this.inputs.stream().map(Input::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(); + } + } \ No newline at end of file diff --git a/src/main/java/org/qortal/crosschain/ElectrumX.java b/src/main/java/org/qortal/crosschain/ElectrumX.java index 8e6d07a0..b34aa199 100644 --- a/src/main/java/org/qortal/crosschain/ElectrumX.java +++ b/src/main/java/org/qortal/crosschain/ElectrumX.java @@ -1,13 +1,17 @@ package org.qortal.crosschain; import java.io.IOException; +import java.math.BigDecimal; import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; import java.util.ArrayList; 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.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; @@ -30,33 +34,25 @@ import org.qortal.crypto.TrustlessSSLSocketFactory; import com.google.common.hash.HashCode; import com.google.common.primitives.Bytes; -/** ElectrumX network support for querying Bitcoin-related info like block headers, transaction outputs, etc. */ -public class ElectrumX { +/** ElectrumX network support for querying Bitcoiny-related info like block headers, transaction outputs, etc. */ +public class ElectrumX extends BitcoinyBlockchainProvider { private static final Logger LOGGER = LogManager.getLogger(ElectrumX.class); private static final Random RANDOM = new Random(); 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 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.'})" 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 - private static final Map instances = new HashMap<>(); + /** Error message sent by some ElectrumX servers when they don't support returning verbose transactions. */ + private static final String VERBOSE_TRANSACTIONS_UNSUPPORTED_MESSAGE = "verbose transactions are currently unsupported"; - private static class Server { + public static class Server { String hostname; - enum ConnectionType { TCP, SSL } + public enum ConnectionType { TCP, SSL } ConnectionType connectionType; int port; @@ -94,108 +90,61 @@ public class ElectrumX { } private Set servers = new HashSet<>(); private List remainingServers = new ArrayList<>(); + private Set uselessServers = Collections.synchronizedSet(new HashSet<>()); - private String expectedGenesisHash; + private final String netId; + private final String expectedGenesisHash; + private final Map defaultPorts = new EnumMap<>(Server.ConnectionType.class); + + private final Object serverLock = new Object(); private Server currentServer; private Socket socket; private Scanner scanner; private int nextId = 1; + private static final int TX_CACHE_SIZE = 200; + @SuppressWarnings("serial") + private final Map 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 eldest) { + return size() > TX_CACHE_SIZE; + } + }); + // Constructors - private ElectrumX(String bitcoinNetwork) { - switch (bitcoinNetwork) { - case "MAIN": - this.expectedGenesisHash = MAIN_GENESIS_HASH; - - 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); + public ElectrumX(String netId, String genesisHash, Collection initialServerList, Map defaultPorts) { + this.netId = netId; + this.expectedGenesisHash = genesisHash; + this.servers.addAll(initialServerList); + this.defaultPorts.putAll(defaultPorts); } // Methods for use by other classes + @Override + public String getNetId() { + return this.netId; + } + /** * Returns current blockchain height. *

    - * @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"); 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; Object heightObj = blockJson.get("height"); 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(); } @@ -203,12 +152,13 @@ public class ElectrumX { /** * Returns list of raw block headers, starting from startHeight inclusive. *

    - * @throws BitcoinException if error occurs + * @throws ForeignBlockchainException if error occurs */ - public List getBlockHeaders(int startHeight, long count) throws BitcoinException { + @Override + public List getRawBlockHeaders(int startHeight, int count) throws ForeignBlockchainException { Object blockObj = this.rpc("blockchain.block.headers", startHeight, count); 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; @@ -216,14 +166,14 @@ public class ElectrumX { Object hexObj = blockJson.get("hex"); 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; String hex = (String) hexObj; byte[] raw = HashCode.fromString(hex).asBytes(); 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 rawBlockHeaders = new ArrayList<>(returnedCount.intValue()); for (int i = 0; i < returnedCount; ++i) @@ -236,22 +186,23 @@ public class ElectrumX { * Returns confirmed balance, based on passed payment script. *

    * @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); Bytes.reverse(scriptHash); Object balanceObj = this.rpc("blockchain.scripthash.get_balance", HashCode.fromBytes(scriptHash).toString()); 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; Object confirmedBalanceObj = balanceJson.get("confirmed"); 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"); } @@ -260,15 +211,16 @@ public class ElectrumX { * Returns list of unspent outputs pertaining to passed payment script. *

    * @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 getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws BitcoinException { + @Override + public List getUnspentOutputs(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); Object unspentJson = this.rpc("blockchain.scripthash.listunspent", HashCode.fromBytes(scriptHash).toString()); 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 unspentOutputs = new ArrayList<>(); for (Object rawUnspent : (JSONArray) unspentJson) { @@ -292,57 +244,93 @@ public class ElectrumX { /** * Returns raw transaction for passed transaction hash. *

    - * @throws BitcoinException.NotFoundException if transaction not found - * @throws BitcoinException if error occurs + * NOTE: Do not mutate returned byte[]! + * + * @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; try { - rawTransactionHex = this.rpc("blockchain.transaction.get", HashCode.fromBytes(txHash).toString()); - } catch (BitcoinException.NetworkException e) { + rawTransactionHex = this.rpc("blockchain.transaction.get", txHash, false); + } 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 BitcoinException.NotFoundException(e.getMessage()); + throw new ForeignBlockchainException.NotFoundException(e.getMessage()); throw e; } 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(); } + /** + * Returns raw transaction for passed transaction hash. + *

    + * 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. *

    - * @throws BitcoinException.NotFoundException if transaction not found - * @throws BitcoinException if error occurs + * @throws ForeignBlockchainException.NotFoundException if transaction not found + * @throws ForeignBlockchainException if error occurs */ - public BitcoinTransaction getTransaction(String txHash) throws BitcoinException { - Object transactionObj; - try { - transactionObj = this.rpc("blockchain.transaction.get", txHash, true); - } catch (BitcoinException.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 BitcoinException.NotFoundException(e.getMessage()); + @Override + public BitcoinyTransaction getTransaction(String txHash) throws ForeignBlockchainException { + // Check cache first + BitcoinyTransaction transaction = transactionCache.get(txHash); + if (transaction != null) + return transaction; - 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)) - 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; Object inputsObj = transactionJson.get("vin"); 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"); 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 { int size = ((Long) transactionJson.get("size")).intValue(); @@ -354,7 +342,7 @@ public class ElectrumX { ? ((Long) timeObj).intValue() : null; - List inputs = new ArrayList<>(); + List inputs = new ArrayList<>(); for (Object inputObj : (JSONArray) inputsObj) { JSONObject inputJson = (JSONObject) inputObj; @@ -363,40 +351,55 @@ public class ElectrumX { String outputTxHash = (String) inputJson.get("txid"); 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 outputs = new ArrayList<>(); + List outputs = new ArrayList<>(); for (Object outputObj : (JSONArray) outputsObj) { JSONObject outputJson = (JSONObject) outputObj; 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 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) { // 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. *

    * @return list of related transactions, or empty list if script unknown - * @throws BitcoinException if error occurs + * @throws ForeignBlockchainException if error occurs */ - public List getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws BitcoinException { + @Override + public List getAddressTransactions(byte[] script, boolean includeUnconfirmed) throws ForeignBlockchainException { byte[] scriptHash = Crypto.digest(script); Bytes.reverse(scriptHash); Object transactionsJson = this.rpc("blockchain.scripthash.get_history", HashCode.fromBytes(scriptHash).toString()); 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 transactionHashes = new ArrayList<>(); @@ -417,16 +420,17 @@ public class ElectrumX { } /** - * Broadcasts raw transaction to Bitcoin network. + * Broadcasts raw transaction to network. *

    - * @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()); // We're expecting a simple string that is the transaction hash 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 @@ -434,10 +438,10 @@ public class ElectrumX { /** * Query current server for its list of peer servers, and return those we can parse. *

    - * @throws BitcoinException + * @throws ForeignBlockchainException * @throws ClassCastException to be handled by caller */ - private Set serverPeersSubscribe() throws BitcoinException { + private Set serverPeersSubscribe() throws ForeignBlockchainException { Set newServers = new HashSet<>(); Object peers = this.connectedRpc("server.peers.subscribe"); @@ -454,17 +458,17 @@ public class ElectrumX { for (Object rawFeature : features) { String feature = (String) rawFeature; Server.ConnectionType connectionType = null; - int port = -1; + Integer port = null; switch (feature.charAt(0)) { case 's': connectionType = Server.ConnectionType.SSL; - port = DEFAULT_SSL_PORT; + port = this.defaultPorts.get(connectionType); break; case 't': connectionType = Server.ConnectionType.TCP; - port = DEFAULT_TCP_PORT; + port = this.defaultPorts.get(connectionType); break; default: @@ -472,7 +476,7 @@ public class ElectrumX { break; } - if (connectionType == null) + if (connectionType == null || port == null) // We couldn't extract any peer connection info? continue; @@ -497,32 +501,29 @@ public class ElectrumX { * Performs RPC call, with automatic reconnection to different server if needed. *

    * @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 { - if (this.remainingServers.isEmpty()) - this.remainingServers.addAll(this.servers); + private Object rpc(String method, Object...params) throws ForeignBlockchainException { + synchronized (this.serverLock) { + if (this.remainingServers.isEmpty()) + this.remainingServers.addAll(this.servers); - while (haveConnection()) { - Object response = connectedRpc(method, params); - if (response != null) - return response; + while (haveConnection()) { + Object response = connectedRpc(method, params); + if (response != null) + return response; - this.currentServer = null; - try { - this.socket.close(); - } catch (IOException e) { - /* ignore */ + // Didn't work, try another server... + this.closeServer(); } - this.scanner = null; - } - // Failed to perform RPC - maybe lack of servers? - throw new BitcoinException.NetworkException("Failed to perform Bitcoin RPC"); + // Failed to perform RPC - maybe lack of servers? + 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. */ - private boolean haveConnection() throws BitcoinException { + private boolean haveConnection() throws ForeignBlockchainException { if (this.currentServer != null) return true; @@ -566,17 +567,9 @@ public class ElectrumX { LOGGER.debug(() -> String.format("Connected to %s", server)); this.currentServer = server; return true; - } catch (IOException | BitcoinException | ClassCastException | NullPointerException e) { - // Try another server... - if (this.socket != null && !this.socket.isClosed()) - try { - this.socket.close(); - } catch (IOException e1) { - // We did try... - } - - this.socket = null; - this.scanner = null; + } catch (IOException | ForeignBlockchainException | ClassCastException | NullPointerException e) { + // Didn't work, try another server... + closeServer(); } } @@ -589,10 +582,10 @@ public class ElectrumX { * @param method * @param params * @return response Object, or null if server fails to respond - * @throws BitcoinException if server returns error + * @throws ForeignBlockchainException if server returns error */ @SuppressWarnings("unchecked") - private Object connectedRpc(String method, Object...params) throws BitcoinException { + private Object connectedRpc(String method, Object...params) throws ForeignBlockchainException { JSONObject requestJson = new JSONObject(); requestJson.put("id", this.nextId++); requestJson.put("method", method); @@ -630,15 +623,18 @@ public class ElectrumX { Object errorObj = responseJson.get("error"); 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)) - 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; Object messageObj = errorJson.get("message"); 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; @@ -649,15 +645,44 @@ public class ElectrumX { if (messageMatcher.find()) try { int daemonErrorCode = Integer.parseInt(messageMatcher.group(1)); - throw new BitcoinException.NetworkException(daemonErrorCode, message); + throw new ForeignBlockchainException.NetworkException(daemonErrorCode, message, this.currentServer); } catch (NumberFormatException e) { // 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"); } + /** + * Closes connection to server 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); + } + } + } diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchain.java b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java new file mode 100644 index 00000000..0a71e9d9 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchain.java @@ -0,0 +1,9 @@ +package org.qortal.crosschain; + +public interface ForeignBlockchain { + + public boolean isValidAddress(String address); + + public boolean isValidWalletKey(String walletKey); + +} diff --git a/src/main/java/org/qortal/crosschain/ForeignBlockchainException.java b/src/main/java/org/qortal/crosschain/ForeignBlockchainException.java new file mode 100644 index 00000000..1e658621 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/ForeignBlockchainException.java @@ -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); + } + } + +} diff --git a/src/main/java/org/qortal/crosschain/Litecoin.java b/src/main/java/org/qortal/crosschain/Litecoin.java new file mode 100644 index 00000000..5cbe4044 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/Litecoin.java @@ -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 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 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 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 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 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); + } + +} diff --git a/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java new file mode 100644 index 00000000..454e80c2 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/LitecoinACCTv1.java @@ -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 + * + *

    + *

      + *
    • Bob generates Litecoin & Qortal 'trade' keys + *
        + *
      • private key required to sign P2SH redeem tx
      • + *
      • private key could be used to create 'secret' (e.g. double-SHA256)
      • + *
      • encrypted private key could be stored in Qortal AT for access by Bob from any node
      • + *
      + *
    • + *
    • Bob deploys Qortal AT + *
        + *
      + *
    • + *
    • Alice finds Qortal AT and wants to trade + *
        + *
      • Alice generates Litecoin & Qortal 'trade' keys
      • + *
      • Alice funds Litecoin P2SH-A
      • + *
      • Alice sends 'offer' MESSAGE to Bob from her Qortal trade address, containing: + *
          + *
        • hash-of-secret-A
        • + *
        • her 'trade' Litecoin PKH
        • + *
        + *
      • + *
      + *
    • + *
    • Bob receives "offer" MESSAGE + *
        + *
      • Checks Alice's P2SH-A
      • + *
      • Sends 'trade' MESSAGE to Qortal AT from his trade address, containing: + *
          + *
        • Alice's trade Qortal address
        • + *
        • Alice's trade Litecoin PKH
        • + *
        • hash-of-secret-A
        • + *
        + *
      • + *
      + *
    • + *
    • Alice checks Qortal AT to confirm it's locked to her + *
        + *
      • Alice sends 'redeem' MESSAGE to Qortal AT from her trade address, containing: + *
          + *
        • secret-A
        • + *
        • Qortal receiving address of her chosing
        • + *
        + *
      • + *
      • AT's QORT funds are sent to Qortal receiving address
      • + *
      + *
    • + *
    • Bob checks AT, extracts secret-A + *
        + *
      • Bob redeems P2SH-A using his Litecoin trade key and secret-A
      • + *
      • P2SH-A LTC funds end up at Litecoin address determined by redeem transaction output(s)
      • + *
      + *
    • + *
    + */ +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; + + /** Value offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ + private static final int MODE_VALUE_OFFSET = 61; + /** Byte offset into AT state data where 'mode' variable (long) is stored. */ + public static final int MODE_BYTE_OFFSET = MachineState.HEADER_LENGTH + (MODE_VALUE_OFFSET * MachineState.VALUE_SIZE); + + public static class OfferMessageData { + public byte[] 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. + *

    + * tradeTimeout (minutes) is the time window for the trade partner to send the + * 32-byte secret to the AT, before the AT automatically refunds the AT's creator. + * + * @param creatorTradeAddress AT creator's trade Qortal address + * @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 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; + } + +} diff --git a/src/main/java/org/qortal/crosschain/SupportedBlockchain.java b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java new file mode 100644 index 00000000..7b6f91f5 --- /dev/null +++ b/src/main/java/org/qortal/crosschain/SupportedBlockchain.java @@ -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> 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> supportedAcctsByName = Arrays.stream(SupportedBlockchain.values()) + .map(supportedBlockchain -> supportedBlockchain.supportedAccts) + .flatMap(List::stream) + .collect(Collectors.toUnmodifiableMap(Triple::getA, Triple::getC)); + + private static final Map blockchainsByName = Arrays.stream(SupportedBlockchain.values()) + .collect(Collectors.toUnmodifiableMap(Enum::name, blockchain -> blockchain)); + + private final List>> supportedAccts; + + SupportedBlockchain(List>> supportedAccts) { + this.supportedAccts = supportedAccts; + } + + public abstract ForeignBlockchain getInstance(); + public abstract ACCT getLatestAcct(); + + public static Map> getAcctMap() { + return supportedAcctsByCodeHash; + } + + public static SupportedBlockchain fromString(String name) { + return blockchainsByName.get(name); + } + + public static Map> 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> 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 acctInstanceSupplier = supportedAcctsByCodeHash.get(wrappedCodeHash); + + if (acctInstanceSupplier == null) + return null; + + return acctInstanceSupplier.get(); + } + + public static ACCT getAcctByName(String acctName) { + Supplier acctInstanceSupplier = supportedAcctsByName.get(acctName); + + if (acctInstanceSupplier == null) + return null; + + return acctInstanceSupplier.get(); + } + +} \ No newline at end of file diff --git a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java index f445f58e..69250e54 100644 --- a/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java +++ b/src/main/java/org/qortal/data/crosschain/CrossChainTradeData.java @@ -4,7 +4,7 @@ import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.AcctMode; import io.swagger.v3.oas.annotations.media.Schema; @@ -20,12 +20,16 @@ public class CrossChainTradeData { @Schema(description = "AT creator's Qortal address") 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; - @Schema(description = "AT creator's Bitcoin trade public-key-hash (PKH)") + @Deprecated + @Schema(description = "DEPRECATED: use creatorForeignPKH instead") 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)") 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)") 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) 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; - @Schema(description = "Suggested Bitcoin P2SH-B nLockTime based on trade timeout") + @Schema(description = "Suggested P2SH-B nLockTime based on trade timeout") public Integer lockTimeB; - @Schema(description = "Trade partner's Bitcoin public-key-hash (PKH)") + @Deprecated + @Schema(description = "DEPRECATED: use partnerForeignPKH instead") 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") public String qortalPartnerReceivingAddress; + public String foreignBlockchain; + + public String acctName; + // Constructors // Necessary for JAXB public CrossChainTradeData() { } + public void duplicateDeprecated() { + this.creatorBitcoinPKH = this.creatorForeignPKH; + this.expectedBitcoin = this.expectedForeignAmount; + this.partnerBitcoinPKH = this.partnerForeignPKH; + } + } diff --git a/src/main/java/org/qortal/data/crosschain/TradeBotData.java b/src/main/java/org/qortal/data/crosschain/TradeBotData.java index 5c9cff4b..b360c53e 100644 --- a/src/main/java/org/qortal/data/crosschain/TradeBotData.java +++ b/src/main/java/org/qortal/data/crosschain/TradeBotData.java @@ -1,10 +1,5 @@ package org.qortal.data.crosschain; -import static java.util.Arrays.stream; -import static java.util.stream.Collectors.toMap; - -import java.util.Map; - import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlTransient; @@ -18,22 +13,13 @@ public class TradeBotData { private byte[] tradePrivateKey; - public enum State { - BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(15), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(25), BOB_DONE(30), BOB_REFUNDED(35), - ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_DONE(95), ALICE_REFUNDING_B(100), ALICE_REFUNDING_A(105), ALICE_REFUNDED(110); + private String acctName; + private String tradeState; - public final int value; - private static final Map map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); - - State(int value) { - this.value = value; - } - - public static State valueOf(int value) { - return map.get(value); - } - } - private State tradeState; + // Internal use - not shown via API + @XmlTransient + @Schema(hidden = true) + private int tradeStateValue; private String creatorAddress; private String atAddress; @@ -50,19 +36,25 @@ public class TradeBotData { private byte[] secret; private byte[] hashOfSecret; + private String foreignBlockchain; private byte[] tradeForeignPublicKey; private byte[] tradeForeignPublicKeyHash; + @Deprecated + @Schema(description = "DEPRECATED: use foreignAmount instead", type = "number") @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) 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 @XmlTransient @Schema(hidden = true) - private String xprv58; + private String foreignKey; private byte[] lastTransactionSignature; - private Integer lockTimeA; // Could be Bitcoin or Qortal... @@ -72,14 +64,18 @@ public class TradeBotData { /* 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, byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, byte[] secret, byte[] hashOfSecret, - byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, - long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { + String foreignBlockchain, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, + long foreignAmount, String foreignKey, + byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { this.tradePrivateKey = tradePrivateKey; + this.acctName = acctName; this.tradeState = tradeState; + this.tradeStateValue = tradeStateValue; this.creatorAddress = creatorAddress; this.atAddress = atAddress; this.timestamp = timestamp; @@ -89,10 +85,13 @@ public class TradeBotData { this.tradeNativeAddress = tradeNativeAddress; this.secret = secret; this.hashOfSecret = hashOfSecret; + this.foreignBlockchain = foreignBlockchain; this.tradeForeignPublicKey = tradeForeignPublicKey; this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; - this.bitcoinAmount = bitcoinAmount; - this.xprv58 = xprv58; + // deprecated copy + this.bitcoinAmount = foreignAmount; + this.foreignAmount = foreignAmount; + this.foreignKey = foreignKey; this.lastTransactionSignature = lastTransactionSignature; this.lockTimeA = lockTimeA; this.receivingAccountInfo = receivingAccountInfo; @@ -102,14 +101,26 @@ public class TradeBotData { return this.tradePrivateKey; } - public State getState() { + public String getAcctName() { + return this.acctName; + } + + public String getState() { return this.tradeState; } - public void setState(State state) { + public void setState(String state) { this.tradeState = state; } + public int getStateValue() { + return this.tradeStateValue; + } + + public void setStateValue(int stateValue) { + this.tradeStateValue = stateValue; + } + public String getCreatorAddress() { return this.creatorAddress; } @@ -154,6 +165,10 @@ public class TradeBotData { return this.hashOfSecret; } + public String getForeignBlockchain() { + return this.foreignBlockchain; + } + public byte[] getTradeForeignPublicKey() { return this.tradeForeignPublicKey; } @@ -162,12 +177,12 @@ public class TradeBotData { return this.tradeForeignPublicKeyHash; } - public long getBitcoinAmount() { - return this.bitcoinAmount; + public long getForeignAmount() { + return this.foreignAmount; } - public String getXprv58() { - return this.xprv58; + public String getForeignKey() { + return this.foreignKey; } public byte[] getLastTransactionSignature() { @@ -192,7 +207,7 @@ public class TradeBotData { // Mostly for debugging 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); } } diff --git a/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java new file mode 100644 index 00000000..001bd5b4 --- /dev/null +++ b/src/main/java/org/qortal/data/transaction/PresenceTransactionData.java @@ -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; + } + +} diff --git a/src/main/java/org/qortal/data/transaction/TransactionData.java b/src/main/java/org/qortal/data/transaction/TransactionData.java index 397693b8..060901f2 100644 --- a/src/main/java/org/qortal/data/transaction/TransactionData.java +++ b/src/main/java/org/qortal/data/transaction/TransactionData.java @@ -40,7 +40,7 @@ import io.swagger.v3.oas.annotations.media.Schema.AccessMode; GroupApprovalTransactionData.class, SetGroupTransactionData.class, UpdateAssetTransactionData.class, AccountFlagsTransactionData.class, RewardShareTransactionData.class, - AccountLevelTransactionData.class, ChatTransactionData.class + AccountLevelTransactionData.class, ChatTransactionData.class, PresenceTransactionData.class }) //All properties to be converted to JSON via JAXB @XmlAccessorType(XmlAccessType.FIELD) diff --git a/src/main/java/org/qortal/event/EventBus.java b/src/main/java/org/qortal/event/EventBus.java index 63c80143..6114c2c6 100644 --- a/src/main/java/org/qortal/event/EventBus.java +++ b/src/main/java/org/qortal/event/EventBus.java @@ -3,9 +3,14 @@ package org.qortal.event; import java.util.ArrayList; import java.util.List; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + public enum EventBus { INSTANCE; + private static final Logger LOGGER = LogManager.getLogger(EventBus.class); + private static final List LISTENERS = new ArrayList<>(); public void addListener(Listener newListener) { @@ -22,18 +27,25 @@ public enum EventBus { /** * WARNING: 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 + * repository.saveChanges() or * repository.discardChanges(). *

    * This is because event listeners might open a new * repository session which will deadlock HSQLDB * if it tries to CHECKPOINT. *

    - * The HSQLDB deadlock occurs because the caller's - * repository session blocks the CHECKPOINT until - * their transaction is closed, yet event listeners - * new sessions are blocked until CHECKPOINT is - * completed, hence deadlock. + * The HSQLDB deadlock path is: + *

      + *
    • write-log blockchain.log has grown past CHECKPOINT threshold (50MB)
    • + *
    • alternatively, another thread has explicitly requested CHECKPOINT
    • + *
    • HSQLDB won't begin CHECKPOINT until all pending (SQL) transactions are committed or rolled back
    • + *
    • Same thread calls EventBus.INSTANCE.notify() before (SQL) transaction closed
    • + *
    • EventBus listener (same thread) requests a new repository session via RepositoryManager.getRepository()
    • + *
    • New repository sessions are blocked pending completion of CHECKPOINT
    • + *
    • Caller is blocked so never has a chance to close (SQL) transaction - hence deadlock
    • + *
    */ public void notify(Event event) { List clonedListeners; @@ -43,6 +55,11 @@ public enum EventBus { } 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); + } } } diff --git a/src/main/java/org/qortal/repository/ATRepository.java b/src/main/java/org/qortal/repository/ATRepository.java index 7970d548..0854a21c 100644 --- a/src/main/java/org/qortal/repository/ATRepository.java +++ b/src/main/java/org/qortal/repository/ATRepository.java @@ -1,9 +1,11 @@ package org.qortal.repository; import java.util.List; +import java.util.Set; import org.qortal.data.at.ATData; import org.qortal.data.at.ATStateData; +import org.qortal.utils.ByteArray; public interface ATRepository { @@ -24,6 +26,9 @@ public interface ATRepository { /** Returns list of ATs with matching code hash, optionally executable only. */ public List 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 getAllATsByFunctionality(Set codeHashes, Boolean isExecutable) throws DataException; + /** Returns creation block height given AT's address or null if not found */ public Integer getATCreationBlockHeight(String atAddress) throws DataException; @@ -75,6 +80,26 @@ public interface ATRepository { Integer dataByteOffset, Long expectedValue, Integer minimumFinalHeight, 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 + * minimumCount entries over a span of at least + * minimumPeriod ms, given enough entries in repository. + *

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

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

    + * Although expectedValue, if provided, is natively an unsigned long, + * the data segment comparison is done via unsigned hex string. + */ + public List getMatchingFinalATStatesQuorum(byte[] codeHash, Boolean isFinished, + Integer dataByteOffset, Long expectedValue, + int minimumCount, long minimumPeriod) throws DataException; + /** * Returns all ATStateData for a given block height. *

    diff --git a/src/main/java/org/qortal/repository/CrossChainRepository.java b/src/main/java/org/qortal/repository/CrossChainRepository.java index cee1dc69..70ebdbf9 100644 --- a/src/main/java/org/qortal/repository/CrossChainRepository.java +++ b/src/main/java/org/qortal/repository/CrossChainRepository.java @@ -8,6 +8,9 @@ public interface CrossChainRepository { 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 excludeStates) throws DataException; + public List getAllTradeBotData() throws DataException; public void save(TradeBotData tradeBotData) throws DataException; diff --git a/src/main/java/org/qortal/repository/TransactionRepository.java b/src/main/java/org/qortal/repository/TransactionRepository.java index 68d0cdac..acde78df 100644 --- a/src/main/java/org/qortal/repository/TransactionRepository.java +++ b/src/main/java/org/qortal/repository/TransactionRepository.java @@ -239,6 +239,18 @@ public interface TransactionRepository { return getUnconfirmedTransactions(null, null, null); } + /** + * Returns list of unconfirmed transactions with specified type and/or creator. + *

    + * At least one of txType or creatorPublicKey must be non-null. + * + * @param txType optional + * @param creatorPublicKey optional + * @return list of transactions, or empty if none. + * @throws DataException + */ + public List getUnconfirmedTransactions(TransactionType txType, byte[] creatorPublicKey) throws DataException; + /** * Remove transaction from unconfirmed transactions pile. * diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java index 10d7874a..f82e4e62 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBATRepository.java @@ -4,6 +4,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; +import java.util.Set; import org.apache.logging.log4j.LogManager; 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.repository.ATRepository; import org.qortal.repository.DataException; +import org.qortal.utils.ByteArray; import com.google.common.primitives.Longs; @@ -201,6 +203,80 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public List getAllATsByFunctionality(Set codeHashes, Boolean isExecutable) throws DataException { + StringBuilder sql = new StringBuilder(512); + List 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 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 public Integer getATCreationBlockHeight(String atAddress) throws DataException { String sql = "SELECT height " @@ -375,6 +451,92 @@ public class HSQLDBATRepository implements ATRepository { } } + @Override + public List 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 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 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 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 public List getBlockATStatesAtHeight(int height) throws DataException { String sql = "SELECT AT_address, state_hash, fees, is_initial " diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java index 589ca0a4..29f2994c 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBCrossChainRepository.java @@ -3,6 +3,7 @@ package org.qortal.repository.hsqldb; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.qortal.data.crosschain.TradeBotData; @@ -19,12 +20,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { @Override 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, " + "trade_native_public_key, trade_native_public_key_hash, " + "trade_native_address, secret, hash_of_secret, " - + "trade_foreign_public_key, trade_foreign_public_key_hash, " - + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info " + + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, " + + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info " + "FROM TradeBotStates " + "WHERE trade_private_key = ?"; @@ -32,49 +34,80 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { if (resultSet == null) return null; - int tradeStateValue = resultSet.getInt(1); - TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); - if (tradeState == null) - throw new DataException("Illegal trade-bot trade-state fetched from repository"); - - String creatorAddress = resultSet.getString(2); - String atAddress = resultSet.getString(3); - long timestamp = resultSet.getLong(4); - long qortAmount = resultSet.getLong(5); - byte[] tradeNativePublicKey = resultSet.getBytes(6); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(7); - String tradeNativeAddress = resultSet.getString(8); - byte[] secret = resultSet.getBytes(9); - byte[] hashOfSecret = resultSet.getBytes(10); - byte[] tradeForeignPublicKey = resultSet.getBytes(11); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(12); - long bitcoinAmount = resultSet.getLong(13); - String xprv58 = resultSet.getString(14); - byte[] lastTransactionSignature = resultSet.getBytes(15); - Integer lockTimeA = resultSet.getInt(16); + String acctName = resultSet.getString(1); + String tradeState = resultSet.getString(2); + int tradeStateValue = resultSet.getInt(3); + String creatorAddress = resultSet.getString(4); + String atAddress = resultSet.getString(5); + long timestamp = resultSet.getLong(6); + long qortAmount = resultSet.getLong(7); + byte[] tradeNativePublicKey = resultSet.getBytes(8); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(9); + String tradeNativeAddress = resultSet.getString(10); + byte[] secret = resultSet.getBytes(11); + byte[] hashOfSecret = resultSet.getBytes(12); + String foreignBlockchain = resultSet.getString(13); + byte[] tradeForeignPublicKey = resultSet.getBytes(14); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(15); + long foreignAmount = resultSet.getLong(16); + String foreignKey = resultSet.getString(17); + byte[] lastTransactionSignature = resultSet.getBytes(18); + Integer lockTimeA = resultSet.getInt(19); if (lockTimeA == 0 && resultSet.wasNull()) 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, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, - tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); + foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo); } catch (SQLException e) { throw new DataException("Unable to fetch trade-bot trading state from repository", e); } } + @Override + public boolean existsTradeWithAtExcludingStates(String atAddress, List 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 public List 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, " + "trade_native_public_key, trade_native_public_key_hash, " + "trade_native_address, secret, hash_of_secret, " - + "trade_foreign_public_key, trade_foreign_public_key_hash, " - + "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info " + + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, " + + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info " + "FROM TradeBotStates"; List allTradeBotData = new ArrayList<>(); @@ -85,36 +118,36 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { do { byte[] tradePrivateKey = resultSet.getBytes(1); - int tradeStateValue = resultSet.getInt(2); - TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); - if (tradeState == null) - throw new DataException("Illegal trade-bot trade-state fetched from repository"); - - String creatorAddress = resultSet.getString(3); - String atAddress = resultSet.getString(4); - long timestamp = resultSet.getLong(5); - long qortAmount = resultSet.getLong(6); - byte[] tradeNativePublicKey = resultSet.getBytes(7); - byte[] tradeNativePublicKeyHash = resultSet.getBytes(8); - String tradeNativeAddress = resultSet.getString(9); - byte[] secret = resultSet.getBytes(10); - byte[] hashOfSecret = resultSet.getBytes(11); - byte[] tradeForeignPublicKey = resultSet.getBytes(12); - byte[] tradeForeignPublicKeyHash = resultSet.getBytes(13); - long bitcoinAmount = resultSet.getLong(14); - String xprv58 = resultSet.getString(15); - byte[] lastTransactionSignature = resultSet.getBytes(16); - Integer lockTimeA = resultSet.getInt(17); + String acctName = resultSet.getString(2); + String tradeState = resultSet.getString(3); + int tradeStateValue = resultSet.getInt(4); + String creatorAddress = resultSet.getString(5); + String atAddress = resultSet.getString(6); + long timestamp = resultSet.getLong(7); + long qortAmount = resultSet.getLong(8); + byte[] tradeNativePublicKey = resultSet.getBytes(9); + byte[] tradeNativePublicKeyHash = resultSet.getBytes(10); + String tradeNativeAddress = resultSet.getString(11); + byte[] secret = resultSet.getBytes(12); + byte[] hashOfSecret = resultSet.getBytes(13); + String foreignBlockchain = resultSet.getString(14); + byte[] tradeForeignPublicKey = resultSet.getBytes(15); + byte[] tradeForeignPublicKeyHash = resultSet.getBytes(16); + long foreignAmount = resultSet.getLong(17); + String foreignKey = resultSet.getString(18); + byte[] lastTransactionSignature = resultSet.getBytes(19); + Integer lockTimeA = resultSet.getInt(20); if (lockTimeA == 0 && resultSet.wasNull()) 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, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, secret, hashOfSecret, - tradeForeignPublicKey, tradeForeignPublicKeyHash, - bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); + foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash, + foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo); allTradeBotData.add(tradeBotData); } while (resultSet.next()); @@ -129,7 +162,9 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository { HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates"); 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("at_address", tradeBotData.getAtAddress()) .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_hash", tradeBotData.getTradeNativePublicKeyHash()) .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_hash", tradeBotData.getTradeForeignPublicKeyHash()) - .bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) - .bind("xprv58", tradeBotData.getXprv58()) + .bind("foreign_amount", tradeBotData.getForeignAmount()) + .bind("foreign_key", tradeBotData.getForeignKey()) .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) .bind("locktime_a", tradeBotData.getLockTimeA()) .bind("receiving_account_info", tradeBotData.getReceivingAccountInfo()); diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java index 2616b1d3..b82f55c3 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBDatabaseUpdates.java @@ -4,9 +4,12 @@ import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.util.Arrays; +import java.util.stream.Collectors; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot; public class HSQLDBDatabaseUpdates { @@ -618,6 +621,7 @@ public class HSQLDBDatabaseUpdates { case 20: // Trade bot + // See case 25 below for changes stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " + "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " @@ -779,6 +783,45 @@ public class HSQLDBDatabaseUpdates { + "height INT NOT NULL, PRIMARY KEY (height, AT_address))"); 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: // nothing to do return false; diff --git a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java index 4fbe1649..eb549367 100644 --- a/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/HSQLDBRepository.java @@ -16,6 +16,7 @@ import java.sql.Savepoint; import java.sql.Statement; import java.util.ArrayDeque; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.Deque; @@ -825,15 +826,18 @@ public class HSQLDBRepository implements Repository { *

    * (Convenience method for HSQLDB repository subclasses). */ - /* package */ static void temporaryValuesTableSql(StringBuilder stringBuilder, List values, String tableName, String columnName) { + /* package */ static void temporaryValuesTableSql(StringBuilder stringBuilder, Collection values, String tableName, String columnName) { stringBuilder.append("(VALUES "); - for (int i = 0; i < values.size(); ++i) { - if (i != 0) + boolean first = true; + for (Object value : values) { + if (first) + first = false; + else stringBuilder.append(", "); stringBuilder.append("("); - stringBuilder.append(values.get(i)); + stringBuilder.append(value); stringBuilder.append(")"); } diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java new file mode 100644 index 00000000..309ffcad --- /dev/null +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBPresenceTransactionRepository.java @@ -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); + } + } + +} diff --git a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java index a603a916..83eeba72 100644 --- a/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java +++ b/src/main/java/org/qortal/repository/hsqldb/transaction/HSQLDBTransactionRepository.java @@ -1124,6 +1124,63 @@ public class HSQLDBTransactionRepository implements TransactionRepository { } } + @Override + public List 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 whereClauses = new ArrayList<>(); + List 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 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 public void confirmTransaction(byte[] signature) throws DataException { try { diff --git a/src/main/java/org/qortal/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java index 1d33dcb7..22d8da24 100644 --- a/src/main/java/org/qortal/settings/Settings.java +++ b/src/main/java/org/qortal/settings/Settings.java @@ -21,7 +21,8 @@ import org.eclipse.persistence.exceptions.XMLMarshalException; import org.eclipse.persistence.jaxb.JAXBContextFactory; import org.eclipse.persistence.jaxb.UnmarshallerProperties; 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 @XmlAccessorType(XmlAccessType.FIELD) @@ -123,6 +124,7 @@ public class Settings { // Which blockchains this node is running private String blockchainConfig = null; // use default from resources private BitcoinNet bitcoinNet = BitcoinNet.MAIN; + private LitecoinNet litecoinNet = LitecoinNet.MAIN; // Also crosschain-related: /** Whether to show SysTray pop-up notifications when trade-bot entries change state */ private boolean tradebotSystrayEnabled = false; @@ -406,6 +408,10 @@ public class Settings { return this.bitcoinNet; } + public LitecoinNet getLitecoinNet() { + return this.litecoinNet; + } + public boolean isTradebotSystrayEnabled() { return this.tradebotSystrayEnabled; } diff --git a/src/main/java/org/qortal/transaction/ChatTransaction.java b/src/main/java/org/qortal/transaction/ChatTransaction.java index d3eec9f7..ccef1f37 100644 --- a/src/main/java/org/qortal/transaction/ChatTransaction.java +++ b/src/main/java/org/qortal/transaction/ChatTransaction.java @@ -141,7 +141,7 @@ public class ChatTransaction extends Transaction { // 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.chatTransactionData.getSignature())) - return ValidationResult.CHAT; + return ValidationResult.INVALID_BUT_OK; // If we have a recipient, check it is a valid address String recipientAddress = chatTransactionData.getRecipient(); @@ -188,6 +188,16 @@ public class ChatTransaction extends Transaction { 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 public void process() throws DataException { throw new DataException("CHAT transactions should never be processed"); diff --git a/src/main/java/org/qortal/transaction/PresenceTransaction.java b/src/main/java/org/qortal/transaction/PresenceTransaction.java new file mode 100644 index 00000000..772928e9 --- /dev/null +++ b/src/main/java/org/qortal/transaction/PresenceTransaction.java @@ -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 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 name or null (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 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. + *

    + * 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> acctSuppliersByCodeHash = SupportedBlockchain.getAcctMap(); + Set codeHashes = acctSuppliersByCodeHash.keySet(); + boolean isExecutable = true; + + List 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 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 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"); + } + +} diff --git a/src/main/java/org/qortal/transaction/Transaction.java b/src/main/java/org/qortal/transaction/Transaction.java index d683f9fa..2236e353 100644 --- a/src/main/java/org/qortal/transaction/Transaction.java +++ b/src/main/java/org/qortal/transaction/Transaction.java @@ -83,7 +83,8 @@ public abstract class Transaction { ENABLE_FORGING(37, false), REWARD_SHARE(38, false), ACCOUNT_LEVEL(39, false), - TRANSFER_PRIVS(40, false); + TRANSFER_PRIVS(40, false), + PRESENCE(41, false); public final int value; public final boolean needsApproval; @@ -244,7 +245,8 @@ public abstract class Transaction { ACCOUNT_ALREADY_EXISTS(92), INVALID_GROUP_BLOCK_DELAY(93), INCORRECT_NONCE(94), - CHAT(999), + INVALID_TIMESTAMP_SIGNATURE(95), + INVALID_BUT_OK(999), NOT_YET_RELEASED(1000); public final int value; @@ -763,15 +765,20 @@ public abstract class Transaction { /** * Import into our repository as a new, unconfirmed transaction. *

    - * Calls repository.saveChanges() + * @implSpec blocks to obtain blockchain lock + *

    + * If transaction is valid, then: + *

      + *
    • calls {@link Repository#discardChanges()}
    • + *
    • calls {@link Controller#onNewTransaction(TransactionData, Peer)}
    • + *
    * * @throws DataException */ public ValidationResult importAsUnconfirmed() throws DataException { // Attempt to acquire blockchain lock ReentrantLock blockchainLock = Controller.getInstance().getBlockchainLock(); - if (!blockchainLock.tryLock()) - return ValidationResult.NO_BLOCKCHAIN_LOCK; + blockchainLock.lock(); try { // Check transaction doesn't already exist @@ -798,22 +805,30 @@ public abstract class Transaction { repository.getTransactionRepository().save(transactionData); repository.getTransactionRepository().unconfirmTransaction(transactionData); - /* - * 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(); + this.onImportAsUnconfirmed(); repository.saveChanges(); + // Notify controller of new transaction + Controller.getInstance().onNewTransaction(transactionData); + return ValidationResult.OK; } finally { blockchainLock.unlock(); } } + /** + * Callback for when a transaction is imported as unconfirmed. + *

    + * Called after transaction is added to repository, but before commit. + *

    + * 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. *

    diff --git a/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java new file mode 100644 index 00000000..bf69d102 --- /dev/null +++ b/src/main/java/org/qortal/transform/transaction/PresenceTransactionTransformer.java @@ -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; + } + +} diff --git a/src/main/java/org/qortal/utils/ByteArray.java b/src/main/java/org/qortal/utils/ByteArray.java index d89714e1..d3464c9f 100644 --- a/src/main/java/org/qortal/utils/ByteArray.java +++ b/src/main/java/org/qortal/utils/ByteArray.java @@ -1,12 +1,19 @@ package org.qortal.utils; +import java.util.Arrays; +import java.util.Objects; + public class ByteArray implements Comparable { private int hash; public final 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 @@ -14,36 +21,39 @@ public class ByteArray implements Comparable { if (this == other) return true; - if (other instanceof ByteArray) - return this.compareTo((ByteArray) other) == 0; - 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; } @Override public int hashCode() { - int h = hash; - if (h == 0 && value.length > 0) { - byte[] val = value; + int h = this.hash; + byte[] val = this.value; + + if (h == 0 && val.length > 0) { + h = 1; for (int i = 0; i < val.length; ++i) h = 31 * h + val[i]; - hash = h; + this.hash = h; } return h; } @Override public int compareTo(ByteArray other) { - return this.compareTo(other.value); + Objects.requireNonNull(other); + return this.compareToPrimitive(other.value); } - public int compareTo(byte[] otherValue) { - byte[] val = value; + public int compareToPrimitive(byte[] otherValue) { + byte[] val = this.value; if (val.length < otherValue.length) return -1; @@ -63,4 +73,17 @@ public class ByteArray implements Comparable { 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(); + } + } diff --git a/src/main/java/org/qortal/utils/Triple.java b/src/main/java/org/qortal/utils/Triple.java index 5095a2da..0b9757ee 100644 --- a/src/main/java/org/qortal/utils/Triple.java +++ b/src/main/java/org/qortal/utils/Triple.java @@ -1,42 +1,55 @@ package org.qortal.utils; -public class Triple { +public class Triple { - private T a; - private U b; - private V c; + @FunctionalInterface + public interface TripleConsumer { + public void accept(A a, B b, C c); + } + + private A a; + private B b; + private C c; + + public static Triple valueOf(A a, B b, C c) { + return new Triple<>(a, b, c); + } public Triple() { } - public Triple(T a, U b, V c) { + public Triple(A a, B b, C c) { this.a = a; this.b = b; this.c = c; } - public void setA(T a) { + public void setA(A a) { this.a = a; } - public T getA() { + public A getA() { return a; } - public void setB(U b) { + public void setB(B b) { this.b = b; } - public U getB() { + public B getB() { return b; } - public void setC(V c) { + public void setC(C c) { this.c = c; } - public V getC() { + public C getC() { return c; } + public void consume(TripleConsumer consumer) { + consumer.accept(this.a, this.b, this.c); + } + } diff --git a/src/test/java/org/qortal/test/ByteArrayTests.java b/src/test/java/org/qortal/test/ByteArrayTests.java index 32c692ef..8fb6f1cf 100644 --- a/src/test/java/org/qortal/test/ByteArrayTests.java +++ b/src/test/java/org/qortal/test/ByteArrayTests.java @@ -3,10 +3,12 @@ package org.qortal.test; import static org.junit.Assert.*; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Random; +import java.util.TreeMap; import org.junit.Before; import org.junit.Test; @@ -28,15 +30,13 @@ public class ByteArrayTests { } } - private void fillMap(Map map) { + private static void fillMap(Map map) { for (byte[] testValue : testValues) map.put(new ByteArray(testValue), String.valueOf(map.size())); } - private byte[] dup(byte[] value) { - byte[] copiedValue = new byte[value.length]; - System.arraycopy(value, 0, copiedValue, 0, copiedValue.length); - return copiedValue; + private static byte[] dup(byte[] value) { + return Arrays.copyOf(value, value.length); } @Test @@ -92,7 +92,7 @@ public class ByteArrayTests { @Test @SuppressWarnings("unlikely-arg-type") - public void testMapContainsKey() { + public void testHashMapContainsKey() { Map testMap = new HashMap<>(); fillMap(testMap); @@ -105,8 +105,59 @@ public class ByteArrayTests { 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 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 and byte[] does not fit + * 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 @@ -116,8 +167,9 @@ public class ByteArrayTests { 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("Primitive hashCode: 0x%08x", copiedValue.hashCode())); + System.out.println(String.format("Duplicated primitive hashCode: 0x%08x", copiedValue.hashCode())); } @Test diff --git a/src/test/java/org/qortal/test/PresenceTests.java b/src/test/java/org/qortal/test/PresenceTests.java new file mode 100644 index 00000000..b53b72cb --- /dev/null +++ b/src/test/java/org/qortal/test/PresenceTests.java @@ -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); + } + +} diff --git a/src/test/java/org/qortal/test/RepositoryTests.java b/src/test/java/org/qortal/test/RepositoryTests.java index 79283607..434e03f0 100644 --- a/src/test/java/org/qortal/test/RepositoryTests.java +++ b/src/test/java/org/qortal/test/RepositoryTests.java @@ -4,7 +4,7 @@ import org.junit.Before; import org.junit.Test; import org.qortal.account.Account; import org.qortal.asset.Asset; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crypto.Crypto; import org.qortal.repository.DataException; import org.qortal.repository.Repository; @@ -398,7 +398,7 @@ public class RepositoryTests extends Common { @Test public void testAtLateral() { try (final HSQLDBRepository hsqldb = (HSQLDBRepository) RepositoryManager.getRepository()) { - byte[] codeHash = BTCACCT.CODE_BYTES_HASH; + byte[] codeHash = BitcoinACCTv1.CODE_BYTES_HASH; Boolean isFinished = null; Integer dataByteOffset = null; Long expectedValue = null; diff --git a/src/test/java/org/qortal/test/api/CrossChainApiTests.java b/src/test/java/org/qortal/test/api/CrossChainApiTests.java index 16e12a43..d4f25bce 100644 --- a/src/test/java/org/qortal/test/api/CrossChainApiTests.java +++ b/src/test/java/org/qortal/test/api/CrossChainApiTests.java @@ -4,10 +4,13 @@ import org.junit.Before; import org.junit.Test; import org.qortal.api.ApiError; import org.qortal.api.resource.CrossChainResource; +import org.qortal.crosschain.SupportedBlockchain; import org.qortal.test.common.ApiCommon; public class CrossChainApiTests extends ApiCommon { + private static final SupportedBlockchain SPECIFIC_BLOCKCHAIN = null; + private CrossChainResource crossChainResource; @Before @@ -17,12 +20,13 @@ public class CrossChainApiTests extends ApiCommon { @Test 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 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 @@ -31,8 +35,8 @@ public class CrossChainApiTests extends ApiCommon { Integer offset = null; Boolean reverse = null; - assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(-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, -1L /*minimumTimestamp*/, limit, offset, reverse)); + assertApiError(ApiError.INVALID_CRITERIA, () -> this.crossChainResource.getCompletedTrades(SPECIFIC_BLOCKCHAIN, 0L /*minimumTimestamp*/, limit, offset, reverse)); } } diff --git a/src/test/java/org/qortal/test/apps/BTCACCTTests.java b/src/test/java/org/qortal/test/apps/BTCACCTTests.java deleted file mode 100644 index 499cf743..00000000 --- a/src/test/java/org/qortal/test/apps/BTCACCTTests.java +++ /dev/null @@ -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) }; - } - -} diff --git a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java b/src/test/java/org/qortal/test/btcacct/BuildP2SH.java deleted file mode 100644 index 6b6b16e1..00000000 --- a/src/test/java/org/qortal/test/btcacct/BuildP2SH.java +++ /dev/null @@ -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 ()")); - 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()); - } - } - -} diff --git a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java b/src/test/java/org/qortal/test/btcacct/CheckP2SH.java deleted file mode 100644 index e7d96bc1..00000000 --- a/src/test/java/org/qortal/test/btcacct/CheckP2SH.java +++ /dev/null @@ -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 ()")); - 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 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()); - } - } - -} diff --git a/src/test/java/org/qortal/test/btcacct/Common.java b/src/test/java/org/qortal/test/btcacct/Common.java deleted file mode 100644 index 320d1c1c..00000000 --- a/src/test/java/org/qortal/test/btcacct/Common.java +++ /dev/null @@ -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"); - -} diff --git a/src/test/java/org/qortal/test/btcacct/P2shTests.java b/src/test/java/org/qortal/test/btcacct/P2shTests.java deleted file mode 100644 index 075b6586..00000000 --- a/src/test/java/org/qortal/test/btcacct/P2shTests.java +++ /dev/null @@ -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 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())); - } - -} diff --git a/src/test/java/org/qortal/test/btcacct/Redeem.java b/src/test/java/org/qortal/test/btcacct/Redeem.java deleted file mode 100644 index 0ca20608..00000000 --- a/src/test/java/org/qortal/test/btcacct/Redeem.java +++ /dev/null @@ -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 ()")); - 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 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()); - } - } - -} diff --git a/src/test/java/org/qortal/test/btcacct/Refund.java b/src/test/java/org/qortal/test/btcacct/Refund.java deleted file mode 100644 index 184985d9..00000000 --- a/src/test/java/org/qortal/test/btcacct/Refund.java +++ /dev/null @@ -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 ()")); - 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 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()); - } - } - -} diff --git a/src/test/java/org/qortal/test/btcacct/BtcTests.java b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java similarity index 69% rename from src/test/java/org/qortal/test/btcacct/BtcTests.java rename to src/test/java/org/qortal/test/crosschain/BitcoinTests.java index 08bd26be..af879e08 100644 --- a/src/test/java/org/qortal/test/btcacct/BtcTests.java +++ b/src/test/java/org/qortal/test/crosschain/BitcoinTests.java @@ -1,44 +1,46 @@ -package org.qortal.test.btcacct; +package org.qortal.test.crosschain; import static org.junit.Assert.*; import java.util.Arrays; -import java.util.List; import org.bitcoinj.core.Transaction; import org.bitcoinj.store.BlockStoreException; import org.junit.After; import org.junit.Before; import org.junit.Test; -import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BTCP2SH; -import org.qortal.crosschain.BitcoinException; +import org.qortal.crosschain.Bitcoin; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.BitcoinyHTLC; import org.qortal.repository.DataException; import org.qortal.test.common.Common; -public class BtcTests extends Common { +public class BitcoinTests extends Common { + + private Bitcoin bitcoin; @Before public void beforeTest() throws DataException { Common.useDefaultSettings(); // TestNet3 + bitcoin = Bitcoin.getInstance(); } @After public void afterTest() { - BTC.resetForTesting(); + Bitcoin.resetForTesting(); + bitcoin = null; } @Test - public void testGetMedianBlockTime() throws BlockStoreException, BitcoinException { + public void testGetMedianBlockTime() throws BlockStoreException, ForeignBlockchainException { System.out.println(String.format("Starting BTC instance...")); - BTC btc = BTC.getInstance(); System.out.println(String.format("BTC instance started")); 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(); - 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 firstPeriod = afterFirst - before; @@ -51,14 +53,12 @@ public class BtcTests extends Common { } @Test - public void testFindP2shSecret() throws BitcoinException { + public void testFindHtlcSecret() throws ForeignBlockchainException { // This actually exists on TEST3 but can take a while to fetch String p2shAddress = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; - List rawTransactions = BTC.getInstance().getAddressTransactions(p2shAddress); - byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes(); - byte[] secret = BTCP2SH.findP2shSecret(p2shAddress, rawTransactions); + byte[] secret = BitcoinyHTLC.findHtlcSecret(bitcoin, p2shAddress); assertNotNull(secret); assertTrue("secret incorrect", Arrays.equals(expectedSecret, secret)); @@ -66,52 +66,46 @@ public class BtcTests extends Common { @Test public void testBuildSpend() { - BTC btc = BTC.getInstance(); - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; String recipient = "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"; long amount = 1000L; - Transaction transaction = btc.buildSpend(xprv58, recipient, amount); + Transaction transaction = bitcoin.buildSpend(xprv58, recipient, amount); assertNotNull(transaction); // Check spent key caching doesn't affect outcome - transaction = btc.buildSpend(xprv58, recipient, amount); + transaction = bitcoin.buildSpend(xprv58, recipient, amount); assertNotNull(transaction); } @Test public void testGetWalletBalance() { - BTC btc = BTC.getInstance(); - String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - Long balance = btc.getWalletBalance(xprv58); + Long balance = bitcoin.getWalletBalance(xprv58); assertNotNull(balance); - System.out.println(BTC.format(balance)); + System.out.println(bitcoin.format(balance)); // Check spent key caching doesn't affect outcome - Long repeatBalance = btc.getWalletBalance(xprv58); + Long repeatBalance = bitcoin.getWalletBalance(xprv58); assertNotNull(repeatBalance); - System.out.println(BTC.format(repeatBalance)); + System.out.println(bitcoin.format(repeatBalance)); assertEquals(balance, repeatBalance); } @Test - public void testGetUnusedReceiveAddress() throws BitcoinException { - BTC btc = BTC.getInstance(); - + public void testGetUnusedReceiveAddress() throws ForeignBlockchainException { String xprv58 = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ"; - String address = btc.getUnusedReceiveAddress(xprv58); + String address = bitcoin.getUnusedReceiveAddress(xprv58); assertNotNull(address); diff --git a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java b/src/test/java/org/qortal/test/crosschain/ElectrumXTests.java similarity index 67% rename from src/test/java/org/qortal/test/btcacct/ElectrumXTests.java rename to src/test/java/org/qortal/test/crosschain/ElectrumXTests.java index 99123763..b7e57cf3 100644 --- a/src/test/java/org/qortal/test/btcacct/ElectrumXTests.java +++ b/src/test/java/org/qortal/test/crosschain/ElectrumXTests.java @@ -1,9 +1,11 @@ -package org.qortal.test.btcacct; +package org.qortal.test.crosschain; import static org.junit.Assert.*; import java.security.Security; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import org.bitcoinj.core.Address; import org.bitcoinj.params.TestNet3Params; @@ -11,11 +13,13 @@ import org.bitcoinj.script.ScriptBuilder; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; import org.junit.Test; -import org.qortal.crosschain.BitcoinException; -import org.qortal.crosschain.BitcoinTransaction; +import org.qortal.crosschain.ForeignBlockchainException; +import org.qortal.crosschain.BitcoinyTransaction; import org.qortal.crosschain.ElectrumX; import org.qortal.crosschain.TransactionHash; import org.qortal.crosschain.UnspentOutput; +import org.qortal.crosschain.Bitcoin.BitcoinNet; +import org.qortal.crosschain.ElectrumX.Server.ConnectionType; import org.qortal.utils.BitTwiddling; import com.google.common.hash.HashCode; @@ -30,15 +34,25 @@ public class ElectrumXTests { Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); } + private static final Map 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 public void testInstance() { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + ElectrumX electrumX = getInstance(); assertNotNull(electrumX); } @Test - public void testGetCurrentHeight() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetCurrentHeight() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); int height = electrumX.getCurrentHeight(); @@ -48,10 +62,10 @@ public class ElectrumXTests { @Test public void testInvalidRequest() { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + ElectrumX electrumX = getInstance(); try { - electrumX.getBlockHeaders(-1, -1); - } catch (BitcoinException e) { + electrumX.getRawBlockHeaders(-1, -1); + } catch (ForeignBlockchainException e) { // Should throw due to negative start block height return; } @@ -60,13 +74,13 @@ public class ElectrumXTests { } @Test - public void testGetRecentBlocks() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetRecentBlocks() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); int height = electrumX.getCurrentHeight(); assertTrue(height > 10000); - List recentBlockHeaders = electrumX.getBlockHeaders(height - 11, 11); + List recentBlockHeaders = electrumX.getRawBlockHeaders(height - 11, 11); System.out.println(String.format("Returned %d recent blocks", recentBlockHeaders.size())); for (int i = 0; i < recentBlockHeaders.size(); ++i) { @@ -80,8 +94,8 @@ public class ElectrumXTests { } @Test - public void testGetP2PKHBalance() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetP2PKHBalance() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); Address address = Address.fromString(TestNet3Params.get(), "n3GNqMveyvaPvUbH469vDRadqpJMPc84JA"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); @@ -93,8 +107,8 @@ public class ElectrumXTests { } @Test - public void testGetP2SHBalance() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetP2SHBalance() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); @@ -106,8 +120,8 @@ public class ElectrumXTests { } @Test - public void testGetUnspentOutputs() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetUnspentOutputs() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); Address address = Address.fromString(TestNet3Params.get(), "2N4szZUfigj7fSBCEX4PaC8TVbC5EvidaVF"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); @@ -120,8 +134,8 @@ public class ElectrumXTests { } @Test - public void testGetRawTransaction() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetRawTransaction() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); byte[] txHash = HashCode.fromString("7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af").asBytes(); @@ -132,26 +146,26 @@ public class ElectrumXTests { @Test public void testGetUnknownRawTransaction() { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + ElectrumX electrumX = getInstance(); byte[] txHash = HashCode.fromString("f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0").asBytes(); try { electrumX.getRawTransaction(txHash); fail("Bitcoin transaction should be unknown and hence throw exception"); - } catch (BitcoinException e) { - if (!(e instanceof BitcoinException.NotFoundException)) + } catch (ForeignBlockchainException e) { + if (!(e instanceof ForeignBlockchainException.NotFoundException)) fail("Bitcoin transaction should be unknown and hence throw NotFoundException"); } } @Test - public void testGetTransaction() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetTransaction() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); String txHash = "7653fea9ffcd829d45ed2672938419a94951b08175982021e77d619b553f29af"; - BitcoinTransaction transaction = electrumX.getTransaction(txHash); + BitcoinyTransaction transaction = electrumX.getTransaction(txHash); assertNotNull(transaction); assertTrue(transaction.txHash.equals(txHash)); @@ -159,22 +173,22 @@ public class ElectrumXTests { @Test public void testGetUnknownTransaction() { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + ElectrumX electrumX = getInstance(); String txHash = "f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0"; try { electrumX.getTransaction(txHash); fail("Bitcoin transaction should be unknown and hence throw exception"); - } catch (BitcoinException e) { - if (!(e instanceof BitcoinException.NotFoundException)) + } catch (ForeignBlockchainException e) { + if (!(e instanceof ForeignBlockchainException.NotFoundException)) fail("Bitcoin transaction should be unknown and hence throw NotFoundException"); } } @Test - public void testGetAddressTransactions() throws BitcoinException { - ElectrumX electrumX = ElectrumX.getInstance("TEST3"); + public void testGetAddressTransactions() throws ForeignBlockchainException { + ElectrumX electrumX = getInstance(); Address address = Address.fromString(TestNet3Params.get(), "2N8WCg52ULCtDSMjkgVTm5mtPdCsUptkHWE"); byte[] script = ScriptBuilder.createOutputScript(address).getProgram(); diff --git a/src/test/java/org/qortal/test/crosschain/HtlcTests.java b/src/test/java/org/qortal/test/crosschain/HtlcTests.java new file mode 100644 index 00000000..82e8e016 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/HtlcTests.java @@ -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); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/LitecoinTests.java b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java new file mode 100644 index 00000000..ea75456e --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java @@ -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); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java new file mode 100644 index 00000000..fa92fde7 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/BuildHTLC.java @@ -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) ")); + 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))); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java new file mode 100644 index 00000000..8b1cc423 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/CheckHTLC.java @@ -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) ")); + 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); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/Common.java b/src/test/java/org/qortal/test/crosschain/apps/Common.java new file mode 100644 index 00000000..78066fe7 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/Common.java @@ -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 getUnspentOutputs(Bitcoiny bitcoiny, String address58) { + List 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); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/GetNextReceiveAddress.java b/src/test/java/org/qortal/test/crosschain/apps/GetNextReceiveAddress.java new file mode 100644 index 00000000..ef22355b --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/GetNextReceiveAddress.java @@ -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) ")); + 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)); + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/GetTransaction.java b/src/test/java/org/qortal/test/crosschain/apps/GetTransaction.java similarity index 60% rename from src/test/java/org/qortal/test/btcacct/GetTransaction.java rename to src/test/java/org/qortal/test/crosschain/apps/GetTransaction.java index 49e1f966..9d903a56 100644 --- a/src/test/java/org/qortal/test/btcacct/GetTransaction.java +++ b/src/test/java/org/qortal/test/crosschain/apps/GetTransaction.java @@ -1,4 +1,4 @@ -package org.qortal.test.btcacct; +package org.qortal.test.crosschain.apps; import java.security.Security; import java.util.List; @@ -6,8 +6,11 @@ import java.util.List; import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.TransactionOutput; import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.qortal.crosschain.BTC; -import org.qortal.crosschain.BitcoinException; +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; import com.google.common.hash.HashCode; @@ -23,34 +26,51 @@ public class GetTransaction { if (error != null) System.err.println(error); - System.err.println(String.format("usage: GetTransaction ")); - System.err.println(String.format("example (mainnet): GetTransaction 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660")); - System.err.println(String.format("example (testnet): GetTransaction 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e")); + System.err.println(String.format("usage: GetTransaction (-b | -l) ")); + System.err.println(String.format("example (mainnet): GetTransaction -b 816407e79568f165f13e09e9912c5f2243e0a23a007cec425acedc2e89284660")); + System.err.println(String.format("example (testnet): GetTransaction -b 3bfd17a492a4e3d6cb7204e17e20aca6c1ab82e1828bd1106eefbaf086fb8a4e")); System.exit(1); } public static void main(String[] args) { - if (args.length != 1) + if (args.length != 2) usage(null); Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + Settings.fileInstance("settings-test.json"); + Bitcoiny bitcoiny = null; byte[] transactionId = null; + int argIndex = 0; 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(); } 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 fundingOutputs; try { - fundingOutputs = BTC.getInstance().getOutputs(transactionId); - } catch (BitcoinException e) { + fundingOutputs = bitcoiny.getOutputs(transactionId); + } catch (ForeignBlockchainException e) { System.out.println(String.format("Transaction not found (or error occurred)")); return; } diff --git a/src/test/java/org/qortal/test/crosschain/apps/GetWalletTransactions.java b/src/test/java/org/qortal/test/crosschain/apps/GetWalletTransactions.java new file mode 100644 index 00000000..f44fc0a6 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/GetWalletTransactions.java @@ -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) ")); + 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 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)); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/Pay.java b/src/test/java/org/qortal/test/crosschain/apps/Pay.java new file mode 100644 index 00000000..93c7aede --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/Pay.java @@ -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) ")); + 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); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java new file mode 100644 index 00000000..d4f1bcf1 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/RedeemHTLC.java @@ -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) ")); + 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 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); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java b/src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java new file mode 100644 index 00000000..723185f0 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/apps/RefundHTLC.java @@ -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) ")); + 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 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); + } + +} diff --git a/src/test/java/org/qortal/test/btcacct/AtTests.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java similarity index 86% rename from src/test/java/org/qortal/test/btcacct/AtTests.java rename to src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java index fd187938..4487e874 100644 --- a/src/test/java/org/qortal/test/btcacct/AtTests.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/BitcoinACCTv1Tests.java @@ -1,4 +1,4 @@ -package org.qortal.test.btcacct; +package org.qortal.test.crosschain.bitcoinv1; import static org.junit.Assert.*; @@ -18,7 +18,8 @@ import org.qortal.account.Account; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; import org.qortal.block.Block; -import org.qortal.crosschain.BTCACCT; +import org.qortal.crosschain.BitcoinACCTv1; +import org.qortal.crosschain.AcctMode; import org.qortal.crypto.Crypto; import org.qortal.data.at.ATData; 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.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[] hashOfSecretA = Crypto.hash160(secretA); // daf59884b4d1aec8c1b17102530909ee43c0151a @@ -51,7 +52,7 @@ public class AtTests extends Common { public static final int tradeTimeout = 20; // blocks public static final long redeemAmount = 80_40200000L; public static final long fundingAmount = 123_45600000L; - public static final long bitcoinAmount = 864200L; + public static final long bitcoinAmount = 864200L; // 0.00864200 BTC private static final Random RANDOM = new Random(); @@ -64,8 +65,10 @@ public class AtTests extends Common { public void testCompile() { PrivateKeyAccount tradeAccount = createTradeAccount(null); - byte[] creationBytes = BTCACCT.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); - System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecretB, redeemAmount, bitcoinAmount, tradeTimeout); + assertNotNull(creationBytes); + + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); } @Test @@ -136,7 +139,7 @@ public class AtTests extends Common { long deployersPostDeploymentBalance = deployersInitialBalance - fundingAmount - deployAtFee; // 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); long messageFee = messageTransaction.getTransactionData().getFee(); @@ -150,8 +153,8 @@ public class AtTests extends Common { assertTrue(atData.getIsFinished()); // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); // Check balances long expectedMinimumBalance = deployersPostDeploymentBalance; @@ -209,8 +212,8 @@ public class AtTests extends Common { assertTrue(atData.getIsFinished()); // AT should be in CANCELLED mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.CANCELLED, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.CANCELLED, tradeData.mode); } } @@ -232,10 +235,10 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // 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); Block postDeploymentBlock = BlockUtils.mintBlock(repository); @@ -247,10 +250,10 @@ public class AtTests extends Common { describeAt(repository, 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 - assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + assertEquals(AcctMode.TRADING, tradeData.mode); // Check hashOfSecretA was extracted correctly assertTrue(Arrays.equals(hashOfSecretA, tradeData.hashOfSecretA)); @@ -259,7 +262,7 @@ public class AtTests extends Common { assertEquals(partner.getAddress(), tradeData.qortalPartnerAddress); // Check trade partner's Bitcoin PKH was extracted correctly - assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerBitcoinPKH)); + assertTrue(Arrays.equals(bitcoinPublicKeyHash, tradeData.partnerForeignPKH)); // Test orphaning BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); @@ -293,10 +296,10 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); 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 - 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); BlockUtils.mintBlock(repository); @@ -309,10 +312,10 @@ public class AtTests extends Common { describeAt(repository, 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 - assertEquals(BTCACCT.Mode.OFFERING, tradeData.mode); + assertEquals(AcctMode.OFFERING, tradeData.mode); } } @@ -334,10 +337,10 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // 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); Block postDeploymentBlock = BlockUtils.mintBlock(repository); @@ -356,8 +359,8 @@ public class AtTests extends Common { assertTrue(atData.getIsFinished()); // AT should be in REFUNDED mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.REFUNDED, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REFUNDED, tradeData.mode); // Test orphaning BlockUtils.orphanToBlock(repository, postDeploymentBlockHeight); @@ -388,17 +391,17 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // 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); // Give AT time to process message BlockUtils.mintBlock(repository); // 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); // AT should send funds in the next block @@ -412,8 +415,8 @@ public class AtTests extends Common { assertTrue(atData.getIsFinished()); // AT should be in REDEEMED mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.REDEEMED, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.REDEEMED, tradeData.mode); // Check balances long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() + redeemAmount; @@ -459,17 +462,17 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // 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); // Give AT time to process message BlockUtils.mintBlock(repository); // 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); // AT should NOT send funds in the next block @@ -483,8 +486,8 @@ public class AtTests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); // Check balances long expectedBalance = partnersInitialBalance; @@ -517,10 +520,10 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // 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); // Give AT time to process message @@ -529,7 +532,7 @@ public class AtTests extends Common { // Send incorrect secrets to AT, from correct account byte[] wrongSecret = new byte[32]; RANDOM.nextBytes(wrongSecret); - messageData = BTCACCT.buildRedeemMessage(wrongSecret, secretB, partner.getAddress()); + messageData = BitcoinACCTv1.buildRedeemMessage(wrongSecret, secretB, partner.getAddress()); messageTransaction = sendMessage(repository, partner, messageData, atAddress); // AT should NOT send funds in the next block @@ -543,8 +546,8 @@ public class AtTests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); long actualBalance = partner.getConfirmedBalance(Asset.QORT); @@ -552,7 +555,7 @@ public class AtTests extends Common { assertEquals("Partner's balance incorrect", expectedBalance, actualBalance); // 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); // AT should NOT send funds in the next block @@ -565,8 +568,8 @@ public class AtTests extends Common { assertFalse(atData.getIsFinished()); // AT should still be in TRADE mode - tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); // Check balances expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee() * 2; @@ -597,10 +600,10 @@ public class AtTests extends Common { long partnersOfferMessageTransactionTimestamp = System.currentTimeMillis(); int lockTimeA = calcTestLockTimeA(partnersOfferMessageTransactionTimestamp); - int lockTimeB = BTCACCT.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); + int lockTimeB = BitcoinACCTv1.calcLockTimeB(partnersOfferMessageTransactionTimestamp, lockTimeA); // 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); // Give AT time to process message @@ -621,8 +624,8 @@ public class AtTests extends Common { assertFalse(atData.getIsFinished()); // AT should be in TRADING mode - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); - assertEquals(BTCACCT.Mode.TRADING, tradeData.mode); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); + assertEquals(AcctMode.TRADING, tradeData.mode); } } @@ -654,7 +657,7 @@ public class AtTests extends Common { HashCode.fromBytes(codeHash))); // Not one of ours? - if (!Arrays.equals(codeHash, BTCACCT.CODE_BYTES_HASH)) + if (!Arrays.equals(codeHash, BitcoinACCTv1.CODE_BYTES_HASH)) continue; describeAt(repository, atAddress); @@ -667,7 +670,7 @@ public class AtTests extends Common { } 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(); byte[] lastReference = deployer.getLastReference(); @@ -744,7 +747,7 @@ public class AtTests extends Common { private void describeAt(Repository repository, String atAddress) throws DataException { ATData atData = repository.getATRepository().fromATAddress(atAddress); - CrossChainTradeData tradeData = BTCACCT.populateTradeData(repository, atData); + CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData); Function epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); @@ -760,17 +763,17 @@ public class AtTests extends Common { + "\texpected bitcoin: %s BTC,\n" + "\tcurrent block height: %d,\n", tradeData.qortalAtAddress, - tradeData.mode.name(), + tradeData.mode, tradeData.qortalCreator, epochMilliFormatter.apply(tradeData.creationTimestamp), Amounts.prettyAmount(tradeData.qortBalance), atData.getIsFinished(), HashCode.fromBytes(tradeData.hashOfSecretB).toString().substring(0, 40), Amounts.prettyAmount(tradeData.qortAmount), - Amounts.prettyAmount(tradeData.expectedBitcoin), + Amounts.prettyAmount(tradeData.expectedForeignAmount), 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" + "\tHASH160 of secret-A: %s,\n" + "\tBitcoin P2SH-A nLockTime: %d (%s),\n" diff --git a/src/test/java/org/qortal/test/btcacct/DeployAT.java b/src/test/java/org/qortal/test/crosschain/bitcoinv1/DeployAT.java similarity index 70% rename from src/test/java/org/qortal/test/btcacct/DeployAT.java rename to src/test/java/org/qortal/test/crosschain/bitcoinv1/DeployAT.java index ef5a0295..f27f7a7b 100644 --- a/src/test/java/org/qortal/test/btcacct/DeployAT.java +++ b/src/test/java/org/qortal/test/crosschain/bitcoinv1/DeployAT.java @@ -1,12 +1,15 @@ -package org.qortal.test.btcacct; +package org.qortal.test.crosschain.bitcoinv1; -import java.security.Security; - -import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.NetworkParameters; import org.qortal.account.PrivateKeyAccount; import org.qortal.asset.Asset; 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.DeployAtTransactionData; import org.qortal.data.transaction.TransactionData; @@ -16,7 +19,7 @@ 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 org.qortal.test.crosschain.apps.Common; import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.Transaction; import org.qortal.transform.TransformationException; @@ -28,20 +31,18 @@ import com.google.common.hash.HashCode; public class DeployAT { - public static final long atFundingExtra = 2000000L; - private static void usage(String error) { if (error != null) System.err.println(error); - System.err.println(String.format("usage: DeployAT ")); + System.err.println(String.format("usage: DeployAT ")); System.err.println(String.format("example: DeployAT " - + "AdTd9SUEYSdTW8mgK3Gu72K97bCHGdUwi2VvLNjUohot \\\n" - + "\t80.4020 \\\n" + + "7Eztjz2TsxwbrWUYEaSdLbASKQGTfK2rR7ViFc5gaiZw \\\n" + + "\t10 \\\n" + + "\t10.1 \\\n" + "\t0.00864200 \\\n" - + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb \\\n" + + "\t750b06757a2448b8a4abebaa6e4662833fd5ddbb (or mrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h) \\\n" + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n" - + "\t123.456 \\\n" + "\t10080")); System.exit(1); } @@ -50,15 +51,17 @@ public class DeployAT { if (args.length != 7) usage(null); - Security.insertProviderAt(new BouncyCastleProvider(), 0); - Settings.fileInstance("settings-test.json"); + Common.init(); + + Bitcoiny bitcoiny = Bitcoin.getInstance(); + NetworkParameters params = bitcoiny.getNetworkParameters(); byte[] refundPrivateKey = null; long redeemAmount = 0; + long fundingAmount = 0; long expectedBitcoin = 0; byte[] bitcoinPublicKeyHash = null; - byte[] secretHash = null; - long fundingAmount = 0; + byte[] hashOfSecret = null; int tradeTimeout = 0; int argIndex = 0; @@ -71,22 +74,30 @@ public class DeployAT { if (redeemAmount <= 0) 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++]); if (expectedBitcoin <= 0) 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) usage("Bitcoin PKH must be 20 bytes"); - secretHash = HashCode.fromString(args[argIndex++]).asBytes(); - if (secretHash.length != 20) + hashOfSecret = HashCode.fromString(args[argIndex++]).asBytes(); + if (hashOfSecret.length != 20) 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++]); if (tradeTimeout < 60 || tradeTimeout > 50000) usage("Trade timeout (minutes) must be between 60 and 50000"); @@ -98,12 +109,11 @@ public class DeployAT { RepositoryFactory repositoryFactory = new HSQLDBRepositoryFactory(Controller.getRepositoryUrl()); RepositoryManager.setRepositoryFactory(repositoryFactory); } 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()) { - System.out.println("Confirm the following is correct based on the info you've given:"); - PrivateKeyAccount refundAccount = new PrivateKeyAccount(repository, refundPrivateKey); 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("HASH160 of secret: %s", HashCode.fromBytes(secretHash))); + System.out.println(String.format("HASH160 of secret: %s", HashCode.fromBytes(hashOfSecret))); // Deploy AT - byte[] creationBytes = BTCACCT.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, secretHash, redeemAmount, expectedBitcoin, tradeTimeout); - System.out.println("CIYAM AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); + byte[] creationBytes = BitcoinACCTv1.buildQortalAT(refundAccount.getAddress(), bitcoinPublicKeyHash, hashOfSecret, redeemAmount, expectedBitcoin, tradeTimeout); + System.out.println("AT creation bytes: " + HashCode.fromBytes(creationBytes).toString()); long txTimestamp = System.currentTimeMillis(); byte[] lastReference = refundAccount.getLastReference(); @@ -149,11 +159,10 @@ public class DeployAT { System.exit(2); } - System.out.println(String.format("\nSigned transaction in base58, ready for POST /transactions/process:\n%s\n", Base58.encode(signedBytes))); - } catch (NumberFormatException e) { - usage(String.format("Number format exception: %s", e.getMessage())); + System.out.println(String.format("%nSigned transaction in base58, ready for POST /transactions/process:%n%s", Base58.encode(signedBytes))); } catch (DataException e) { - throw new RuntimeException("Repository issue: " + e.getMessage()); + System.err.println(String.format("Repository issue: %s", e.getMessage())); + System.exit(2); } } diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/DeployAT.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/DeployAT.java new file mode 100644 index 00000000..3a1f9208 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/DeployAT.java @@ -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 ")); + 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); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java new file mode 100644 index 00000000..609ff5f3 --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/LitecoinACCTv1Tests.java @@ -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 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 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"); + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendCancelMessage.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendCancelMessage.java new file mode 100644 index 00000000..2d04098c --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendCancelMessage.java @@ -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 ")); + 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); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendRedeemMessage.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendRedeemMessage.java new file mode 100644 index 00000000..20386d2a --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendRedeemMessage.java @@ -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 ")); + 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); + } + } + +} diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendTradeMessage.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendTradeMessage.java new file mode 100644 index 00000000..83e9a20e --- /dev/null +++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendTradeMessage.java @@ -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 ")); + 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); + } + } + +} diff --git a/src/test/resources/test-settings-v2-bitcoin-regtest.json b/src/test/resources/test-settings-v2-bitcoin-regtest.json index d996c9fe..86379ae7 100644 --- a/src/test/resources/test-settings-v2-bitcoin-regtest.json +++ b/src/test/resources/test-settings-v2-bitcoin-regtest.json @@ -1,5 +1,6 @@ { "bitcoinNet": "REGTEST", + "litecoinNet": "REGTEST", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", "wipeUnconfirmedOnStart": false, diff --git a/src/test/resources/test-settings-v2.json b/src/test/resources/test-settings-v2.json index 1cefddee..a8983d3d 100644 --- a/src/test/resources/test-settings-v2.json +++ b/src/test/resources/test-settings-v2.json @@ -1,5 +1,6 @@ { "bitcoinNet": "TEST3", + "litecoinNet": "TEST3", "restrictedApi": false, "blockchainConfig": "src/test/resources/test-chain-v2.json", "wipeUnconfirmedOnStart": false,