WIP: refactoring to support multiple foreign blockchains

API support for Litecoin wallet balance and sending LTC.

TradeBotCreateRequest rejigged to use blockchain-agnostic
field names, e.g. bitcoinAmount now foreignAmount,
and added foreignBlockchain field.

The massive API CrossChainResource class has been split into:

CrossChainAtResource: for building TRADE/REDEEM/CANCEL messages
(OFFER missing?)

CrossChainBitcoinResource: for Bitcoin wallet balance/spend
CrossChainLitecoinResource: ditto for Litecoin

CrossChainHtlcResource: for Bitcoiny-HTLC actions like:
deriving P2SH address
checking HTLC status
eventually: building refund/redeem transactions

CrossChainResource: for creating/cancelling/listing trade offers.

CrossChainTradeBotResource: for creating/cancelling trade-bot
entries, including responding to trade offers.

---

Other general trading changes:

TradeBot states are now specific to each individual trade-bot,
e.g. BitcoinACCTv1TradeBot or LitecoinACCTv1TradeBot, etc.

TradeBot states now a combination of int & String, instead of
enums due to above.

Extra columns added to DB TradeBotStates to store
blockchain, which ACCT in use, etc.

---

UNTESTED at this point!
This commit is contained in:
catbref 2020-09-18 17:07:49 +01:00
parent 76a15bb026
commit 514689d2f4
28 changed files with 2105 additions and 1449 deletions

View File

@ -15,7 +15,7 @@ public enum ApiError {
// COMMON // COMMON
// UNKNOWN(0, 500), // UNKNOWN(0, 500),
JSON(1, 400), JSON(1, 400),
// NO_BALANCE(2, 422), INSUFFICIENT_BALANCE(2, 422),
// NOT_YET_RELEASED(3, 422), // NOT_YET_RELEASED(3, 422),
UNAUTHORIZED(4, 403), UNAUTHORIZED(4, 403),
REPOSITORY_ISSUE(5, 500), REPOSITORY_ISSUE(5, 500),
@ -126,10 +126,10 @@ public enum ApiError {
// Groups // Groups
GROUP_UNKNOWN(1101, 404), GROUP_UNKNOWN(1101, 404),
// Bitcoin // Foreign blockchain
BTC_NETWORK_ISSUE(1201, 500), FOREIGN_BLOCKCHAIN_NETWORK_ISSUE(1201, 500),
BTC_BALANCE_ISSUE(1202, 402), FOREIGN_BLOCKCHAIN_BALANCE_ISSUE(1202, 402),
BTC_TOO_SOON(1203, 408); FOREIGN_BLOCKCHAIN_TOO_SOON(1203, 408);
private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError)); private static final Map<Integer, ApiError> map = stream(ApiError.values()).collect(toMap(apiError -> apiError.code, apiError -> apiError));

View File

@ -1,4 +1,4 @@
package org.qortal.api.model; package org.qortal.api.model.crosschain;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlAccessorType;
@ -9,16 +9,20 @@ import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
public class BitcoinSendRequest { public class BitcoinSendRequest {
@Schema(description = "Bitcoin BIP32 extended private key", example = "tprv8ZgxMBicQKsPdahhFSrCdvC1bsWyzHHZfTneTVqUXN6s1wEtZLwAkZXzFP6TbTVGajEB55L1HYLg2aQMecZLXLre5YJcawpdFG66STVAWPJ") @Schema(description = "Bitcoin BIP32 extended private key", example = "tprv___________________________________________________________________________________________________________")
public String xprv58; public String xprv58;
@Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE") @Schema(description = "Recipient's Bitcoin address ('legacy' P2PKH only)", example = "1BitcoinEaterAddressDontSendf59kuE")
public String receivingAddress; public String receivingAddress;
@Schema(description = "Amount of BTC to send") @Schema(description = "Amount of BTC to send", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long bitcoinAmount; public long bitcoinAmount;
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 BTC (100 sats) per byte", example = "0.00000100", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public Long feePerByte;
public BitcoinSendRequest() { public BitcoinSendRequest() {
} }

View File

@ -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() {
}
}

View File

@ -1,9 +1,11 @@
package org.qortal.api.model; package org.qortal.api.model.crosschain;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import org.qortal.crosschain.SupportedBlockchain;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
@ -12,22 +14,30 @@ public class TradeBotCreateRequest {
@Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB") @Schema(description = "Trade creator's public key", example = "2zR1WFsbM7akHghqSCYKBPk6LDP8aKiQSRS1FrwoLvoB")
public byte[] creatorPublicKey; public byte[] creatorPublicKey;
@Schema(description = "QORT amount paid out on successful trade", example = "80.40200000") @Schema(description = "QORT amount paid out on successful trade", example = "80.40000000", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long qortAmount; public long qortAmount;
@Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "81") @Schema(description = "QORT amount funding AT, including covering AT execution fees", example = "80.50000000", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long fundingQortAmount; public long fundingQortAmount;
@Schema(description = "Bitcoin amount wanted in return", example = "0.00864200") @Deprecated
@Schema(description = "Bitcoin amount wanted in return. DEPRECATED: use foreignAmount instead", example = "0.00864200", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long bitcoinAmount; public Long bitcoinAmount;
@Schema(description = "Foreign blockchain. Note: default (BITCOIN) to be removed in the future", example = "Bitcoin", defaultValue = "BITCOIN")
public SupportedBlockchain foreignBlockchain;
@Schema(description = "Foreign blockchain amount wanted in return", example = "0.00864200", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public Long foreignAmount;
@Schema(description = "Suggested trade timeout (minutes)", example = "10080") @Schema(description = "Suggested trade timeout (minutes)", example = "10080")
public int tradeTimeout; public int tradeTimeout;
@Schema(description = "Bitcoin address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE") @Schema(description = "Foreign blockchain address for receiving", example = "1BitcoinEaterAddressDontSendf59kuE")
public String receivingAddress; public String receivingAddress;
public TradeBotCreateRequest() { public TradeBotCreateRequest() {

View File

@ -1,4 +1,4 @@
package org.qortal.api.model; package org.qortal.api.model.crosschain;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlAccessorType;
@ -11,9 +11,15 @@ public class TradeBotRespondRequest {
@Schema(description = "Qortal AT address", example = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") @Schema(description = "Qortal AT address", example = "Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
public String atAddress; public String atAddress;
@Schema(description = "Bitcoin BIP32 extended private key", example = "xprv___________________________________________________________________________________________________________") @Deprecated
@Schema(description = "Bitcoin BIP32 extended private key. DEPRECATED: use foreignKey instead",
example = "xprv___________________________________________________________________________________________________________")
public String xprv58; 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") @Schema(description = "Qortal address for receiving QORT from AT", example = "Qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq")
public String receivingAddress; public String receivingAddress;

View File

@ -0,0 +1,329 @@
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.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.crosschain.BitcoinACCTv1;
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.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.MessageTransaction;
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.MessageTransactionTransformer;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
@Path("/crosschain/at")
@Tag(name = "Cross-Chain (AT-related)")
public class CrossChainAtResource {
@Context
HttpServletRequest request;
@POST
@Path("/trademessage")
@Operation(
summary = "Builds raw, unsigned 'trade' MESSAGE transaction that sends cross-chain trade recipient address, triggering 'trade' mode",
description = "Specify address of cross-chain AT that needs to be messaged, and signature of 'offer' MESSAGE from trade partner.<br>"
+ "AT needs to be in 'offer' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>"
+ "You need to sign output with trade private key otherwise the MESSAGE transaction will be invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = CrossChainTradeRequest.class
)
)
),
responses = {
@ApiResponse(
content = @Content(
schema = @Schema(
type = "string"
)
)
)
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String buildTradeMessage(CrossChainTradeRequest tradeRequest) {
Security.checkApiCallAllowed(request);
byte[] tradePublicKey = tradeRequest.tradePublicKey;
if (tradePublicKey == null || tradePublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
if (tradeRequest.atAddress == null || !Crypto.isValidAtAddress(tradeRequest.atAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
if (tradeRequest.messageTransactionSignature == null || !Crypto.isValidAddress(tradeRequest.messageTransactionSignature))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
try (final Repository repository = RepositoryManager.getRepository()) {
ATData atData = fetchAtDataWithChecking(repository, tradeRequest.atAddress);
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
if (crossChainTradeData.mode != AcctMode.OFFERING)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
// Does supplied public key match trade public key?
if (!Crypto.toAddress(tradePublicKey).equals(crossChainTradeData.qortalCreatorTradeAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
TransactionData transactionData = repository.getTransactionRepository().fromSignature(tradeRequest.messageTransactionSignature);
if (transactionData == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_UNKNOWN);
if (transactionData.getType() != TransactionType.MESSAGE)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
MessageTransactionData messageTransactionData = (MessageTransactionData) transactionData;
byte[] messageData = messageTransactionData.getData();
BitcoinACCTv1.OfferMessageData offerMessageData = BitcoinACCTv1.extractOfferMessageData(messageData);
if (offerMessageData == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.TRANSACTION_INVALID);
// Good to make MESSAGE
byte[] aliceForeignPublicKeyHash = offerMessageData.partnerBitcoinPKH;
byte[] hashOfSecretA = offerMessageData.hashOfSecretA;
int lockTimeA = (int) offerMessageData.lockTimeA;
String aliceNativeAddress = Crypto.toAddress(messageTransactionData.getCreatorPublicKey());
int lockTimeB = BitcoinACCTv1.calcLockTimeB(messageTransactionData.getTimestamp(), lockTimeA);
byte[] outgoingMessageData = BitcoinACCTv1.buildTradeMessage(aliceNativeAddress, aliceForeignPublicKeyHash, hashOfSecretA, lockTimeA, lockTimeB);
byte[] messageTransactionBytes = buildAtMessage(repository, tradePublicKey, tradeRequest.atAddress, outgoingMessageData);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Path("/redeemmessage")
@Operation(
summary = "Builds raw, unsigned 'redeem' MESSAGE transaction that sends secrets to AT, releasing funds to partner",
description = "Specify address of cross-chain AT that needs to be messaged, both 32-byte secrets and an address for receiving QORT from AT.<br>"
+ "AT needs to be in 'trade' mode. Messages sent to an AT in any other mode will be ignored, but still cost fees to send!<br>"
+ "You need to sign output with account the AT considers the trade 'partner' otherwise the MESSAGE transaction will be invalid.",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(
implementation = CrossChainSecretRequest.class
)
)
),
responses = {
@ApiResponse(
content = @Content(
schema = @Schema(
type = "string"
)
)
)
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_ADDRESS, ApiError.INVALID_DATA, ApiError.INVALID_CRITERIA, ApiError.REPOSITORY_ISSUE})
public String buildRedeemMessage(CrossChainSecretRequest secretRequest) {
Security.checkApiCallAllowed(request);
byte[] partnerPublicKey = secretRequest.partnerPublicKey;
if (partnerPublicKey == null || partnerPublicKey.length != Transformer.PUBLIC_KEY_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
if (secretRequest.atAddress == null || !Crypto.isValidAtAddress(secretRequest.atAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
if (secretRequest.secretA == null || secretRequest.secretA.length != BitcoinACCTv1.SECRET_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (secretRequest.secretB == null || secretRequest.secretB.length != BitcoinACCTv1.SECRET_LENGTH)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
if (secretRequest.receivingAddress == null || !Crypto.isValidAddress(secretRequest.receivingAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
try (final Repository repository = RepositoryManager.getRepository()) {
ATData atData = fetchAtDataWithChecking(repository, secretRequest.atAddress);
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
if (crossChainTradeData.mode != AcctMode.TRADING)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
String partnerAddress = Crypto.toAddress(partnerPublicKey);
// MESSAGE must come from address that AT considers trade partner
if (!crossChainTradeData.qortalPartnerAddress.equals(partnerAddress))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_ADDRESS);
// Good to make MESSAGE
byte[] messageData = BitcoinACCTv1.buildRedeemMessage(secretRequest.secretA, secretRequest.secretB, secretRequest.receivingAddress);
byte[] messageTransactionBytes = buildAtMessage(repository, partnerPublicKey, secretRequest.atAddress, messageData);
return Base58.encode(messageTransactionBytes);
} catch (DataException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.REPOSITORY_ISSUE, e);
}
}
@POST
@Path("/cancelmessage")
@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.<br>"
+ "AT needs to be in 'offer' mode. Messages sent to an AT in 'trade' mode will be ignored, but still cost fees to send!<br>"
+ "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})
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 = BitcoinACCTv1.getInstance().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 = BitcoinACCTv1.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(), 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);
}
}
}

View File

@ -0,0 +1,125 @@
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 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.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 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 = "tprv___________________________________________________________________________________________________________"
)
)
),
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 xprv58) {
Security.checkApiCallAllowed(request);
Bitcoin bitcoin = Bitcoin.getInstance();
if (!bitcoin.isValidXprv(xprv58))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
Long balance = bitcoin.getWalletBalance(xprv58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
return balance.toString();
}
@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.isValidXprv(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();
}
}

View File

@ -0,0 +1,176 @@
package org.qortal.api.resource;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.math.BigDecimal;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import org.bitcoinj.core.TransactionOutput;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrors;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.Security;
import org.qortal.api.model.CrossChainBitcoinyHTLCStatus;
import org.qortal.crosschain.Bitcoiny;
import org.qortal.crosschain.ForeignBlockchainException;
import org.qortal.crosschain.SupportedBlockchain;
import org.qortal.crosschain.BitcoinyHTLC;
import org.qortal.utils.NTP;
import com.google.common.hash.HashCode;
@Path("/crosschain/htlc")
@Tag(name = "Cross-Chain (Hash time-locked contracts)")
public class CrossChainHtlcResource {
@Context
HttpServletRequest request;
@GET
@Path("/address/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}")
@Operation(
summary = "Returns HTLC address based on trade info",
description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.",
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
)
}
)
@ApiErrors({ApiError.INVALID_PUBLIC_KEY, ApiError.INVALID_CRITERIA})
public String deriveHtlcAddress(@PathParam("blockchain") String blockchainName,
@PathParam("refundPKH") String refundHex,
@PathParam("locktime") int lockTime,
@PathParam("redeemPKH") String redeemHex,
@PathParam("hashOfSecret") String hashOfSecretHex) {
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
if (blockchain == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
byte[] refunderPubKeyHash;
byte[] redeemerPubKeyHash;
byte[] hashOfSecret;
try {
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
} catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
}
try {
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
if (hashOfSecret.length != 20)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
} catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
}
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
return bitcoiny.deriveP2shAddress(redeemScript);
}
@GET
@Path("/status/{blockchain}/{refundPKH}/{locktime}/{redeemPKH}/{hashOfSecret}")
@Operation(
summary = "Checks HTLC status",
description = "Blockchain can be BITCOIN or LITECOIN. Public key hashes (PKH) and hash of secret should be 20 bytes (hex). Locktime is seconds since epoch.",
responses = {
@ApiResponse(
content = @Content(mediaType = MediaType.APPLICATION_JSON, schema = @Schema(implementation = CrossChainBitcoinyHTLCStatus.class))
)
}
)
@ApiErrors({ApiError.INVALID_CRITERIA, ApiError.INVALID_ADDRESS, ApiError.ADDRESS_UNKNOWN})
public CrossChainBitcoinyHTLCStatus checkHtlcStatus(@PathParam("blockchain") String blockchainName,
@PathParam("refundPKH") String refundHex,
@PathParam("locktime") int lockTime,
@PathParam("redeemPKH") String redeemHex,
@PathParam("hashOfSecret") String hashOfSecretHex) {
Security.checkApiCallAllowed(request);
SupportedBlockchain blockchain = SupportedBlockchain.valueOf(blockchainName);
if (blockchain == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
byte[] refunderPubKeyHash;
byte[] redeemerPubKeyHash;
byte[] hashOfSecret;
try {
refunderPubKeyHash = HashCode.fromString(refundHex).asBytes();
redeemerPubKeyHash = HashCode.fromString(redeemHex).asBytes();
if (refunderPubKeyHash.length != 20 || redeemerPubKeyHash.length != 20)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
} catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PUBLIC_KEY);
}
try {
hashOfSecret = HashCode.fromString(hashOfSecretHex).asBytes();
if (hashOfSecret.length != 20)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
} catch (IllegalArgumentException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
}
byte[] redeemScript = BitcoinyHTLC.buildScript(refunderPubKeyHash, lockTime, redeemerPubKeyHash, hashOfSecret);
Bitcoiny bitcoiny = (Bitcoiny) blockchain.getInstance();
String p2shAddress = bitcoiny.deriveP2shAddress(redeemScript);
long now = NTP.getTime();
try {
int medianBlockTime = bitcoiny.getMedianBlockTime();
// Check P2SH is funded
long p2shBalance = bitcoiny.getConfirmedBalance(p2shAddress.toString());
CrossChainBitcoinyHTLCStatus htlcStatus = new CrossChainBitcoinyHTLCStatus();
htlcStatus.bitcoinP2shAddress = p2shAddress;
htlcStatus.bitcoinP2shBalance = BigDecimal.valueOf(p2shBalance, 8);
List<TransactionOutput> fundingOutputs = bitcoiny.getUnspentOutputs(p2shAddress.toString());
if (p2shBalance > 0L && !fundingOutputs.isEmpty()) {
htlcStatus.canRedeem = now >= medianBlockTime * 1000L;
htlcStatus.canRefund = now >= lockTime * 1000L;
}
if (now >= medianBlockTime * 1000L) {
// See if we can extract secret
List<byte[]> rawTransactions = bitcoiny.getAddressTransactions(htlcStatus.bitcoinP2shAddress);
htlcStatus.secret = BitcoinyHTLC.findHtlcSecret(bitcoiny.getNetworkParameters(), htlcStatus.bitcoinP2shAddress, rawTransactions);
}
return htlcStatus;
} catch (ForeignBlockchainException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
}
}
// TODO: refund
// TODO: redeem
}

View File

@ -0,0 +1,125 @@
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 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.ForeignBlockchainException;
import org.qortal.crosschain.Litecoin;
@Path("/crosschain/ltc")
@Tag(name = "Cross-Chain (Bitcoin)")
public class CrossChainLitecoinResource {
@Context
HttpServletRequest request;
@POST
@Path("/walletbalance")
@Operation(
summary = "Returns LTC balance for hierarchical, deterministic 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 = "tprv___________________________________________________________________________________________________________"
)
)
),
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 xprv58) {
Security.checkApiCallAllowed(request);
Litecoin litecoin = Litecoin.getInstance();
if (!litecoin.isValidXprv(xprv58))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
Long balance = litecoin.getWalletBalance(xprv58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
return balance.toString();
}
@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.isValidXprv(litecoinSendRequest.xprv58))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_PRIVATE_KEY);
Transaction spendTransaction = litecoin.buildSpend(litecoinSendRequest.xprv58,
litecoinSendRequest.receivingAddress,
litecoinSendRequest.litecoinAmount,
litecoinSendRequest.feePerByte);
if (spendTransaction == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_BALANCE_ISSUE);
try {
litecoin.broadcastTransaction(spendTransaction);
} catch (ForeignBlockchainException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
}
return spendTransaction.getTxId().toString();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,273 @@
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.DELETE;
import javax.ws.rs.GET;
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.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.ACCT;
import org.qortal.crosschain.AcctMode;
import org.qortal.crypto.Crypto;
import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.crosschain.TradeBotData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.utils.Base58;
@Path("/crosschain/tradebot")
@Tag(name = "Cross-Chain (Trade-Bot)")
public class CrossChainTradeBotResource {
@Context
HttpServletRequest request;
@GET
@Operation(
summary = "List current trade-bot states",
responses = {
@ApiResponse(
content = @Content(
array = @ArraySchema(
schema = @Schema(
implementation = TradeBotData.class
)
)
)
)
}
)
@ApiErrors({ApiError.REPOSITORY_ISSUE})
public List<TradeBotData> getTradeBotStates(
// TODO: optional filter for foreign blockchain(s)?
) {
Security.checkApiCallAllowed(request);
try (final Repository repository = RepositoryManager.getRepository()) {
// TODO: maybe sub-class returned trade-bot data according to which trade-bot/ACCT applies?
return repository.getCrossChainRepository().getAllTradeBotData();
} 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);
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;
}
}

View File

@ -15,7 +15,7 @@ import org.eclipse.jetty.websocket.api.annotations.OnWebSocketError;
import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage;
import org.eclipse.jetty.websocket.api.annotations.WebSocket; import org.eclipse.jetty.websocket.api.annotations.WebSocket;
import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory;
import org.qortal.controller.TradeBot; import org.qortal.controller.tradebot.TradeBot;
import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.crosschain.TradeBotData;
import org.qortal.event.Event; import org.qortal.event.Event;
import org.qortal.event.EventBus; import org.qortal.event.EventBus;
@ -30,7 +30,7 @@ import org.qortal.utils.Base58;
public class TradeBotWebSocket extends ApiWebSocket implements Listener { public class TradeBotWebSocket extends ApiWebSocket implements Listener {
/** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */ /** Cache of trade-bot entry states, keyed by trade-bot entry's "trade private key" (base58) */
private static final Map<String, TradeBotData.State> PREVIOUS_STATES = new HashMap<>(); private static final Map<String, Integer> PREVIOUS_STATES = new HashMap<>();
@Override @Override
public void configure(WebSocketServletFactory factory) { public void configure(WebSocketServletFactory factory) {
@ -42,7 +42,7 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
// How do we properly fail here? // How do we properly fail here?
return; return;
PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getState))); PREVIOUS_STATES.putAll(tradeBotEntries.stream().collect(Collectors.toMap(entry -> Base58.encode(entry.getTradePrivateKey()), TradeBotData::getStateValue)));
} catch (DataException e) { } catch (DataException e) {
// No output this time // No output this time
} }
@ -59,11 +59,11 @@ public class TradeBotWebSocket extends ApiWebSocket implements Listener {
String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey()); String tradePrivateKey58 = Base58.encode(tradeBotData.getTradePrivateKey());
synchronized (PREVIOUS_STATES) { synchronized (PREVIOUS_STATES) {
if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getState()) if (PREVIOUS_STATES.get(tradePrivateKey58) == tradeBotData.getStateValue())
// Not changed // Not changed
return; return;
PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getState()); PREVIOUS_STATES.put(tradePrivateKey58, tradeBotData.getStateValue());
} }
List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData); List<TradeBotData> tradeBotEntries = Collections.singletonList(tradeBotData);

