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

@ -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,8 +278,11 @@ 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));
// Allow override of default for TestNet3, etc. if (feePerByte != null)
sendRequest.feePerKb = this.getFeePerKb(); sendRequest.feePerKb = Coin.valueOf(feePerByte * 1000L); // Note: 1000 not 1024
else
// Allow override of default for TestNet3, etc.
sendRequest.feePerKb = this.getFeePerKb();
try { try {
wallet.completeTx(sendRequest); wallet.completeTx(sendRequest);
@ -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();