diff --git a/pom.xml b/pom.xml
index 2a73ff0e..92a53000 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,8 @@
1.3.5
jar
- 0.15.5
+ bf9fb80
+ 0.15.6
1.64
${maven.build.timestamp}
1.3.7
@@ -375,6 +376,11 @@
project
file:${project.basedir}/lib
+
+
+ jitpack.io
+ https://jitpack.io
+
@@ -415,6 +421,12 @@
bitcoinj-core
${bitcoinj.version}
+
+
+ com.github.jjos2372
+ altcoinj
+ ${altcoinj.version}
+
com.googlecode.json-simple
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..be533ccd
--- /dev/null
+++ b/src/main/java/org/qortal/crosschain/Litecoin.java
@@ -0,0 +1,166 @@
+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.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";
+
+ // 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(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
+
+ /**
+ * 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/settings/Settings.java b/src/main/java/org/qortal/settings/Settings.java
index a169ec35..6a150889 100644
--- a/src/main/java/org/qortal/settings/Settings.java
+++ b/src/main/java/org/qortal/settings/Settings.java
@@ -21,6 +21,7 @@ import org.eclipse.persistence.jaxb.JAXBContextFactory;
import org.eclipse.persistence.jaxb.UnmarshallerProperties;
import org.qortal.block.BlockChain;
import org.qortal.crosschain.Bitcoin.BitcoinNet;
+import org.qortal.crosschain.Litecoin.LitecoinNet;
// All properties to be converted to JSON via JAXB
@XmlAccessorType(XmlAccessType.FIELD)
@@ -98,6 +99,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;
@@ -370,6 +372,10 @@ public class Settings {
return this.bitcoinNet;
}
+ public LitecoinNet getLitecoinNet() {
+ return this.litecoinNet;
+ }
+
public boolean isTradebotSystrayEnabled() {
return this.tradebotSystrayEnabled;
}
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..71dd9974
--- /dev/null
+++ b/src/test/java/org/qortal/test/crosschain/LitecoinTests.java
@@ -0,0 +1,115 @@
+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.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";
+
+ List rawTransactions = litecoin.getAddressTransactions(p2shAddress);
+
+ byte[] expectedSecret = "This string is exactly 32 bytes!".getBytes();
+ byte[] secret = BitcoinyHTLC.findHtlcSecret(litecoin.getNetworkParameters(), p2shAddress, rawTransactions);
+
+ 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/litecoinv1/BuildHTLC.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/BuildHTLC.java
new file mode 100644
index 00000000..021544ed
--- /dev/null
+++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/BuildHTLC.java
@@ -0,0 +1,112 @@
+package org.qortal.test.crosschain.litecoinv1;
+
+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.NetworkParameters;
+import org.bitcoinj.script.Script.ScriptType;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.crosschain.Litecoin;
+import org.qortal.crosschain.BitcoinyHTLC;
+import org.qortal.settings.Settings;
+
+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 "));
+ System.err.println(String.format("example: BuildHTLC "
+ + "msAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n"
+ + "\t0.00008642 \\\n"
+ + "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n"
+ + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
+ + "\t1600000000"));
+ System.exit(1);
+ }
+
+ public static void main(String[] args) {
+ if (args.length < 5 || args.length > 5)
+ usage(null);
+
+ Security.insertProviderAt(new BouncyCastleProvider(), 0);
+ Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
+
+ Settings.fileInstance("settings-test.json");
+
+ Litecoin litecoin = Litecoin.getInstance();
+ NetworkParameters params = litecoin.getNetworkParameters();
+
+ Address refundLitecoinAddress = null;
+ Coin litecoinAmount = null;
+ Address redeemLitecoinAddress = null;
+ byte[] secretHash = null;
+ int lockTime = 0;
+
+ int argIndex = 0;
+ try {
+ refundLitecoinAddress = Address.fromString(params, args[argIndex++]);
+ if (refundLitecoinAddress.getOutputScriptType() != ScriptType.P2PKH)
+ usage("Refund Litecoin address must be in P2PKH form");
+
+ litecoinAmount = Coin.parseCoin(args[argIndex++]);
+
+ redeemLitecoinAddress = Address.fromString(params, args[argIndex++]);
+ if (redeemLitecoinAddress.getOutputScriptType() != ScriptType.P2PKH)
+ usage("Redeem Litecoin 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");
+ } catch (IllegalArgumentException e) {
+ usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
+ }
+
+ Coin p2shFee;
+ try {
+ p2shFee = Coin.valueOf(litecoin.getP2shFee(null));
+ } catch (ForeignBlockchainException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+
+ System.out.println("Confirm the following is correct based on the info you've given:");
+
+ System.out.println(String.format("Refund Litecoin address: %s", refundLitecoinAddress));
+ System.out.println(String.format("Litecoin redeem amount: %s", litecoinAmount.toPlainString()));
+
+ System.out.println(String.format("Redeem Litecoin address: %s", redeemLitecoinAddress));
+ System.out.println(String.format("Redeem miner's fee: %s", litecoin.format(p2shFee)));
+
+ 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 = BitcoinyHTLC.buildScript(refundLitecoinAddress.getHash(), lockTime, redeemLitecoinAddress.getHash(), secretHash);
+ System.out.println(String.format("Redeem script: %s", HashCode.fromBytes(redeemScriptBytes)));
+
+ String p2shAddress = litecoin.deriveP2shAddress(redeemScriptBytes);
+ System.out.println(String.format("P2SH address: %s", p2shAddress));
+
+ litecoinAmount = litecoinAmount.add(p2shFee);
+
+ // Fund P2SH
+ System.out.println(String.format("\nYou need to fund %s with %s (includes redeem/refund fee of %s)",
+ p2shAddress, litecoin.format(litecoinAmount), litecoin.format(p2shFee)));
+
+ System.out.println("Once this is done, responder should run Respond to check P2SH funding and create AT");
+ }
+
+}
diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/Refund.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/Refund.java
new file mode 100644
index 00000000..37752e79
--- /dev/null
+++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/Refund.java
@@ -0,0 +1,218 @@
+package org.qortal.test.crosschain.litecoinv1;
+
+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.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.crosschain.Litecoin;
+import org.qortal.crosschain.Bitcoiny;
+import org.qortal.crosschain.BitcoinyHTLC;
+import org.qortal.crypto.Crypto;
+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 "
+ + "2N4378NbEVGjmiUmoUD9g1vCY6kyx9tDUJ6 \\\n"
+ + "\tef8f31b49c31b4a140aebcd9605fded88cc2dad0844c4b984f9191a5a416f72d3801e16447b0 \\\n"
+ + "\tmrBpZYYGYMwUa8tRjTiXfP1ySqNXszWN5h \\\n"
+ + "\tdaf59884b4d1aec8c1b17102530909ee43c0151a \\\n"
+ + "\t1600184800 \\\n"
+ + "\tmoJtbbhs7T4Z5hmBH2iyKhGrCWBzQWS2CL"));
+ System.exit(1);
+ }
+
+ public static void main(String[] args) {
+ if (args.length < 6 || args.length > 6)
+ usage(null);
+
+ Security.insertProviderAt(new BouncyCastleProvider(), 0);
+ Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
+
+ Settings.fileInstance("settings-test.json");
+
+ Bitcoiny bitcoiny = Litecoin.getInstance();
+ NetworkParameters params = bitcoiny.getNetworkParameters();
+
+ Address p2shAddress = null;
+ byte[] refundPrivateKey = null;
+ Address redeemAddress = null;
+ byte[] secretHash = null;
+ int lockTime = 0;
+ Address outputAddress = null;
+
+ 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");
+
+ redeemAddress = Address.fromString(params, args[argIndex++]);
+ if (redeemAddress.getOutputScriptType() != ScriptType.P2PKH)
+ usage("Redeem 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++]);
+
+ 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()));
+ }
+
+ Coin p2shFee;
+ try {
+ p2shFee = Coin.valueOf(bitcoiny.getP2shFee(null));
+ } catch (ForeignBlockchainException e) {
+ throw new RuntimeException(e.getMessage());
+ }
+
+ try {
+ System.out.println(String.format("P2SH address: %s", p2shAddress));
+ System.out.println(String.format("Refund PRIVATE key: %s", HashCode.fromBytes(refundPrivateKey)));
+ System.out.println(String.format("Redeem address: %s", redeemAddress));
+ System.out.println(String.format("Redeem script lockTime: %s (%d)", LocalDateTime.ofInstant(Instant.ofEpochSecond(lockTime), ZoneOffset.UTC), lockTime));
+ System.out.println(String.format("Refund recipient (PKH): %s (%s)", outputAddress, HashCode.fromBytes(outputAddress.getHash())));
+
+ System.out.println(String.format("Refund miner's fee: %s", bitcoiny.format(p2shFee)));
+
+ // New/derived info
+
+ ECKey refundKey = ECKey.fromPrivate(refundPrivateKey);
+ Address refundAddress = Address.fromKey(params, refundKey, ScriptType.P2PKH);
+
+ byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(refundAddress.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 = bitcoiny.getMedianBlockTime();
+ } catch (ForeignBlockchainException 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 = bitcoiny.getConfirmedBalance(p2shAddress.toString());
+ } catch (ForeignBlockchainException 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, bitcoiny.format(p2shBalance)));
+
+ // Grab all P2SH funding transactions (just in case there are more than one)
+ List fundingOutputs;
+ try {
+ fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString());
+ } catch (ForeignBlockchainException 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(), bitcoiny.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(p2shFee);
+ System.out.println(String.format("Spending %s of output, with %s as mining fee", bitcoiny.format(refundAmount), bitcoiny.format(p2shFee)));
+
+ Transaction refundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoiny.getNetworkParameters(), refundAmount, refundKey,
+ fundingOutputs, redeemScriptBytes, lockTime, outputAddress.getHash());
+
+ byte[] rawTransactionBytes = refundTransaction.bitcoinSerialize();
+
+ System.out.println(String.format("\nRaw transaction bytes:\n%s\n", HashCode.fromBytes(rawTransactionBytes).toString()));
+
+ try {
+ bitcoiny.broadcastTransaction(refundTransaction);
+ } catch (ForeignBlockchainException e) {
+ System.err.println(String.format("Failed to broadcast transaction: %s", e.getMessage()));
+ System.exit(1);
+ }
+ } catch (NumberFormatException e) {
+ usage(String.format("Number format exception: %s", e.getMessage()));
+ }
+ }
+
+}
diff --git a/src/test/java/org/qortal/test/crosschain/litecoinv1/SendLTC.java b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendLTC.java
new file mode 100644
index 00000000..6e3a489a
--- /dev/null
+++ b/src/test/java/org/qortal/test/crosschain/litecoinv1/SendLTC.java
@@ -0,0 +1,74 @@
+package org.qortal.test.crosschain.litecoinv1;
+
+import java.security.Security;
+
+import org.bitcoinj.core.Address;
+import org.bitcoinj.core.Coin;
+import org.bitcoinj.core.NetworkParameters;
+import org.bitcoinj.core.Transaction;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider;
+import org.qortal.crosschain.ForeignBlockchainException;
+import org.qortal.crosschain.Litecoin;
+import org.qortal.settings.Settings;
+
+public class SendLTC {
+
+ private static void usage(String error) {
+ if (error != null)
+ System.err.println(error);
+
+ System.err.println(String.format("usage: SendLTC "));
+ System.err.println(String.format("example: SendLTC "
+ + "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TYLg2aQMecZLXLre5bTVGajEB55L1HYJcawpdFG66STVAWPJ \\\n"
+ + "\tmsAfaDaJ8JiprxxFaAXEEPxKK3JaZCYpLv \\\n"
+ + "\t0.00008642"));
+ System.exit(1);
+ }
+
+ public static void main(String[] args) {
+ if (args.length < 3 || args.length > 3)
+ usage(null);
+
+ Security.insertProviderAt(new BouncyCastleProvider(), 0);
+ Security.insertProviderAt(new BouncyCastleJsseProvider(), 1);
+
+ Settings.fileInstance("settings-test.json");
+
+ Litecoin litecoin = Litecoin.getInstance();
+ NetworkParameters params = litecoin.getNetworkParameters();
+
+ String xprv58 = null;
+ Address litecoinAddress = null;
+ Coin litecoinAmount = null;
+
+ int argIndex = 0;
+ try {
+ xprv58 = args[argIndex++];
+ if (!litecoin.isValidXprv(xprv58))
+ usage("xprv invalid");
+
+ litecoinAddress = Address.fromString(params, args[argIndex++]);
+
+ litecoinAmount = Coin.parseCoin(args[argIndex++]);
+ } catch (IllegalArgumentException e) {
+ usage(String.format("Invalid argument %d: %s", argIndex, e.getMessage()));
+ }
+
+ System.out.println(String.format("Litecoin address: %s", litecoinAddress));
+ System.out.println(String.format("Litecoin amount: %s", litecoinAmount.toPlainString()));
+
+ Transaction transaction = litecoin.buildSpend(xprv58, litecoinAddress.toString(), litecoinAmount.value);
+ if (transaction == null) {
+ System.err.println("Insufficent funds");
+ System.exit(1);
+ }
+
+ try {
+ litecoin.broadcastTransaction(transaction);
+ } catch (ForeignBlockchainException e) {
+ System.err.println("Transaction broadcast failed: " + e.getMessage());
+ }
+ }
+
+}
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,