View File

@ -38,6 +38,7 @@ import org.qortal.block.Block;
import org.qortal.block.BlockChain; import org.qortal.block.BlockChain;
import org.qortal.block.BlockChain.BlockTimingByHeight; import org.qortal.block.BlockChain.BlockTimingByHeight;
import org.qortal.controller.Synchronizer.SynchronizationResult; import org.qortal.controller.Synchronizer.SynchronizationResult;
import org.qortal.controller.tradebot.TradeBot;
import org.qortal.crypto.Crypto; import org.qortal.crypto.Crypto;
import org.qortal.data.account.MintingAccountData; import org.qortal.data.account.MintingAccountData;
import org.qortal.data.account.RewardShareData; import org.qortal.data.account.RewardShareData;

View File

@ -0,0 +1,25 @@
package org.qortal.controller.tradebot;
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 }
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;
}

View File

@ -1,14 +1,14 @@
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.Arrays;
import java.util.List; import java.util.List;
import java.util.Random; import java.util.Map;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.util.Supplier;
import org.bitcoinj.core.Address; import org.bitcoinj.core.Address;
import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.AddressFormatException;
import org.bitcoinj.core.Coin; import org.bitcoinj.core.Coin;
@ -18,36 +18,30 @@ import org.bitcoinj.core.TransactionOutput;
import org.bitcoinj.script.Script.ScriptType; import org.bitcoinj.script.Script.ScriptType;
import org.qortal.account.PrivateKeyAccount; import org.qortal.account.PrivateKeyAccount;
import org.qortal.account.PublicKeyAccount; 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.asset.Asset;
import org.qortal.crosschain.ACCT;
import org.qortal.crosschain.AcctMode; import org.qortal.crosschain.AcctMode;
import org.qortal.crosschain.Bitcoin; import org.qortal.crosschain.Bitcoin;
import org.qortal.crosschain.BitcoinACCTv1; import org.qortal.crosschain.BitcoinACCTv1;
import org.qortal.crosschain.ForeignBlockchainException; import org.qortal.crosschain.ForeignBlockchainException;
import org.qortal.crosschain.SupportedBlockchain;
import org.qortal.crosschain.BitcoinyHTLC; import org.qortal.crosschain.BitcoinyHTLC;
import org.qortal.crypto.Crypto; import org.qortal.crypto.Crypto;
import org.qortal.data.account.AccountBalanceData;
import org.qortal.data.at.ATData; import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData; import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.data.crosschain.TradeBotData; import org.qortal.data.crosschain.TradeBotData;
import org.qortal.data.transaction.BaseTransactionData; import org.qortal.data.transaction.BaseTransactionData;
import org.qortal.data.transaction.DeployAtTransactionData; import org.qortal.data.transaction.DeployAtTransactionData;
import org.qortal.data.transaction.MessageTransactionData; 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.group.Group;
import org.qortal.gui.SysTray;
import org.qortal.repository.DataException; import org.qortal.repository.DataException;
import org.qortal.repository.Repository; import org.qortal.repository.Repository;
import org.qortal.repository.RepositoryManager;
import org.qortal.settings.Settings;
import org.qortal.transaction.DeployAtTransaction; import org.qortal.transaction.DeployAtTransaction;
import org.qortal.transaction.MessageTransaction; import org.qortal.transaction.MessageTransaction;
import org.qortal.transaction.Transaction.ValidationResult; import org.qortal.transaction.Transaction.ValidationResult;
import org.qortal.transform.TransformationException; import org.qortal.transform.TransformationException;
import org.qortal.transform.transaction.DeployAtTransactionTransformer; import org.qortal.transform.transaction.DeployAtTransactionTransformer;
import org.qortal.utils.Amounts;
import org.qortal.utils.Base58; import org.qortal.utils.Base58;
import org.qortal.utils.NTP; import org.qortal.utils.NTP;
@ -57,43 +51,71 @@ import org.qortal.utils.NTP;
* We deal with three different independent state-spaces here: * We deal with three different independent state-spaces here:
* <ul> * <ul>
* <li>Qortal blockchain</li> * <li>Qortal blockchain</li>
* <li>Bitcoin blockchain</li> * <li>Foreign blockchain</li>
* <li>Trade-bot entries</li> * <li>Trade-bot entries</li>
* </ul> * </ul>
*/ */
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 { public enum State implements TradeBot.StateNameAndValueSupplier {
private final TradeBotData tradeBotData; BOB_WAITING_FOR_AT_CONFIRM(10, false, false),
BOB_WAITING_FOR_MESSAGE(15, true, false),
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) { ALICE_WAITING_FOR_P2SH_A(80, true, true),
this.tradeBotData = tradeBotData; 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<Integer, State> map = stream(State.values()).collect(toMap(state -> state.value, state -> state));
public final int value;
public final boolean requiresAtData;
public final boolean requiresTradeData;
State(int value, boolean requiresAtData, boolean requiresTradeData) {
this.value = value;
this.requiresAtData = requiresAtData;
this.requiresTradeData = requiresTradeData;
} }
public TradeBotData getTradeBotData() { public static State valueOf(int value) {
return this.tradeBotData; 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); /** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. (milliseconds) */
private static final Random RANDOM = new SecureRandom();
/** Maximum time Bob waits for his AT creation transaction to be confirmed into a block. */
private static final long MAX_AT_CONFIRMATION_PERIOD = 24 * 60 * 60 * 1000L; // ms 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() { private BitcoinACCTv1TradeBot() {
EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
} }
public static synchronized TradeBot getInstance() { public static synchronized BitcoinACCTv1TradeBot getInstance() {
if (instance == null) if (instance == null)
instance = new TradeBot(); instance = new BitcoinACCTv1TradeBot();
return instance; return instance;
} }
@ -130,16 +152,16 @@ public class TradeBot implements Listener {
* @return raw, unsigned DEPLOY_AT transaction * @return raw, unsigned DEPLOY_AT transaction
* @throws DataException * @throws DataException
*/ */
public static byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException { public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
byte[] tradePrivateKey = generateTradePrivateKey(); byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
byte[] secretB = generateSecret(); byte[] secretB = TradeBot.generateSecret();
byte[] hashOfSecretB = Crypto.hash160(secretB); byte[] hashOfSecretB = Crypto.hash160(secretB);
byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
// Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time) // Convert Bitcoin receiving address into public key hash (we only support P2PKH at this time)
@ -168,7 +190,7 @@ public class TradeBot implements Listener {
String aTType = "ACCT"; String aTType = "ACCT";
String tags = "ACCT QORT BTC"; String tags = "ACCT QORT BTC";
byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount, byte[] creationBytes = BitcoinACCTv1.buildQortalAT(tradeNativeAddress, tradeForeignPublicKeyHash, hashOfSecretB, tradeBotCreateRequest.qortAmount,
tradeBotCreateRequest.bitcoinAmount, tradeBotCreateRequest.tradeTimeout); tradeBotCreateRequest.foreignAmount, tradeBotCreateRequest.tradeTimeout);
long amount = tradeBotCreateRequest.fundingQortAmount; long amount = tradeBotCreateRequest.fundingQortAmount;
DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT); DeployAtTransactionData deployAtTransactionData = new DeployAtTransactionData(baseTransactionData, name, description, aTType, tags, creationBytes, amount, Asset.QORT);
@ -180,15 +202,16 @@ public class TradeBot implements Listener {
DeployAtTransaction.ensureATAddress(deployAtTransactionData); DeployAtTransaction.ensureATAddress(deployAtTransactionData);
String atAddress = deployAtTransactionData.getAtAddress(); 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, creator.getAddress(), atAddress, timestamp, tradeBotCreateRequest.qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secretB, hashOfSecretB, secretB, hashOfSecretB,
SupportedBlockchain.BITCOIN.name(),
tradeForeignPublicKey, tradeForeignPublicKeyHash, tradeForeignPublicKey, tradeForeignPublicKeyHash,
tradeBotCreateRequest.bitcoinAmount, null, null, null, bitcoinReceivingAccountInfo); tradeBotCreateRequest.foreignAmount, null, null, null, bitcoinReceivingAccountInfo);
updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Built AT %s. Waiting for deployment", atAddress));
() -> 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 // Return to user for signing and broadcast as we don't have their Qortal private key
try { try {
@ -236,26 +259,28 @@ public class TradeBot implements Listener {
* @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise * @return true if P2SH-A funding transaction successfully broadcast to Bitcoin network, false otherwise
* @throws DataException * @throws DataException
*/ */
public static ResponseResult startResponse(Repository repository, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException { public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct, CrossChainTradeData crossChainTradeData, String xprv58, String receivingAddress) throws DataException {
byte[] tradePrivateKey = generateTradePrivateKey(); byte[] tradePrivateKey = TradeBot.generateTradePrivateKey();
byte[] secretA = generateSecret(); byte[] secretA = TradeBot.generateSecret();
byte[] hashOfSecretA = Crypto.hash160(secretA); byte[] hashOfSecretA = Crypto.hash160(secretA);
byte[] tradeNativePublicKey = deriveTradeNativePublicKey(tradePrivateKey); byte[] tradeNativePublicKey = TradeBot.deriveTradeNativePublicKey(tradePrivateKey);
byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey); byte[] tradeNativePublicKeyHash = Crypto.hash160(tradeNativePublicKey);
String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey); String tradeNativeAddress = Crypto.toAddress(tradeNativePublicKey);
byte[] tradeForeignPublicKey = deriveTradeForeignPublicKey(tradePrivateKey); byte[] tradeForeignPublicKey = TradeBot.deriveTradeForeignPublicKey(tradePrivateKey);
byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey); byte[] tradeForeignPublicKeyHash = Crypto.hash160(tradeForeignPublicKey);
byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH byte[] receivingPublicKeyHash = Base58.decode(receivingAddress); // Actually the whole address, not just PKH
// We need to generate lockTime-A: add tradeTimeout to now // We need to generate lockTime-A: add tradeTimeout to now
int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L); int lockTimeA = crossChainTradeData.tradeTimeout * 60 + (int) (NTP.getTime() / 1000L);
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, TradeBotData.State.ALICE_WAITING_FOR_P2SH_A, TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, BitcoinACCTv1.NAME,
State.ALICE_WAITING_FOR_P2SH_A.name(), State.ALICE_WAITING_FOR_P2SH_A.value,
receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount, receivingAddress, crossChainTradeData.qortalAtAddress, NTP.getTime(), crossChainTradeData.qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secretA, hashOfSecretA, secretA, hashOfSecretA,
SupportedBlockchain.BITCOIN.name(),
tradeForeignPublicKey, tradeForeignPublicKeyHash, tradeForeignPublicKey, tradeForeignPublicKeyHash,
crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash); crossChainTradeData.expectedBitcoin, xprv58, null, lockTimeA, receivingPublicKeyHash);
@ -267,7 +292,7 @@ public class TradeBot implements Listener {
p2shFee = Bitcoin.getInstance().getP2shFee(lockTimeA * 1000L); p2shFee = Bitcoin.getInstance().getP2shFee(lockTimeA * 1000L);
} catch (ForeignBlockchainException e) { } catch (ForeignBlockchainException e) {
LOGGER.debug("Couldn't estimate Bitcoin fees?"); 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. // Fee for redeem/refund is subtracted from P2SH-A balance.
@ -278,7 +303,7 @@ public class TradeBot implements Listener {
// As buildSpend also adds a fee, this is more pessimistic than required // As buildSpend also adds a fee, this is more pessimistic than required
Transaction fundingCheckTransaction = Bitcoin.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired); Transaction fundingCheckTransaction = Bitcoin.getInstance().buildSpend(xprv58, tradeForeignAddress, totalFundsRequired);
if (fundingCheckTransaction == null) if (fundingCheckTransaction == null)
return ResponseResult.INSUFFICIENT_FUNDS; return ResponseResult.BALANCE_ISSUE;
// P2SH-A to be funded // P2SH-A to be funded
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA); byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeForeignPublicKeyHash, lockTimeA, crossChainTradeData.creatorBitcoinPKH, hashOfSecretA);
@ -289,10 +314,10 @@ public class TradeBot implements Listener {
// Do not include fee for funding transaction as this is covered by buildSpend() // Do not include fee for funding transaction as this is covered by buildSpend()
long amountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/; long amountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-A*/;
Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getXprv58(), p2shAddress, amountA); Transaction p2shFundingTransaction = Bitcoin.getInstance().buildSpend(tradeBotData.getForeignKey(), p2shAddress, amountA);
if (p2shFundingTransaction == null) { if (p2shFundingTransaction == null) {
LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?"); LOGGER.debug("Unable to build P2SH-A funding transaction - lack of funds?");
return ResponseResult.BTC_BALANCE_ISSUE; return ResponseResult.BALANCE_ISSUE;
} }
try { try {
@ -300,76 +325,88 @@ public class TradeBot implements Listener {
} catch (ForeignBlockchainException e) { } catch (ForeignBlockchainException e) {
// We couldn't fund P2SH-A at this time // We couldn't fund P2SH-A at this time
LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?"); LOGGER.debug("Couldn't broadcast P2SH-A funding transaction?");
return ResponseResult.BTC_NETWORK_ISSUE; return ResponseResult.NETWORK_ISSUE;
} }
updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), TradeBot.updateTradeBotState(repository, tradeBotData, () -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress));
() -> String.format("Funding P2SH-A %s. Waiting for confirmation", p2shAddress));
return ResponseResult.OK; return ResponseResult.OK;
} }
private static byte[] generateTradePrivateKey() { @Override
// The private key is used for both Curve25519 and secp256k1 so needs to be valid for both. public boolean canDelete(Repository repository, TradeBotData tradeBotData) {
// Curve25519 accepts any seed, so generate a valid secp256k1 key and use that. State tradeBotState = State.valueOf(tradeBotData.getStateValue());
return new ECKey().getPrivKeyBytes(); if (tradeBotState == null)
} return true;
private static byte[] deriveTradeNativePublicKey(byte[] privateKey) { switch (tradeBotState) {
return PrivateKeyAccount.toPublicKey(privateKey); 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) { default:
return ECKey.fromPrivate(privateKey).getPubKey(); return false;
} }
private static byte[] generateSecret() {
byte[] secret = new byte[32];
RANDOM.nextBytes(secret);
return secret;
} }
@Override @Override
public void listen(Event event) { public void progress(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException {
if (!(event instanceof Controller.NewBlockEvent)) 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; return;
}
synchronized (this) { ATData atData = null;
// Get repo for trade situations CrossChainTradeData tradeData = null;
try (final Repository repository = RepositoryManager.getRepository()) {
List<TradeBotData> allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
for (TradeBotData tradeBotData : allTradeBotData) { if (tradeBotState.requiresAtData) {
repository.discardChanges(); // 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;
}
try { if (tradeBotState.requiresTradeData) {
switch (tradeBotData.getState()) { 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: case BOB_WAITING_FOR_AT_CONFIRM:
handleBobWaitingForAtConfirm(repository, tradeBotData); handleBobWaitingForAtConfirm(repository, tradeBotData);
break; break;
case ALICE_WAITING_FOR_P2SH_A: case ALICE_WAITING_FOR_P2SH_A:
handleAliceWaitingForP2shA(repository, tradeBotData); handleAliceWaitingForP2shA(repository, tradeBotData, atData, tradeData);
break; break;
case BOB_WAITING_FOR_MESSAGE: case BOB_WAITING_FOR_MESSAGE:
handleBobWaitingForMessage(repository, tradeBotData); handleBobWaitingForMessage(repository, tradeBotData, atData);
break; break;
case ALICE_WAITING_FOR_AT_LOCK: case ALICE_WAITING_FOR_AT_LOCK:
handleAliceWaitingForAtLock(repository, tradeBotData); handleAliceWaitingForAtLock(repository, tradeBotData, atData, tradeData);
break; break;
case BOB_WAITING_FOR_P2SH_B: case BOB_WAITING_FOR_P2SH_B:
handleBobWaitingForP2shB(repository, tradeBotData); handleBobWaitingForP2shB(repository, tradeBotData, atData, tradeData);
break; break;
case ALICE_WATCH_P2SH_B: case ALICE_WATCH_P2SH_B:
handleAliceWatchingP2shB(repository, tradeBotData); handleAliceWatchingP2shB(repository, tradeBotData, atData, tradeData);
break; break;
case BOB_WAITING_FOR_AT_REDEEM: case BOB_WAITING_FOR_AT_REDEEM:
handleBobWaitingForAtRedeem(repository, tradeBotData); handleBobWaitingForAtRedeem(repository, tradeBotData, atData, tradeData);
break; break;
case ALICE_DONE: case ALICE_DONE:
@ -377,27 +414,16 @@ public class TradeBot implements Listener {
break; break;
case ALICE_REFUNDING_B: case ALICE_REFUNDING_B:
handleAliceRefundingP2shB(repository, tradeBotData); handleAliceRefundingP2shB(repository, tradeBotData, atData, tradeData);
break; break;
case ALICE_REFUNDING_A: case ALICE_REFUNDING_A:
handleAliceRefundingP2shA(repository, tradeBotData); handleAliceRefundingP2shA(repository, tradeBotData, atData, tradeData);
break; break;
case ALICE_REFUNDED: case ALICE_REFUNDED:
case BOB_REFUNDED: case BOB_REFUNDED:
break; break;
default:
LOGGER.warn(() -> String.format("Unhandled trade-bot state %s", tradeBotData.getState().name()));
}
} catch (ForeignBlockchainException 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);
}
} }
} }
@ -413,18 +439,19 @@ public class TradeBot implements Listener {
// We've waited ages for AT to be confirmed into a block but something has gone awry. // 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. // 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()); tradeBotData.setTimestamp(NTP.getTime());
// We delete trade-bot entry here instead of saving, hence not using updateTradeBotState() // We delete trade-bot entry here instead of saving, hence not using updateTradeBotState()
repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey()); repository.getCrossChainRepository().delete(tradeBotData.getTradePrivateKey());
repository.saveChanges(); repository.saveChanges();
LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress())); LOGGER.info(() -> String.format("AT %s never confirmed. Giving up on trade", tradeBotData.getAtAddress()));
notifyStateChange(tradeBotData); TradeBot.notifyStateChange(tradeBotData);
return; 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())); () -> String.format("AT %s confirmed ready. Waiting for trade message", tradeBotData.getAtAddress()));
} }
@ -445,27 +472,16 @@ public class TradeBot implements Listener {
* If MESSAGE transaction is successfully broadcast, trade-bot's next step is to wait until Bob's AT has locked trade to Alice only. * 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 ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleAliceWaitingForP2shA(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) { if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return; return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
Bitcoin bitcoin = Bitcoin.getInstance(); Bitcoin bitcoin = Bitcoin.getInstance();
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
// 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;
}
// Fee for redeem/refund is subtracted from P2SH-A balance. // Fee for redeem/refund is subtracted from P2SH-A balance.
long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT; long minimumAmountA = crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT;
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
@ -478,13 +494,13 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// This shouldn't occur, but defensively check P2SH-B in case we haven't redeemed the AT // 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)); () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA));
return; return;
case REFUND_IN_PROGRESS: case REFUND_IN_PROGRESS:
case REFUNDED: 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)); () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
return; return;
@ -517,7 +533,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", () -> String.format("P2SH-A %s funding confirmed. Messaged %s. Waiting for AT %s to lock to us",
p2shAddressA, messageRecipient, tradeBotData.getAtAddress())); p2shAddressA, messageRecipient, tradeBotData.getAtAddress()));
} }
@ -541,17 +557,10 @@ public class TradeBot implements Listener {
* needed by Alice to progress her side of the trade. * needed by Alice to progress her side of the trade.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleBobWaitingForMessage(Repository repository, TradeBotData tradeBotData, ATData atData) throws DataException, ForeignBlockchainException {
// 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;
}
// If AT has finished then Bob likely cancelled his trade offer // If AT has finished then Bob likely cancelled his trade offer
if (atData.getIsFinished()) { 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())); () -> String.format("AT %s cancelled - trading aborted", tradeBotData.getAtAddress()));
return; return;
} }
@ -592,7 +601,7 @@ public class TradeBot implements Listener {
byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA); byte[] redeemScriptA = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeA, tradeBotData.getTradeForeignPublicKeyHash(), hashOfSecretA);
String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
final long minimumAmountA = tradeBotData.getBitcoinAmount() - P2SH_B_OUTPUT_AMOUNT; final long minimumAmountA = tradeBotData.getForeignAmount() - P2SH_B_OUTPUT_AMOUNT;
BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA); BitcoinyHTLC.Status htlcStatusA = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressA, minimumAmountA);
@ -605,7 +614,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// This shouldn't occur, but defensively bump to next state // 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)); () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA));
return; return;
@ -649,7 +658,7 @@ public class TradeBot implements Listener {
byte[] redeemScriptB = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret()); byte[] redeemScriptB = BitcoinyHTLC.buildScript(aliceForeignPublicKeyHash, lockTimeB, tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getHashOfSecret());
String p2shAddressB = bitcoin.deriveP2shAddress(redeemScriptB); 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)); () -> String.format("Locked AT %s to %s. Waiting for P2SH-B %s", tradeBotData.getAtAddress(), aliceNativeAddress, p2shAddressB));
return; return;
@ -657,7 +666,7 @@ public class TradeBot implements Listener {
// Don't resave/notify if we don't need to // Don't resave/notify if we don't need to
if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature) if (tradeBotData.getLastTransactionSignature() != originalLastTransactionSignature)
updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), null); TradeBot.updateTradeBotState(repository, tradeBotData, null);
} }
/** /**
@ -675,18 +684,15 @@ public class TradeBot implements Listener {
* step is to watch for Bob revealing secret-B by redeeming P2SH-B. * step is to watch for Bob revealing secret-B by redeeming P2SH-B.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleAliceWaitingForAtLock(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) { if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return; return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
Bitcoin bitcoin = Bitcoin.getInstance(); Bitcoin bitcoin = Bitcoin.getInstance();
// Refund P2SH-A if AT finished (i.e. Bob cancelled trade) or we've passed lockTime-A // Refund P2SH-A if we've passed lockTime-A
if (atData.getIsFinished() || NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) { if (NTP.getTime() >= tradeBotData.getLockTimeA() * 1000L) {
byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret()); byte[] redeemScriptA = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA); String p2shAddressA = bitcoin.deriveP2shAddress(redeemScriptA);
@ -697,20 +703,20 @@ public class TradeBot implements Listener {
case UNFUNDED: case UNFUNDED:
case FUNDING_IN_PROGRESS: case FUNDING_IN_PROGRESS:
// This shouldn't occur, but defensively revert back to waiting for P2SH-A // 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)); () -> String.format("P2SH-A %s no longer funded? Defensively checking P2SH-A next", p2shAddressA));
return; return;
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// This shouldn't occur, but defensively bump to next state // 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)); () -> String.format("P2SH-A %s already spent? Defensively checking P2SH-B next", p2shAddressA));
return; return;
case REFUND_IN_PROGRESS: case REFUND_IN_PROGRESS:
case REFUNDED: 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)); () -> String.format("P2SH-A %s already refunded. Trade aborted", p2shAddressA));
return; return;
@ -719,7 +725,7 @@ public class TradeBot implements Listener {
break; break;
} }
updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_REFUNDING_A, TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_REFUNDING_A,
() -> atData.getIsFinished() () -> atData.getIsFinished()
? String.format("AT %s cancelled. Refunding P2SH-A %s - aborting trade", tradeBotData.getAtAddress(), p2shAddressA) ? 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)); : String.format("LockTime-A reached, refunding P2SH-A %s - aborting trade", p2shAddressA));
@ -731,22 +737,7 @@ public class TradeBot implements Listener {
if (crossChainTradeData.mode != AcctMode.TRADING) if (crossChainTradeData.mode != AcctMode.TRADING)
return; return;
// We're expecting AT to be locked to our native trade address // AT is in TRADE mode and locked to us as checked by aliceUnexpectedState() above
if (!crossChainTradeData.qortalPartnerAddress.equals(tradeBotData.getTradeNativeAddress())) {
// AT locked to different address! We shouldn't continue but wait and refund.
byte[] redeemScriptBytes = BitcoinyHTLC.buildScript(tradeBotData.getTradeForeignPublicKeyHash(), tradeBotData.getLockTimeA(), crossChainTradeData.creatorBitcoinPKH, tradeBotData.getHashOfSecret());
String p2shAddress = bitcoin.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;
}
// Alice needs to fund P2SH-B here // Alice needs to fund P2SH-B here
@ -780,7 +771,20 @@ public class TradeBot implements Listener {
BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB); BitcoinyHTLC.Status htlcStatusB = BitcoinyHTLC.determineHtlcStatus(bitcoin.getBlockchainProvider(), p2shAddressB, minimumAmountB);
switch (htlcStatusB) { switch (htlcStatusB) {
case UNFUNDED: case UNFUNDED: {
// Do not include fee for funding transaction as this is covered by buildSpend()
long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFee /*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;
}
case FUNDING_IN_PROGRESS: case FUNDING_IN_PROGRESS:
case FUNDED: case FUNDED:
break; break;
@ -788,32 +792,19 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// This shouldn't occur, but defensively bump to next state // 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)); () -> String.format("P2SH-B %s already spent? Defensively checking P2SH-B next", p2shAddressB));
return; return;
case REFUND_IN_PROGRESS: case REFUND_IN_PROGRESS:
case REFUNDED: 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)); () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB));
return; return;
} }
if (htlcStatusB == BitcoinyHTLC.Status.UNFUNDED) {
// Do not include fee for funding transaction as this is covered by buildSpend()
long amountB = P2SH_B_OUTPUT_AMOUNT + p2shFee /*redeeming/refunding P2SH-B*/;
Transaction p2shFundingTransaction = bitcoin.buildSpend(tradeBotData.getXprv58(), p2shAddressB, amountB);
if (p2shFundingTransaction == null) {
LOGGER.debug("Unable to build P2SH-B funding transaction - lack of funds?");
return;
}
bitcoin.broadcastTransaction(p2shFundingTransaction);
}
// P2SH-B funded, now we wait for Bob to redeem it // 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", () -> String.format("AT %s locked to us (%s). P2SH-B %s funded. Watching P2SH-B for secret-B",
tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddressB)); tradeBotData.getAtAddress(), tradeBotData.getTradeNativeAddress(), p2shAddressB));
} }
@ -829,17 +820,11 @@ public class TradeBot implements Listener {
* Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT. * Trade-bot's next step is to wait for Alice to use secret-B, and her secret-A, to redeem Bob's AT.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleBobWaitingForP2shB(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) {
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
// If we've passed AT refund timestamp then AT will have finished after auto-refunding // If we've passed AT refund timestamp then AT will have finished after auto-refunding
if (atData.getIsFinished()) { 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())); () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
return; return;
@ -871,7 +856,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// This shouldn't occur, but defensively bump to next state // 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())); () -> String.format("P2SH-B %s already spent (exposing secret-B)? Checking AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress()));
return; return;
@ -896,7 +881,7 @@ public class TradeBot implements Listener {
bitcoin.broadcastTransaction(p2shRedeemTransaction); bitcoin.broadcastTransaction(p2shRedeemTransaction);
// P2SH-B redeemed, now we wait for Alice to use secret-A to redeem AT // 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())); () -> String.format("P2SH-B %s redeemed (exposing secret-B). Watching AT %s for secret-A", p2shAddressB, tradeBotData.getAtAddress()));
} }
@ -917,22 +902,10 @@ public class TradeBot implements Listener {
* If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done. * If trade-bot successfully broadcasts the MESSAGE transaction, then this specific trade is done.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleAliceWatchingP2shB(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) { if (aliceUnexpectedState(repository, tradeBotData, atData, crossChainTradeData))
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return; return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
// We check variable in AT that is set when Bob is refunded
if (atData.getIsFinished() && crossChainTradeData.mode == AcctMode.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()));
return;
}
Bitcoin bitcoin = Bitcoin.getInstance(); Bitcoin bitcoin = Bitcoin.getInstance();
@ -960,7 +933,7 @@ public class TradeBot implements Listener {
case REFUND_IN_PROGRESS: case REFUND_IN_PROGRESS:
case REFUNDED: case REFUNDED:
// We've refunded P2SH-B? Bump to refunding P2SH-A then // 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)); () -> String.format("P2SH-B %s already refunded. Refunding P2SH-A next", p2shAddressB));
return; return;
} }
@ -996,7 +969,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", () -> String.format("P2SH-B %s redeemed, using secrets to redeem AT %s. Funds should arrive at %s",
p2shAddressB, tradeBotData.getAtAddress(), qortalReceivingAddress)); p2shAddressB, tradeBotData.getAtAddress(), qortalReceivingAddress));
} }
@ -1015,30 +988,17 @@ public class TradeBot implements Listener {
* If trade-bot successfully broadcasts the transaction, then this specific trade is done. * If trade-bot successfully broadcasts the transaction, then this specific trade is done.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleBobWaitingForAtRedeem(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) {
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
// AT should be 'finished' once Alice has redeemed QORT funds // AT should be 'finished' once Alice has redeemed QORT funds
if (!atData.getIsFinished()) if (!atData.getIsFinished())
// Not finished yet // Not finished yet
return; return;
// If AT's balance should be zero // If AT is not REDEEMED then something has gone wrong
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 != AcctMode.REDEEMED) { if (crossChainTradeData.mode != AcctMode.REDEEMED) {
// Not redeemed so must be refunded // Not redeemed so must be refunded/cancelled
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())); () -> String.format("AT %s has auto-refunded - trade aborted", tradeBotData.getAtAddress()));
return; return;
@ -1078,12 +1038,7 @@ public class TradeBot implements Listener {
// Wait for AT to auto-refund // Wait for AT to auto-refund
return; return;
case FUNDED: case FUNDED: {
// Fall-through out of switch...
break;
}
if (htlcStatusA == BitcoinyHTLC.Status.FUNDED) {
Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); Coin redeemAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT);
ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); ECKey redeemKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
List<TransactionOutput> fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); List<TransactionOutput> fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA);
@ -1092,11 +1047,13 @@ public class TradeBot implements Listener {
fundingOutputs, redeemScriptA, secretA, receivingAccountInfo); fundingOutputs, redeemScriptA, secretA, receivingAccountInfo);
bitcoin.broadcastTransaction(p2shRedeemTransaction); bitcoin.broadcastTransaction(p2shRedeemTransaction);
break;
}
} }
String receivingAddress = bitcoin.pkhToAddress(receivingAccountInfo); String receivingAddress = bitcoin.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)); () -> String.format("P2SH-A %s redeemed. Funds should arrive at %s", tradeBotData.getAtAddress(), receivingAddress));
} }
@ -1108,14 +1065,8 @@ public class TradeBot implements Listener {
* Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A. * Upon successful broadcast of P2SH-B refunding transaction, trade-bot's next step is to begin refunding of P2SH-A.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleAliceRefundingP2shB(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) {
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
// We can't refund P2SH-B until lockTime-B has passed // We can't refund P2SH-B until lockTime-B has passed
if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L) if (NTP.getTime() <= crossChainTradeData.lockTimeB * 1000L)
return; return;
@ -1138,6 +1089,10 @@ public class TradeBot implements Listener {
switch (htlcStatusB) { switch (htlcStatusB) {
case UNFUNDED: 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: case FUNDING_IN_PROGRESS:
// Still waiting for P2SH-B to be funded... // Still waiting for P2SH-B to be funded...
return; return;
@ -1145,7 +1100,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// We must be very close to trade timeout. Defensively try to refund P2SH-A // 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)); () -> String.format("P2SH-B %s already spent?. Refunding P2SH-A next", p2shAddressB));
return; return;
@ -1153,26 +1108,24 @@ public class TradeBot implements Listener {
case REFUNDED: case REFUNDED:
break; break;
case FUNDED: case FUNDED:{
break;
}
if (htlcStatusB == BitcoinyHTLC.Status.FUNDED) {
Coin refundAmount = Coin.valueOf(P2SH_B_OUTPUT_AMOUNT); // An actual amount to avoid dust filter, remaining used as fees. 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()); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
List<TransactionOutput> fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB); List<TransactionOutput> fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressB);
// Determine receive address for refund // Determine receive address for refund
String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getXprv58()); String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress);
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey,
fundingOutputs, redeemScriptB, crossChainTradeData.lockTimeB, receiving.getHash()); fundingOutputs, redeemScriptB, crossChainTradeData.lockTimeB, receiving.getHash());
bitcoin.broadcastTransaction(p2shRefundTransaction); bitcoin.broadcastTransaction(p2shRefundTransaction);
break;
}
} }
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)); () -> String.format("Refunded P2SH-B %s. Waiting for LockTime-A", p2shAddressB));
} }
@ -1180,14 +1133,8 @@ public class TradeBot implements Listener {
* Trade-bot is attempting to refund P2SH-A. * Trade-bot is attempting to refund P2SH-A.
* @throws ForeignBlockchainException * @throws ForeignBlockchainException
*/ */
private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData) throws DataException, ForeignBlockchainException { private void handleAliceRefundingP2shA(Repository repository, TradeBotData tradeBotData,
ATData atData = repository.getATRepository().fromATAddress(tradeBotData.getAtAddress()); ATData atData, CrossChainTradeData crossChainTradeData) throws DataException, ForeignBlockchainException {
if (atData == null) {
LOGGER.warn(() -> String.format("Unable to fetch trade AT %s from repository", tradeBotData.getAtAddress()));
return;
}
CrossChainTradeData crossChainTradeData = BitcoinACCTv1.populateTradeData(repository, atData);
// We can't refund P2SH-A until lockTime-A has passed // We can't refund P2SH-A until lockTime-A has passed
if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L) if (NTP.getTime() <= tradeBotData.getLockTimeA() * 1000L)
return; return;
@ -1215,7 +1162,7 @@ public class TradeBot implements Listener {
case REDEEM_IN_PROGRESS: case REDEEM_IN_PROGRESS:
case REDEEMED: case REDEEMED:
// Too late! // Too late!
updateTradeBotState(repository, tradeBotData, TradeBotData.State.ALICE_DONE, TradeBot.updateTradeBotState(repository, tradeBotData, State.ALICE_DONE,
() -> String.format("P2SH-A %s already spent!", p2shAddressA)); () -> String.format("P2SH-A %s already spent!", p2shAddressA));
return; return;
@ -1223,51 +1170,57 @@ public class TradeBot implements Listener {
case REFUNDED: case REFUNDED:
break; break;
case FUNDED: case FUNDED:{
// Fall-through out of switch...
break;
}
if (htlcStatusA == BitcoinyHTLC.Status.FUNDED) {
Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT); Coin refundAmount = Coin.valueOf(crossChainTradeData.expectedBitcoin - P2SH_B_OUTPUT_AMOUNT);
ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey()); ECKey refundKey = ECKey.fromPrivate(tradeBotData.getTradePrivateKey());
List<TransactionOutput> fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA); List<TransactionOutput> fundingOutputs = bitcoin.getUnspentOutputs(p2shAddressA);
// Determine receive address for refund // Determine receive address for refund
String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getXprv58()); String receiveAddress = bitcoin.getUnusedReceiveAddress(tradeBotData.getForeignKey());
Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress); Address receiving = Address.fromString(bitcoin.getNetworkParameters(), receiveAddress);
Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey, Transaction p2shRefundTransaction = BitcoinyHTLC.buildRefundTransaction(bitcoin.getNetworkParameters(), refundAmount, refundKey,
fundingOutputs, redeemScriptA, tradeBotData.getLockTimeA(), receiving.getHash()); fundingOutputs, redeemScriptA, tradeBotData.getLockTimeA(), receiving.getHash());
bitcoin.broadcastTransaction(p2shRefundTransaction); bitcoin.broadcastTransaction(p2shRefundTransaction);
break;
}
} }
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)); () -> 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<String> logMessageSupplier) throws DataException { * Returns true if Alice finds AT unexpectedly cancelled, refunded, redeemed or locked to someone else.
tradeBotData.setState(newState); * <p>
tradeBotData.setTimestamp(NTP.getTime()); * Will automatically update trade-bot state to <tt>ALICE_REFUNDING_B</tt> or <tt>ALICE_DONE</tt> as necessary.
repository.getCrossChainRepository().save(tradeBotData); *
repository.saveChanges(); * @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()) boolean isAtLockedToUs = tradeBotData.getTradeNativeAddress().equals(crossChainTradeData.qortalPartnerAddress);
SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState.name()), MessageType.INFO);
if (logMessageSupplier != null) if (!atData.getIsFinished() && crossChainTradeData.mode == AcctMode.TRADING && isAtLockedToUs)
LOGGER.info(logMessageSupplier); 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?
notifyStateChange(tradeBotData); 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()));
} }
private static void notifyStateChange(TradeBotData tradeBotData) { return true;
StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
EventBus.INSTANCE.notify(stateChangeEvent);
} }
} }

View File

@ -0,0 +1,295 @@
package org.qortal.controller.tradebot;
import java.awt.TrayIcon.MessageType;
import java.security.SecureRandom;
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.event.Event;
import org.qortal.event.EventBus;
import org.qortal.event.Listener;
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.utils.NTP;
/**
* Performing cross-chain trading steps on behalf of user.
* <p>
* We deal with three different independent state-spaces here:
* <ul>
* <li>Qortal blockchain</li>
* <li>Foreign blockchain</li>
* <li>Trade-bot entries</li>
* </ul>
*/
public class TradeBot implements Listener {
private static final Logger LOGGER = LogManager.getLogger(TradeBot.class);
private static final Random RANDOM = new SecureRandom();
public interface StateNameAndValueSupplier {
public String getState();
public int getStateValue();
}
public static class StateChangeEvent implements Event {
private final TradeBotData tradeBotData;
public StateChangeEvent(TradeBotData tradeBotData) {
this.tradeBotData = tradeBotData;
}
public TradeBotData getTradeBotData() {
return this.tradeBotData;
}
}
private static final Map<Class<? extends ACCT>, Supplier<AcctTradeBot>> acctTradeBotSuppliers = new HashMap<>();
static {
acctTradeBotSuppliers.put(BitcoinACCTv1.class, BitcoinACCTv1TradeBot::getInstance);
// acctTradeBotSuppliers.put(LitecoinACCTv1.class, LitecoinACCTv1TradeBot::getInstance);
}
private static TradeBot instance;
private TradeBot() {
EventBus.INSTANCE.addListener(event -> TradeBot.getInstance().listen(event));
}
public static synchronized TradeBot getInstance() {
if (instance == null)
instance = new TradeBot();
return instance;
}
public ACCT getAcctUsingAtData(ATData atData) {
byte[] codeHash = atData.getCodeHash();
if (codeHash == null)
return null;
return SupportedBlockchain.getAcctByCodeHash(codeHash);
}
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
ACCT acct = this.getAcctUsingAtData(atData);
if (acct == null)
return null;
return acct.populateTradeData(repository, atData);
}
/**
* Creates a new trade-bot entry from the "Bob" viewpoint,
* i.e. OFFERing QORT in exchange for foreign blockchain currency.
* <p>
* Generates:
* <ul>
* <li>new 'trade' private key</li>
* <li>secret(s)</li>
* </ul>
* Derives:
* <ul>
* <li>'native' (as in Qortal) public key, public key hash, address (starting with Q)</li>
* <li>'foreign' public key, public key hash</li>
* <li>hash(es) of secret(s)</li>
* </ul>
* A Qortal AT is then constructed including the following as constants in the 'data segment':
* <ul>
* <li>'native' (Qortal) 'trade' address - used to MESSAGE AT</li>
* <li>'foreign' public key hash - used by Alice's to allow redeem of currency on foreign blockchain</li>
* <li>hash(es) of secret(s) - used by AT (optional) and foreign blockchain as needed</li>
* <li>QORT amount on offer by Bob</li>
* <li>foreign currency amount expected in return by Bob (from Alice)</li>
* <li>trading timeout, in case things go wrong and everyone needs to refund</li>
* </ul>
* Returns a DEPLOY_AT transaction that needs to be signed and broadcast to the Qortal network.
* <p>
* Trade-bot will wait for Bob's AT to be deployed before taking next step.
* <p>
* @param repository
* @param tradeBotCreateRequest
* @return raw, unsigned DEPLOY_AT transaction
* @throws DataException
*/
public byte[] createTrade(Repository repository, TradeBotCreateRequest tradeBotCreateRequest) throws DataException {
// Fetch latest ACCT version for requested foreign blockchain
ACCT acct = tradeBotCreateRequest.foreignBlockchain.getLatestAcct();
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
if (acctTradeBot == null)
return null;
return acctTradeBot.createTrade(repository, tradeBotCreateRequest);
}
/**
* Creates a trade-bot entry from the 'Alice' viewpoint,
* i.e. matching foreign blockchain currency to an existing QORT offer.
* <p>
* Requires a chosen trade offer from Bob, passed by <tt>crossChainTradeData</tt>
* and access to a foreign blockchain wallet via <tt>foreignKey</tt>.
* <p>
* @param repository
* @param crossChainTradeData chosen trade OFFER that Alice wants to match
* @param foreignKey foreign blockchain wallet key
* @throws DataException
*/
public ResponseResult startResponse(Repository repository, ATData atData, ACCT acct,
CrossChainTradeData crossChainTradeData, String foreignKey, String receivingAddress) throws DataException {
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
if (acctTradeBot == null) {
LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot for AT %s", atData.getATAddress()));
return ResponseResult.NETWORK_ISSUE;
}
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);
return canDelete;
}
@Override
public void listen(Event event) {
if (!(event instanceof Controller.NewBlockEvent))
return;
synchronized (this) {
List<TradeBotData> allTradeBotData;
try (final Repository repository = RepositoryManager.getRepository()) {
allTradeBotData = repository.getCrossChainRepository().getAllTradeBotData();
} catch (DataException e) {
LOGGER.error("Couldn't run trade bot due to repository issue", e);
return;
}
for (TradeBotData tradeBotData : allTradeBotData)
try (final Repository repository = RepositoryManager.getRepository()) {
// Find ACCT-specific trade-bot for this entry
ACCT acct = SupportedBlockchain.getAcctByName(tradeBotData.getAcctName());
if (acct == null) {
LOGGER.debug(() -> String.format("Couldn't find ACCT matching name %s", tradeBotData.getAcctName()));
continue;
}
AcctTradeBot acctTradeBot = findTradeBotForAcct(acct);
if (acctTradeBot == null) {
LOGGER.debug(() -> String.format("Couldn't find ACCT trade-bot matching name %s", tradeBotData.getAcctName()));
continue;
}
acctTradeBot.progress(repository, tradeBotData);
} catch (DataException e) {
LOGGER.error("Couldn't run trade bot due to repository issue", e);
} catch (ForeignBlockchainException e) {
LOGGER.warn(() -> String.format("Bitcoin issue processing trade-bot entry for AT %s: %s", tradeBotData.getAtAddress(), e.getMessage()));
}
}
}
/*package*/ static byte[] generateTradePrivateKey() {
// The private key is used for both Curve25519 and secp256k1 so needs to be valid for both.
// Curve25519 accepts any seed, so generate a valid secp256k1 key and use that.
return new ECKey().getPrivKeyBytes();
}
/*package*/ static byte[] deriveTradeNativePublicKey(byte[] privateKey) {
return PrivateKeyAccount.toPublicKey(privateKey);
}
/*package*/ static byte[] deriveTradeForeignPublicKey(byte[] privateKey) {
return ECKey.fromPrivate(privateKey).getPubKey();
}
/*package*/ static byte[] generateSecret() {
byte[] secret = new byte[32];
RANDOM.nextBytes(secret);
return secret;
}
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData,
String newState, int newStateValue, Supplier<String> logMessageSupplier) throws DataException {
tradeBotData.setState(newState);
tradeBotData.setStateValue(newStateValue);
tradeBotData.setTimestamp(NTP.getTime());
repository.getCrossChainRepository().save(tradeBotData);
repository.saveChanges();
if (Settings.getInstance().isTradebotSystrayEnabled())
SysTray.getInstance().showMessage("Trade-Bot", String.format("%s: %s", tradeBotData.getAtAddress(), newState), MessageType.INFO);
if (logMessageSupplier != null)
LOGGER.info(logMessageSupplier);
LOGGER.debug(() -> String.format("new state for trade-bot entry based on AT %s: %s", tradeBotData.getAtAddress(), newState));
notifyStateChange(tradeBotData);
}
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, StateNameAndValueSupplier newStateSupplier, Supplier<String> logMessageSupplier) throws DataException {
updateTradeBotState(repository, tradeBotData, newStateSupplier.getState(), newStateSupplier.getStateValue(), logMessageSupplier);
}
/** Updates trade-bot entry to new state, with current timestamp, logs message and notifies state-change listeners. */
/*package*/ static void updateTradeBotState(Repository repository, TradeBotData tradeBotData, Supplier<String> logMessageSupplier) throws DataException {
updateTradeBotState(repository, tradeBotData, tradeBotData.getState(), tradeBotData.getStateValue(), logMessageSupplier);
}
/*package*/ static void notifyStateChange(TradeBotData tradeBotData) {
StateChangeEvent stateChangeEvent = new StateChangeEvent(tradeBotData);
EventBus.INSTANCE.notify(stateChangeEvent);
}
/*package*/ static AcctTradeBot findTradeBotForAcct(ACCT acct) {
Supplier<AcctTradeBot> acctTradeBotSupplier = acctTradeBotSuppliers.get(acct.getClass());
if (acctTradeBotSupplier == null)
return null;
return acctTradeBotSupplier.get();
}
}

View File

@ -0,0 +1,16 @@
package org.qortal.crosschain;
import org.qortal.data.at.ATData;
import org.qortal.data.crosschain.CrossChainTradeData;
import org.qortal.repository.DataException;
import org.qortal.repository.Repository;
public interface ACCT {
public byte[] getCodeBytesHash();
public ForeignBlockchain getBlockchain();
public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException;
}

View File

@ -98,11 +98,12 @@ import com.google.common.primitives.Bytes;
* </li> * </li>
* </ul> * </ul>
*/ */
public class BitcoinACCTv1 { public class BitcoinACCTv1 implements ACCT {
public static final String NAME = "BitcoinACCTv1";
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
public static final int SECRET_LENGTH = 32; public static final int SECRET_LENGTH = 32;
public static final int MIN_LOCKTIME = 1500000000;
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("f7f419522a9aaa3c671149878f8c1374dfc59d4fd86ca43ff2a4d913cfbc9e89").asBytes(); // SHA256 of AT code bytes
/** <b>Value</b> offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ /** <b>Value</b> offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */
private static final int MODE_VALUE_OFFSET = 68; private static final int MODE_VALUE_OFFSET = 68;
@ -123,9 +124,26 @@ public class BitcoinACCTv1 {
public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/; public static final int REDEEM_MESSAGE_LENGTH = 32 /*secret*/ + 32 /*secret*/ + 32 /*partner's Qortal receiving address padded from 25 to 32*/;
public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/; public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
private static BitcoinACCTv1 instance;
private BitcoinACCTv1() { private BitcoinACCTv1() {
} }
public static synchronized BitcoinACCTv1 getInstance() {
if (instance == null)
instance = new BitcoinACCTv1();
return instance;
}
public byte[] getCodeBytesHash() {
return CODE_BYTES_HASH;
}
public ForeignBlockchain getBlockchain() {
return Bitcoin.getInstance();
}
/** /**
* Returns Qortal AT creation bytes for cross-chain trading AT. * Returns Qortal AT creation bytes for cross-chain trading AT.
* <p> * <p>
@ -590,7 +608,7 @@ public class BitcoinACCTv1 {
* @param atAddress * @param atAddress
* @throws DataException * @throws DataException
*/ */
public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData);
} }

View File

@ -9,6 +9,7 @@ import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.bitcoinj.core.Address; import org.bitcoinj.core.Address;
import org.bitcoinj.core.AddressFormatException;
import org.bitcoinj.core.Coin; import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Context; import org.bitcoinj.core.Context;
import org.bitcoinj.core.ECKey; import org.bitcoinj.core.ECKey;
@ -36,7 +37,7 @@ import org.qortal.utils.BitTwiddling;
import com.google.common.hash.HashCode; import com.google.common.hash.HashCode;
/** Bitcoin-like (Bitcoin, Litecoin, etc.) support */ /** Bitcoin-like (Bitcoin, Litecoin, etc.) support */
public abstract class Bitcoiny { public abstract class Bitcoiny implements ForeignBlockchain {
protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class); protected static final Logger LOGGER = LogManager.getLogger(Bitcoiny.class);
@ -82,6 +83,24 @@ public abstract class Bitcoiny {
return this.params; 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.isValidXprv(walletKey);
}
// Actual useful methods for use by other classes // Actual useful methods for use by other classes
public String format(Coin amount) { public String format(Coin amount) {
@ -247,9 +266,10 @@ public abstract class Bitcoiny {
* @param xprv58 BIP32 private key * @param xprv58 BIP32 private key
* @param recipient P2PKH address * @param recipient P2PKH address
* @param amount unscaled amount * @param amount unscaled amount
* @param feePerByte unscaled fee per byte, or null to use default fees
* @return transaction, or null if insufficient funds * @return transaction, or null if insufficient funds
*/ */
public Transaction buildSpend(String xprv58, String recipient, long amount) { public Transaction buildSpend(String xprv58, String recipient, long amount, Long feePerByte) {
Context.propagate(bitcoinjContext); Context.propagate(bitcoinjContext);
Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS); Wallet wallet = Wallet.fromSpendingKeyB58(this.params, xprv58, DeterministicHierarchy.BIP32_STANDARDISATION_TIME_SECS);
@ -258,6 +278,9 @@ public abstract class Bitcoiny {
Address destination = Address.fromString(this.params, recipient); Address destination = Address.fromString(this.params, recipient);
SendRequest sendRequest = SendRequest.to(destination, Coin.valueOf(amount)); 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. // Allow override of default for TestNet3, etc.
sendRequest.feePerKb = this.getFeePerKb(); sendRequest.feePerKb = this.getFeePerKb();
@ -269,6 +292,18 @@ public abstract class Bitcoiny {
} }
} }
/**
* Returns bitcoinj transaction sending <tt>amount</tt> to <tt>recipient</tt> using default fees.
*
* @param xprv58 BIP32 private key
* @param recipient P2PKH address
* @param amount unscaled amount
* @return transaction, or null if insufficient funds
*/
public Transaction buildSpend(String xprv58, String recipient, long amount) {
return buildSpend(xprv58, recipient, amount, null);
}
/** /**
* Returns unspent Bitcoin balance given 'm' BIP32 key. * Returns unspent Bitcoin balance given 'm' BIP32 key.
* *

View File

@ -0,0 +1,9 @@
package org.qortal.crosschain;
public interface ForeignBlockchain {
public boolean isValidAddress(String address);
public boolean isValidWalletKey(String walletKey);
}

View File

@ -87,11 +87,12 @@ import com.google.common.primitives.Bytes;
* </li> * </li>
* </ul> * </ul>
*/ */
public class LitecoinACCTv1 { public class LitecoinACCTv1 implements ACCT {
public static final String NAME = "LitcoinACCTv1";
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0fb15ad9ad1867dfbcafa51155481aa15d984ff9506f2b428eca4e2a2feac2b3").asBytes(); // SHA256 of AT code bytes
public static final int SECRET_LENGTH = 32; public static final int SECRET_LENGTH = 32;
public static final int MIN_LOCKTIME = 1500000000;
public static final byte[] CODE_BYTES_HASH = HashCode.fromString("0fb15ad9ad1867dfbcafa51155481aa15d984ff9506f2b428eca4e2a2feac2b3").asBytes(); // SHA256 of AT code bytes
/** <b>Value</b> offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */ /** <b>Value</b> offset into AT segment where 'mode' variable (long) is stored. (Multiply by MachineState.VALUE_SIZE for byte offset). */
private static final int MODE_VALUE_OFFSET = 61; private static final int MODE_VALUE_OFFSET = 61;
@ -112,9 +113,26 @@ public class LitecoinACCTv1 {
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 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*/; public static final int CANCEL_MESSAGE_LENGTH = 32 /*AT creator's Qortal address*/;
private static LitecoinACCTv1 instance;
private LitecoinACCTv1() { private LitecoinACCTv1() {
} }
public static synchronized LitecoinACCTv1 getInstance() {
if (instance == null)
instance = new LitecoinACCTv1();
return instance;
}
public byte[] getCodeBytesHash() {
return CODE_BYTES_HASH;
}
public ForeignBlockchain getBlockchain() {
return Litecoin.getInstance();
}
/** /**
* Returns Qortal AT creation bytes for cross-chain trading AT. * Returns Qortal AT creation bytes for cross-chain trading AT.
* <p> * <p>
@ -541,7 +559,7 @@ public class LitecoinACCTv1 {
* @param atAddress * @param atAddress
* @throws DataException * @throws DataException
*/ */
public static CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException { public CrossChainTradeData populateTradeData(Repository repository, ATData atData) throws DataException {
ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress()); ATStateData atStateData = repository.getATRepository().getLatestATState(atData.getATAddress());
return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData); return populateTradeData(repository, atData.getCreatorPublicKey(), atStateData);
} }

View File

@ -0,0 +1,80 @@
package org.qortal.crosschain;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
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)
)) {
@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 final Map<ByteArray, Supplier<ACCT>> supportedAcctsByCodeHash = new HashMap<>();
private final Map<String, Supplier<ACCT>> supportedAcctsByName = new HashMap<>();
SupportedBlockchain(List<Triple<String, byte[], Supplier<ACCT>>> supportedAccts) {
supportedAccts.forEach(triple -> triple.consume((acctName, hashBytes, supplier) -> {
supportedAcctsByCodeHash.put(new ByteArray(hashBytes), supplier);
supportedAcctsByName.put(acctName, supplier);
}));
}
public abstract ForeignBlockchain getInstance();
public abstract ACCT getLatestAcct();
public static ACCT getAcctByCodeHash(byte[] codeHash) {
for (SupportedBlockchain supportedBlockchain : SupportedBlockchain.values()) {
@SuppressWarnings("unlikely-arg-type") // OK, because ByteArray is designed to work with byte[]
Supplier<ACCT> acctInstanceSupplier = supportedBlockchain.supportedAcctsByCodeHash.get(codeHash);
if (acctInstanceSupplier != null)
return acctInstanceSupplier.get();
}
return null;
}
public static ACCT getAcctByName(String acctName) {
for (SupportedBlockchain supportedBlockchain : SupportedBlockchain.values()) {
Supplier<ACCT> acctInstanceSupplier = supportedBlockchain.supportedAcctsByName.get(acctName);
if (acctInstanceSupplier != null)
return acctInstanceSupplier.get();
}
return null;
}
}

View File

@ -1,10 +1,5 @@
package org.qortal.data.crosschain; package org.qortal.data.crosschain;
import static java.util.Arrays.stream;
import static java.util.stream.Collectors.toMap;
import java.util.Map;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlTransient; import javax.xml.bind.annotation.XmlTransient;
@ -18,22 +13,13 @@ public class TradeBotData {
private byte[] tradePrivateKey; private byte[] tradePrivateKey;
public enum State { private String acctName;
BOB_WAITING_FOR_AT_CONFIRM(10), BOB_WAITING_FOR_MESSAGE(15), BOB_WAITING_FOR_P2SH_B(20), BOB_WAITING_FOR_AT_REDEEM(25), BOB_DONE(30), BOB_REFUNDED(35), private String tradeState;
ALICE_WAITING_FOR_P2SH_A(80), ALICE_WAITING_FOR_AT_LOCK(85), ALICE_WATCH_P2SH_B(90), ALICE_DONE(95), ALICE_REFUNDING_B(100), ALICE_REFUNDING_A(105), ALICE_REFUNDED(110);
public final int value; // Internal use - not shown via API
private static final Map<Integer, State> map = stream(State.values()).collect(toMap(state -> state.value, state -> state)); @XmlTransient
@Schema(hidden = true)
State(int value) { private int tradeStateValue;
this.value = value;
}
public static State valueOf(int value) {
return map.get(value);
}
}
private State tradeState;
private String creatorAddress; private String creatorAddress;
private String atAddress; private String atAddress;
@ -50,19 +36,25 @@ public class TradeBotData {
private byte[] secret; private byte[] secret;
private byte[] hashOfSecret; private byte[] hashOfSecret;
private String foreignBlockchain;
private byte[] tradeForeignPublicKey; private byte[] tradeForeignPublicKey;
private byte[] tradeForeignPublicKeyHash; private byte[] tradeForeignPublicKeyHash;
@Deprecated
@Schema(description = "DEPRECATED: use foreignAmount instead", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class) @XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long bitcoinAmount; private long bitcoinAmount;
@Schema(description = "amount in foreign blockchain currency", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
private long foreignAmount;
// Never expose this via API // Never expose this via API
@XmlTransient @XmlTransient
@Schema(hidden = true) @Schema(hidden = true)
private String xprv58; private String foreignKey;
private byte[] lastTransactionSignature; private byte[] lastTransactionSignature;
private Integer lockTimeA; private Integer lockTimeA;
// Could be Bitcoin or Qortal... // Could be Bitcoin or Qortal...
@ -72,14 +64,18 @@ public class TradeBotData {
/* JAXB */ /* JAXB */
} }
public TradeBotData(byte[] tradePrivateKey, State tradeState, String creatorAddress, String atAddress, public TradeBotData(byte[] tradePrivateKey, String acctName, String tradeState, int tradeStateValue,
String creatorAddress, String atAddress,
long timestamp, long qortAmount, long timestamp, long qortAmount,
byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress, byte[] tradeNativePublicKey, byte[] tradeNativePublicKeyHash, String tradeNativeAddress,
byte[] secret, byte[] hashOfSecret, byte[] secret, byte[] hashOfSecret,
byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash, String foreignBlockchain, byte[] tradeForeignPublicKey, byte[] tradeForeignPublicKeyHash,
long bitcoinAmount, String xprv58, byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) { long foreignAmount, String foreignKey,
byte[] lastTransactionSignature, Integer lockTimeA, byte[] receivingAccountInfo) {
this.tradePrivateKey = tradePrivateKey; this.tradePrivateKey = tradePrivateKey;
this.acctName = acctName;
this.tradeState = tradeState; this.tradeState = tradeState;
this.tradeStateValue = tradeStateValue;
this.creatorAddress = creatorAddress; this.creatorAddress = creatorAddress;
this.atAddress = atAddress; this.atAddress = atAddress;
this.timestamp = timestamp; this.timestamp = timestamp;
@ -89,10 +85,13 @@ public class TradeBotData {
this.tradeNativeAddress = tradeNativeAddress; this.tradeNativeAddress = tradeNativeAddress;
this.secret = secret; this.secret = secret;
this.hashOfSecret = hashOfSecret; this.hashOfSecret = hashOfSecret;
this.foreignBlockchain = foreignBlockchain;
this.tradeForeignPublicKey = tradeForeignPublicKey; this.tradeForeignPublicKey = tradeForeignPublicKey;
this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash; this.tradeForeignPublicKeyHash = tradeForeignPublicKeyHash;
this.bitcoinAmount = bitcoinAmount; // deprecated copy
this.xprv58 = xprv58; this.bitcoinAmount = foreignAmount;
this.foreignAmount = foreignAmount;
this.foreignKey = foreignKey;
this.lastTransactionSignature = lastTransactionSignature; this.lastTransactionSignature = lastTransactionSignature;
this.lockTimeA = lockTimeA; this.lockTimeA = lockTimeA;
this.receivingAccountInfo = receivingAccountInfo; this.receivingAccountInfo = receivingAccountInfo;
@ -102,14 +101,26 @@ public class TradeBotData {
return this.tradePrivateKey; return this.tradePrivateKey;
} }
public State getState() { public String getAcctName() {
return this.acctName;
}
public String getState() {
return this.tradeState; return this.tradeState;
} }
public void setState(State state) { public void setState(String state) {
this.tradeState = state; this.tradeState = state;
} }
public int getStateValue() {
return this.tradeStateValue;
}
public void setStateValue(int stateValue) {
this.tradeStateValue = stateValue;
}
public String getCreatorAddress() { public String getCreatorAddress() {
return this.creatorAddress; return this.creatorAddress;
} }
@ -154,6 +165,10 @@ public class TradeBotData {
return this.hashOfSecret; return this.hashOfSecret;
} }
public String getForeignBlockchain() {
return this.foreignBlockchain;
}
public byte[] getTradeForeignPublicKey() { public byte[] getTradeForeignPublicKey() {
return this.tradeForeignPublicKey; return this.tradeForeignPublicKey;
} }
@ -162,12 +177,12 @@ public class TradeBotData {
return this.tradeForeignPublicKeyHash; return this.tradeForeignPublicKeyHash;
} }
public long getBitcoinAmount() { public long getForeignAmount() {
return this.bitcoinAmount; return this.foreignAmount;
} }
public String getXprv58() { public String getForeignKey() {
return this.xprv58; return this.foreignKey;
} }
public byte[] getLastTransactionSignature() { public byte[] getLastTransactionSignature() {
@ -192,7 +207,7 @@ public class TradeBotData {
// Mostly for debugging // Mostly for debugging
public String toString() { public String toString() {
return String.format("%s: %s", this.atAddress, this.tradeState.name()); return String.format("%s: %s (%d)", this.atAddress, this.tradeState, this.tradeStateValue);
} }
} }

View File

@ -19,12 +19,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
@Override @Override
public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException { public TradeBotData getTradeBotData(byte[] tradePrivateKey) throws DataException {
String sql = "SELECT trade_state, creator_address, at_address, " String sql = "SELECT acct_name, trade_state, trade_state_value, "
+ "creator_address, at_address, "
+ "updated_when, qort_amount, " + "updated_when, qort_amount, "
+ "trade_native_public_key, trade_native_public_key_hash, " + "trade_native_public_key, trade_native_public_key_hash, "
+ "trade_native_address, secret, hash_of_secret, " + "trade_native_address, secret, hash_of_secret, "
+ "trade_foreign_public_key, trade_foreign_public_key_hash, " + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, "
+ "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info " + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info "
+ "FROM TradeBotStates " + "FROM TradeBotStates "
+ "WHERE trade_private_key = ?"; + "WHERE trade_private_key = ?";
@ -32,36 +33,36 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
if (resultSet == null) if (resultSet == null)
return null; return null;
int tradeStateValue = resultSet.getInt(1); String acctName = resultSet.getString(1);
TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); String tradeState = resultSet.getString(2);
if (tradeState == null) int tradeStateValue = resultSet.getInt(3);
throw new DataException("Illegal trade-bot trade-state fetched from repository"); String creatorAddress = resultSet.getString(4);
String atAddress = resultSet.getString(5);
String creatorAddress = resultSet.getString(2); long timestamp = resultSet.getLong(6);
String atAddress = resultSet.getString(3); long qortAmount = resultSet.getLong(7);
long timestamp = resultSet.getLong(4); byte[] tradeNativePublicKey = resultSet.getBytes(8);
long qortAmount = resultSet.getLong(5); byte[] tradeNativePublicKeyHash = resultSet.getBytes(9);
byte[] tradeNativePublicKey = resultSet.getBytes(6); String tradeNativeAddress = resultSet.getString(10);
byte[] tradeNativePublicKeyHash = resultSet.getBytes(7); byte[] secret = resultSet.getBytes(11);
String tradeNativeAddress = resultSet.getString(8); byte[] hashOfSecret = resultSet.getBytes(12);
byte[] secret = resultSet.getBytes(9); String foreignBlockchain = resultSet.getString(13);
byte[] hashOfSecret = resultSet.getBytes(10); byte[] tradeForeignPublicKey = resultSet.getBytes(14);
byte[] tradeForeignPublicKey = resultSet.getBytes(11); byte[] tradeForeignPublicKeyHash = resultSet.getBytes(15);
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(12); long foreignAmount = resultSet.getLong(16);
long bitcoinAmount = resultSet.getLong(13); String foreignKey = resultSet.getString(17);
String xprv58 = resultSet.getString(14); byte[] lastTransactionSignature = resultSet.getBytes(18);
byte[] lastTransactionSignature = resultSet.getBytes(15); Integer lockTimeA = resultSet.getInt(19);
Integer lockTimeA = resultSet.getInt(16);
if (lockTimeA == 0 && resultSet.wasNull()) if (lockTimeA == 0 && resultSet.wasNull())
lockTimeA = null; lockTimeA = null;
byte[] receivingAccountInfo = resultSet.getBytes(17); byte[] receivingAccountInfo = resultSet.getBytes(20);
return new TradeBotData(tradePrivateKey, tradeState, return new TradeBotData(tradePrivateKey, acctName,
tradeState, tradeStateValue,
creatorAddress, atAddress, timestamp, qortAmount, creatorAddress, atAddress, timestamp, qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secret, hashOfSecret, secret, hashOfSecret,
tradeForeignPublicKey, tradeForeignPublicKeyHash, foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash,
bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo);
} catch (SQLException e) { } catch (SQLException e) {
throw new DataException("Unable to fetch trade-bot trading state from repository", e); throw new DataException("Unable to fetch trade-bot trading state from repository", e);
} }
@ -69,12 +70,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
@Override @Override
public List<TradeBotData> getAllTradeBotData() throws DataException { public List<TradeBotData> getAllTradeBotData() throws DataException {
String sql = "SELECT trade_private_key, trade_state, creator_address, at_address, " String sql = "SELECT trade_private_key, acct_name, trade_state, trade_state_value, "
+ "creator_address, at_address, "
+ "updated_when, qort_amount, " + "updated_when, qort_amount, "
+ "trade_native_public_key, trade_native_public_key_hash, " + "trade_native_public_key, trade_native_public_key_hash, "
+ "trade_native_address, secret, hash_of_secret, " + "trade_native_address, secret, hash_of_secret, "
+ "trade_foreign_public_key, trade_foreign_public_key_hash, " + "foreign_blockchain, trade_foreign_public_key, trade_foreign_public_key_hash, "
+ "bitcoin_amount, xprv58, last_transaction_signature, locktime_a, receiving_account_info " + "foreign_amount, foreign_key, last_transaction_signature, locktime_a, receiving_account_info "
+ "FROM TradeBotStates"; + "FROM TradeBotStates";
List<TradeBotData> allTradeBotData = new ArrayList<>(); List<TradeBotData> allTradeBotData = new ArrayList<>();
@ -85,36 +87,36 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
do { do {
byte[] tradePrivateKey = resultSet.getBytes(1); byte[] tradePrivateKey = resultSet.getBytes(1);
int tradeStateValue = resultSet.getInt(2); String acctName = resultSet.getString(2);
TradeBotData.State tradeState = TradeBotData.State.valueOf(tradeStateValue); String tradeState = resultSet.getString(3);
if (tradeState == null) int tradeStateValue = resultSet.getInt(4);
throw new DataException("Illegal trade-bot trade-state fetched from repository"); String creatorAddress = resultSet.getString(5);
String atAddress = resultSet.getString(6);
String creatorAddress = resultSet.getString(3); long timestamp = resultSet.getLong(7);
String atAddress = resultSet.getString(4); long qortAmount = resultSet.getLong(8);
long timestamp = resultSet.getLong(5); byte[] tradeNativePublicKey = resultSet.getBytes(9);
long qortAmount = resultSet.getLong(6); byte[] tradeNativePublicKeyHash = resultSet.getBytes(10);
byte[] tradeNativePublicKey = resultSet.getBytes(7); String tradeNativeAddress = resultSet.getString(11);
byte[] tradeNativePublicKeyHash = resultSet.getBytes(8); byte[] secret = resultSet.getBytes(12);
String tradeNativeAddress = resultSet.getString(9); byte[] hashOfSecret = resultSet.getBytes(13);
byte[] secret = resultSet.getBytes(10); String foreignBlockchain = resultSet.getString(14);
byte[] hashOfSecret = resultSet.getBytes(11); byte[] tradeForeignPublicKey = resultSet.getBytes(15);
byte[] tradeForeignPublicKey = resultSet.getBytes(12); byte[] tradeForeignPublicKeyHash = resultSet.getBytes(16);
byte[] tradeForeignPublicKeyHash = resultSet.getBytes(13); long foreignAmount = resultSet.getLong(17);
long bitcoinAmount = resultSet.getLong(14); String foreignKey = resultSet.getString(18);
String xprv58 = resultSet.getString(15); byte[] lastTransactionSignature = resultSet.getBytes(19);
byte[] lastTransactionSignature = resultSet.getBytes(16); Integer lockTimeA = resultSet.getInt(20);
Integer lockTimeA = resultSet.getInt(17);
if (lockTimeA == 0 && resultSet.wasNull()) if (lockTimeA == 0 && resultSet.wasNull())
lockTimeA = null; lockTimeA = null;
byte[] receivingAccountInfo = resultSet.getBytes(18); byte[] receivingAccountInfo = resultSet.getBytes(21);
TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, tradeState, TradeBotData tradeBotData = new TradeBotData(tradePrivateKey, acctName,
tradeState, tradeStateValue,
creatorAddress, atAddress, timestamp, qortAmount, creatorAddress, atAddress, timestamp, qortAmount,
tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress, tradeNativePublicKey, tradeNativePublicKeyHash, tradeNativeAddress,
secret, hashOfSecret, secret, hashOfSecret,
tradeForeignPublicKey, tradeForeignPublicKeyHash, foreignBlockchain, tradeForeignPublicKey, tradeForeignPublicKeyHash,
bitcoinAmount, xprv58, lastTransactionSignature, lockTimeA, receivingAccountInfo); foreignAmount, foreignKey, lastTransactionSignature, lockTimeA, receivingAccountInfo);
allTradeBotData.add(tradeBotData); allTradeBotData.add(tradeBotData);
} while (resultSet.next()); } while (resultSet.next());
@ -129,7 +131,9 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates"); HSQLDBSaver saveHelper = new HSQLDBSaver("TradeBotStates");
saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey()) saveHelper.bind("trade_private_key", tradeBotData.getTradePrivateKey())
.bind("trade_state", tradeBotData.getState().value) .bind("acct_name", tradeBotData.getAcctName())
.bind("trade_state", tradeBotData.getState())
.bind("trade_state_value", tradeBotData.getStateValue())
.bind("creator_address", tradeBotData.getCreatorAddress()) .bind("creator_address", tradeBotData.getCreatorAddress())
.bind("at_address", tradeBotData.getAtAddress()) .bind("at_address", tradeBotData.getAtAddress())
.bind("updated_when", tradeBotData.getTimestamp()) .bind("updated_when", tradeBotData.getTimestamp())
@ -137,11 +141,13 @@ public class HSQLDBCrossChainRepository implements CrossChainRepository {
.bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey()) .bind("trade_native_public_key", tradeBotData.getTradeNativePublicKey())
.bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash()) .bind("trade_native_public_key_hash", tradeBotData.getTradeNativePublicKeyHash())
.bind("trade_native_address", tradeBotData.getTradeNativeAddress()) .bind("trade_native_address", tradeBotData.getTradeNativeAddress())
.bind("secret", tradeBotData.getSecret()).bind("hash_of_secret", tradeBotData.getHashOfSecret()) .bind("secret", tradeBotData.getSecret())
.bind("hash_of_secret", tradeBotData.getHashOfSecret())
.bind("foreign_blockchain", tradeBotData.getForeignBlockchain())
.bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey()) .bind("trade_foreign_public_key", tradeBotData.getTradeForeignPublicKey())
.bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash()) .bind("trade_foreign_public_key_hash", tradeBotData.getTradeForeignPublicKeyHash())
.bind("bitcoin_amount", tradeBotData.getBitcoinAmount()) .bind("foreign_amount", tradeBotData.getForeignAmount())
.bind("xprv58", tradeBotData.getXprv58()) .bind("foreign_key", tradeBotData.getForeignKey())
.bind("last_transaction_signature", tradeBotData.getLastTransactionSignature()) .bind("last_transaction_signature", tradeBotData.getLastTransactionSignature())
.bind("locktime_a", tradeBotData.getLockTimeA()) .bind("locktime_a", tradeBotData.getLockTimeA())
.bind("receiving_account_info", tradeBotData.getReceivingAccountInfo()); .bind("receiving_account_info", tradeBotData.getReceivingAccountInfo());

View File

@ -4,9 +4,12 @@ import java.sql.Connection;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.sql.Statement; import java.sql.Statement;
import java.util.Arrays;
import java.util.stream.Collectors;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.qortal.controller.tradebot.BitcoinACCTv1TradeBot;
public class HSQLDBDatabaseUpdates { public class HSQLDBDatabaseUpdates {
@ -609,6 +612,7 @@ public class HSQLDBDatabaseUpdates {
case 20: case 20:
// Trade bot // Trade bot
// See case 25 below for changes
stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, " stmt.execute("CREATE TABLE TradeBotStates (trade_private_key QortalKeySeed NOT NULL, trade_state TINYINT NOT NULL, "
+ "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, " + "creator_address QortalAddress NOT NULL, at_address QortalAddress, updated_when BIGINT NOT NULL, qort_amount QortalAmount NOT NULL, "
+ "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, " + "trade_native_public_key QortalPublicKey NOT NULL, trade_native_public_key_hash VARBINARY(32) NOT NULL, "
@ -653,6 +657,35 @@ public class HSQLDBDatabaseUpdates {
stmt.execute("DROP TABLE IF EXISTS NextBlockHeight"); stmt.execute("DROP TABLE IF EXISTS NextBlockHeight");
break; break;
case 25:
// 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");
break;
default: default:
// nothing to do // nothing to do
return false; return false;

View File

@ -1,42 +1,55 @@
package org.qortal.utils; package org.qortal.utils;
public class Triple<T, U, V> { public class Triple<A, B, C> {
private T a; @FunctionalInterface
private U b; public interface TripleConsumer<A, B, C> {
private V c; public void accept(A a, B b, C c);
}
private A a;
private B b;
private C c;
public static <A, B, C> Triple<A, B, C> valueOf(A a, B b, C c) {
return new Triple<>(a, b, c);
}
public Triple() { public Triple() {
} }
public Triple(T a, U b, V c) { public Triple(A a, B b, C c) {
this.a = a; this.a = a;
this.b = b; this.b = b;
this.c = c; this.c = c;
} }
public void setA(T a) { public void setA(A a) {
this.a = a; this.a = a;
} }
public T getA() { public A getA() {
return a; return a;
} }
public void setB(U b) { public void setB(B b) {
this.b = b; this.b = b;
} }
public U getB() { public B getB() {
return b; return b;
} }
public void setC(V c) { public void setC(C c) {
this.c = c; this.c = c;
} }
public V getC() { public C getC() {
return c; return c;
} }
public void consume(TripleConsumer<A, B, C> consumer) {
consumer.accept(this.a, this.b, this.c);
}
} }

View File

@ -153,7 +153,7 @@ public class BitcoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in CANCELLED mode // AT should be in CANCELLED mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.CANCELLED, tradeData.mode); assertEquals(AcctMode.CANCELLED, tradeData.mode);
// Check balances // Check balances
@ -212,7 +212,7 @@ public class BitcoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in CANCELLED mode // AT should be in CANCELLED mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.CANCELLED, tradeData.mode); assertEquals(AcctMode.CANCELLED, tradeData.mode);
} }
} }
@ -250,7 +250,7 @@ public class BitcoinACCTv1Tests extends Common {
describeAt(repository, atAddress); describeAt(repository, atAddress);
ATData atData = repository.getATRepository().fromATAddress(atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress);
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
// AT should be in TRADE mode // AT should be in TRADE mode
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
@ -312,7 +312,7 @@ public class BitcoinACCTv1Tests extends Common {
describeAt(repository, atAddress); describeAt(repository, atAddress);
ATData atData = repository.getATRepository().fromATAddress(atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress);
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
// AT should still be in OFFER mode // AT should still be in OFFER mode
assertEquals(AcctMode.OFFERING, tradeData.mode); assertEquals(AcctMode.OFFERING, tradeData.mode);
@ -359,7 +359,7 @@ public class BitcoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in REFUNDED mode // AT should be in REFUNDED mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.REFUNDED, tradeData.mode); assertEquals(AcctMode.REFUNDED, tradeData.mode);
// Test orphaning // Test orphaning
@ -415,7 +415,7 @@ public class BitcoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in REDEEMED mode // AT should be in REDEEMED mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.REDEEMED, tradeData.mode); assertEquals(AcctMode.REDEEMED, tradeData.mode);
// Check balances // Check balances
@ -486,7 +486,7 @@ public class BitcoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should still be in TRADE mode // AT should still be in TRADE mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
// Check balances // Check balances
@ -546,7 +546,7 @@ public class BitcoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should still be in TRADE mode // AT should still be in TRADE mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee();
@ -568,7 +568,7 @@ public class BitcoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should still be in TRADE mode // AT should still be in TRADE mode
tradeData = BitcoinACCTv1.populateTradeData(repository, atData); tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
// Check balances // Check balances
@ -624,7 +624,7 @@ public class BitcoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should be in TRADING mode // AT should be in TRADING mode
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
} }
} }
@ -747,7 +747,7 @@ public class BitcoinACCTv1Tests extends Common {
private void describeAt(Repository repository, String atAddress) throws DataException { private void describeAt(Repository repository, String atAddress) throws DataException {
ATData atData = repository.getATRepository().fromATAddress(atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress);
CrossChainTradeData tradeData = BitcoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = BitcoinACCTv1.getInstance().populateTradeData(repository, atData);
Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight();

View File

@ -151,7 +151,7 @@ public class LitecoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in CANCELLED mode // AT should be in CANCELLED mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.CANCELLED, tradeData.mode); assertEquals(AcctMode.CANCELLED, tradeData.mode);
// Check balances // Check balances
@ -210,7 +210,7 @@ public class LitecoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in CANCELLED mode // AT should be in CANCELLED mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.CANCELLED, tradeData.mode); assertEquals(AcctMode.CANCELLED, tradeData.mode);
} }
} }
@ -248,7 +248,7 @@ public class LitecoinACCTv1Tests extends Common {
describeAt(repository, atAddress); describeAt(repository, atAddress);
ATData atData = repository.getATRepository().fromATAddress(atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress);
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
// AT should be in TRADE mode // AT should be in TRADE mode
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
@ -310,7 +310,7 @@ public class LitecoinACCTv1Tests extends Common {
describeAt(repository, atAddress); describeAt(repository, atAddress);
ATData atData = repository.getATRepository().fromATAddress(atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress);
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
// AT should still be in OFFER mode // AT should still be in OFFER mode
assertEquals(AcctMode.OFFERING, tradeData.mode); assertEquals(AcctMode.OFFERING, tradeData.mode);
@ -357,7 +357,7 @@ public class LitecoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in REFUNDED mode // AT should be in REFUNDED mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.REFUNDED, tradeData.mode); assertEquals(AcctMode.REFUNDED, tradeData.mode);
// Test orphaning // Test orphaning
@ -413,7 +413,7 @@ public class LitecoinACCTv1Tests extends Common {
assertTrue(atData.getIsFinished()); assertTrue(atData.getIsFinished());
// AT should be in REDEEMED mode // AT should be in REDEEMED mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.REDEEMED, tradeData.mode); assertEquals(AcctMode.REDEEMED, tradeData.mode);
// Check balances // Check balances
@ -484,7 +484,7 @@ public class LitecoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should still be in TRADE mode // AT should still be in TRADE mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
// Check balances // Check balances
@ -544,7 +544,7 @@ public class LitecoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should still be in TRADE mode // AT should still be in TRADE mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee(); long expectedBalance = partnersInitialBalance - messageTransaction.getTransactionData().getFee();
@ -599,7 +599,7 @@ public class LitecoinACCTv1Tests extends Common {
assertFalse(atData.getIsFinished()); assertFalse(atData.getIsFinished());
// AT should be in TRADING mode // AT should be in TRADING mode
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
assertEquals(AcctMode.TRADING, tradeData.mode); assertEquals(AcctMode.TRADING, tradeData.mode);
} }
} }
@ -722,7 +722,7 @@ public class LitecoinACCTv1Tests extends Common {
private void describeAt(Repository repository, String atAddress) throws DataException { private void describeAt(Repository repository, String atAddress) throws DataException {
ATData atData = repository.getATRepository().fromATAddress(atAddress); ATData atData = repository.getATRepository().fromATAddress(atAddress);
CrossChainTradeData tradeData = LitecoinACCTv1.populateTradeData(repository, atData); CrossChainTradeData tradeData = LitecoinACCTv1.getInstance().populateTradeData(repository, atData);
Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)); Function<Long, String> epochMilliFormatter = (timestamp) -> LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneOffset.UTC).format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM));
int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight(); int currentBlockHeight = repository.getBlockRepository().getBlockchainHeight();