3
0
mirror of https://github.com/Qortal/qortal.git synced 2025-02-14 11:15:49 +00:00

Added support for Pirate Chain wallets.

Note: this relies on (a modified version of) liblitewallet-jni which is not included, but will ultimately be compiled for each supported architecture and hosted on QDN.

LiteWalletJni code is based on https://github.com/PirateNetwork/cordova-plugin-litewallet - thanks to @CryptoForge for the help in getting this up and running.
This commit is contained in:
CalDescent 2022-05-07 16:32:04 +01:00
parent 184984c16f
commit d073b9da65
11 changed files with 1003 additions and 1 deletions

1
.gitignore vendored
View File

@ -28,6 +28,7 @@
/WindowsInstaller/Install Files/qortal.jar
/*.7z
/tmp
/wallets
/data*
/src/test/resources/arbitrary/*/.qortal/cache
apikey.txt

View File

@ -0,0 +1,64 @@
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* LiteWalletJni code based on https://github.com/PirateNetwork/cordova-plugin-litewallet
*
* MIT License
*
* Copyright (c) 2020 Zero Currency Coin
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package com.rust.litewalletjni;
public class LiteWalletJni
{
public static native String initlogging();
public static native String initnew(final String serveruri, final String params, final String saplingOutputb64, final String saplingSpendb64);
public static native String initfromseed(final String serveruri, final String params, final String seed, final String birthday, final String saplingOutputb64, final String saplingSpendb64);
public static native String initfromb64(final String serveruri, final String params, final String datab64, final String saplingOutputb64, final String saplingSpendb64);
public static native String save();
public static native String execute(final String cmd, final String args);
public static native String getseedphrase();
public static native String getseedphrasefromentropyb64(final String entropby64);
public static native String checkseedphrase(final String input);
static {
System.loadLibrary("litewallet-jni");
}
}

View File

@ -0,0 +1,32 @@
package org.qortal.api.model.crosschain;
import io.swagger.v3.oas.annotations.media.Schema;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
@XmlAccessorType(XmlAccessType.FIELD)
public class PirateChainSendRequest {
@Schema(description = "32 bytes of entropy, Base58 encoded", example = "5oSXF53qENtdUyKhqSxYzP57m6RhVFP9BJKRr9E5kRGV")
public String entropy58;
@Schema(description = "Recipient's Pirate Chain address", example = "zc...")
public String receivingAddress;
@Schema(description = "Amount of ARRR to send", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public long arrrAmount;
@Schema(description = "Transaction fee per byte (optional). Default is 0.00000100 ARRR (100 sats) per byte", example = "0.00000100", type = "number")
@XmlJavaTypeAdapter(value = org.qortal.api.AmountTypeAdapter.class)
public Long feePerByte;
@Schema(description = "Optional memo to include information for the recipient", example = "zc...")
public String memo;
public PirateChainSendRequest() {
}
}

View File

@ -0,0 +1,229 @@
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.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
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.PirateChainSendRequest;
import org.qortal.crosschain.ForeignBlockchainException;
import org.qortal.crosschain.PirateChain;
import org.qortal.crosschain.SimpleTransaction;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.HeaderParam;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import java.util.List;
@Path("/crosschain/arrr")
@Tag(name = "Cross-Chain (Pirate Chain)")
public class CrossChainPirateChainResource {
@Context
HttpServletRequest request;
@POST
@Path("/walletbalance")
@Operation(
summary = "Returns ARRR balance",
description = "Supply 32 bytes of entropy, Base58 encoded",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
description = "32 bytes of entropy, Base58 encoded",
example = "5oSXF53qENtdUyKhqSxYzP57m6RhVFP9BJKRr9E5kRGV"
)
)
),
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})
@SecurityRequirement(name = "apiKey")
public String getPirateChainWalletBalance(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String entropy58) {
Security.checkApiCallAllowed(request);
PirateChain pirateChain = PirateChain.getInstance();
try {
Long balance = pirateChain.getWalletBalance(entropy58);
if (balance == null)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE);
return balance.toString();
} catch (ForeignBlockchainException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, e.getMessage());
}
}
@POST
@Path("/wallettransactions")
@Operation(
summary = "Returns transactions",
description = "Supply 32 bytes of entropy, Base58 encoded",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
description = "32 bytes of entropy, Base58 encoded",
example = "5oSXF53qENtdUyKhqSxYzP57m6RhVFP9BJKRr9E5kRGV"
)
)
),
responses = {
@ApiResponse(
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
)
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
@SecurityRequirement(name = "apiKey")
public List<SimpleTransaction> getPirateChainWalletTransactions(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String entropy58) {
Security.checkApiCallAllowed(request);
PirateChain pirateChain = PirateChain.getInstance();
try {
return pirateChain.getWalletTransactions(entropy58);
} catch (ForeignBlockchainException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, e.getMessage());
}
}
@POST
@Path("/send")
@Operation(
summary = "Sends ARRR from wallet",
description = "Currently supports 'legacy' P2PKH PirateChain addresses and Native SegWit (P2WPKH) addresses. 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 = "32 bytes of entropy, Base58 encoded",
example = "5oSXF53qENtdUyKhqSxYzP57m6RhVFP9BJKRr9E5kRGV"
)
)
),
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})
@SecurityRequirement(name = "apiKey")
public String sendBitcoin(@HeaderParam(Security.API_KEY_HEADER) String apiKey, PirateChainSendRequest pirateChainSendRequest) {
Security.checkApiCallAllowed(request);
if (pirateChainSendRequest.arrrAmount <= 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
if (pirateChainSendRequest.feePerByte != null && pirateChainSendRequest.feePerByte <= 0)
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_CRITERIA);
PirateChain pirateChain = PirateChain.getInstance();
try {
return pirateChain.sendCoins(pirateChainSendRequest);
} catch (ForeignBlockchainException e) {
// TODO
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, e.getMessage());
}
}
@POST
@Path("/walletaddress")
@Operation(
summary = "Returns main wallet address",
description = "Supply 32 bytes of entropy, Base58 encoded",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
description = "32 bytes of entropy, Base58 encoded",
example = "5oSXF53qENtdUyKhqSxYzP57m6RhVFP9BJKRr9E5kRGV"
)
)
),
responses = {
@ApiResponse(
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
)
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
@SecurityRequirement(name = "apiKey")
public String getPirateChainWalletAddress(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String entropy58) {
Security.checkApiCallAllowed(request);
PirateChain pirateChain = PirateChain.getInstance();
try {
return pirateChain.getWalletAddress(entropy58);
} catch (ForeignBlockchainException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, e.getMessage());
}
}
@POST
@Path("/syncstatus")
@Operation(
summary = "Returns synchronization status",
description = "Supply 32 bytes of entropy, Base58 encoded",
requestBody = @RequestBody(
required = true,
content = @Content(
mediaType = MediaType.TEXT_PLAIN,
schema = @Schema(
type = "string",
description = "32 bytes of entropy, Base58 encoded",
example = "5oSXF53qENtdUyKhqSxYzP57m6RhVFP9BJKRr9E5kRGV"
)
)
),
responses = {
@ApiResponse(
content = @Content(array = @ArraySchema( schema = @Schema( implementation = SimpleTransaction.class ) ) )
)
}
)
@ApiErrors({ApiError.INVALID_PRIVATE_KEY, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE})
@SecurityRequirement(name = "apiKey")
public String getPirateChainSyncStatus(@HeaderParam(Security.API_KEY_HEADER) String apiKey, String entropy58) {
Security.checkApiCallAllowed(request);
PirateChain pirateChain = PirateChain.getInstance();
try {
return pirateChain.getSyncStatus(entropy58);
} catch (ForeignBlockchainException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.FOREIGN_BLOCKCHAIN_NETWORK_ISSUE, e.getMessage());
}
}
}

View File

@ -441,6 +441,9 @@ public class Controller extends Thread {
AutoUpdate.getInstance().start();
}
LOGGER.info("Starting wallets");
PirateChainWalletController.getInstance().start();
LOGGER.info(String.format("Starting API on port %d", Settings.getInstance().getApiPort()));
try {
ApiService apiService = ApiService.getInstance();
@ -810,6 +813,9 @@ public class Controller extends Thread {
LOGGER.info("Shutting down API");
ApiService.getInstance().stop();
LOGGER.info("Shutting down wallets");
PirateChainWalletController.getInstance().shutdown();
if (Settings.getInstance().isAutoUpdateEnabled()) {
LOGGER.info("Shutting down auto-update");
AutoUpdate.getInstance().shutdown();

View File

@ -0,0 +1,189 @@
package org.qortal.controller;
import com.rust.litewalletjni.LiteWalletJni;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import org.qortal.crosschain.ForeignBlockchainException;
import org.qortal.crosschain.PirateWallet;
import org.qortal.utils.Base58;
import org.qortal.utils.NTP;
import java.io.IOException;
import java.util.Objects;
public class PirateChainWalletController extends Thread {
protected static final Logger LOGGER = LogManager.getLogger(PirateChainWalletController.class);
private static PirateChainWalletController instance;
final private static long SAVE_INTERVAL = 60 * 60 * 1000L; // 1 hour
private long lastSaveTime = 0L;
private boolean running;
private PirateWallet currentWallet = null;
private PirateChainWalletController() {
this.running = true;
}
public static PirateChainWalletController getInstance() {
if (instance == null)
instance = new PirateChainWalletController();
return instance;
}
@Override
public void run() {
Thread.currentThread().setName("Pirate Chain Wallet Controller");
try {
while (running && !Controller.isStopping()) {
Thread.sleep(1000);
if (this.currentWallet == null) {
// Nothing to do yet
continue;
}
LOGGER.debug("Syncing Pirate Chain wallet...");
String response = LiteWalletJni.execute("sync", "");
LOGGER.debug("sync response: {}", response);
JSONObject json = new JSONObject(response);
if (json.has("result")) {
String result = json.getString("result");
// We may have to set wallet to ready if this is the first ever successful sync
if (Objects.equals(result, "success")) {
this.currentWallet.setReady(true);
}
}
// Rate limit sync attempts
Thread.sleep(60000);
// Save wallet if needed
Long now = NTP.getTime();
if (now != null && now-SAVE_INTERVAL >= this.lastSaveTime) {
this.saveCurrentWallet();
}
}
} catch (InterruptedException e) {
// Fall-through to exit
}
}
public void shutdown() {
// Save the wallet
this.saveCurrentWallet();
this.running = false;
this.interrupt();
}
public boolean initWithEntropy58(String entropy58) throws ForeignBlockchainException {
byte[] entropyBytes = Base58.decode(entropy58);
if (entropyBytes == null || entropyBytes.length != 32) {
LOGGER.info("Invalid entropy bytes");
return false;
}
if (this.currentWallet != null) {
if (this.currentWallet.entropyBytesEqual(entropyBytes)) {
// Wallet already active - nothing to do
return true;
}
else {
// Different wallet requested - close the existing one and switch over
this.closeCurrentWallet();
}
}
try {
this.currentWallet = new PirateWallet(entropyBytes);
return true;
} catch (IOException e) {
LOGGER.info("Unable to initialize wallet: {}", e.getMessage());
}
return false;
}
private void saveCurrentWallet() {
if (this.currentWallet == null) {
// Nothing to do
return;
}
try {
if (this.currentWallet.save()) {
Long now = NTP.getTime();
if (now != null) {
this.lastSaveTime = now;
}
}
} catch (IOException e) {
LOGGER.info("Unable to save wallet");
}
}
public PirateWallet getCurrentWallet() {
return this.currentWallet;
}
private void closeCurrentWallet() {
this.saveCurrentWallet();
this.currentWallet = null;
}
public void ensureInitialized() throws ForeignBlockchainException {
if (this.currentWallet == null || !this.currentWallet.isInitialized()) {
throw new ForeignBlockchainException("Unable to initialize Pirate wallet");
}
}
public void ensureSynchronized() throws ForeignBlockchainException {
String response = LiteWalletJni.execute("syncStatus", "");
JSONObject json = new JSONObject(response);
if (json.has("syncing")) {
boolean isSyncing = Boolean.valueOf(json.getString("syncing"));
if (isSyncing) {
long syncedBlocks = json.getLong("synced_blocks");
long totalBlocks = json.getLong("total_blocks");
throw new ForeignBlockchainException(String.format("Sync in progress (%d / %d). Please try again later.", syncedBlocks, totalBlocks));
}
}
}
public String getSyncStatus() {
// TODO: check library exists, and show status of download if not
if (this.currentWallet == null || !this.currentWallet.isInitialized()) {
return "Not initialized yet";
}
String syncStatusResponse = LiteWalletJni.execute("syncStatus", "");
org.json.JSONObject json = new JSONObject(syncStatusResponse);
if (json.has("syncing")) {
boolean isSyncing = Boolean.valueOf(json.getString("syncing"));
if (isSyncing) {
long syncedBlocks = json.getLong("synced_blocks");
long totalBlocks = json.getLong("total_blocks");
return String.format("Sync in progress (%d / %d)", syncedBlocks, totalBlocks);
}
}
boolean isSynchronized = this.currentWallet.isSynchronized();
if (isSynchronized) {
return "Synchronized";
}
return "Not synchronized yet";
}
}

View File

@ -1,16 +1,21 @@
package org.qortal.crosschain;
import cash.z.wallet.sdk.rpc.CompactFormats;
import com.rust.litewalletjni.LiteWalletJni;
import org.bitcoinj.core.Coin;
import org.bitcoinj.core.Context;
import org.bitcoinj.core.NetworkParameters;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.libdohj.params.LitecoinMainNetParams;
import org.libdohj.params.LitecoinRegTestParams;
import org.libdohj.params.LitecoinTestNet3Params;
import org.qortal.api.model.crosschain.PirateChainSendRequest;
import org.qortal.controller.PirateChainWalletController;
import org.qortal.crosschain.PirateLightClient.Server;
import org.qortal.crosschain.PirateLightClient.Server.ConnectionType;
import org.qortal.settings.Settings;
import java.util.*;
public class PirateChain extends Bitcoiny {
@ -206,4 +211,136 @@ public class PirateChain extends Bitcoiny {
return this.blockchainProvider.getCompactBlocks(startHeight, count);
}
public Long getWalletBalance(String entropy58) throws ForeignBlockchainException {
synchronized (this) {
PirateChainWalletController walletController = PirateChainWalletController.getInstance();
walletController.initWithEntropy58(entropy58);
walletController.ensureInitialized();
walletController.ensureSynchronized();
// Get balance
String response = LiteWalletJni.execute("balance", "");
JSONObject json = new JSONObject(response);
if (json.has("zbalance")) {
return json.getLong("zbalance");
}
throw new ForeignBlockchainException("Unable to determine balance");
}
}
public List<SimpleTransaction> getWalletTransactions(String entropy58) throws ForeignBlockchainException {
synchronized (this) {
PirateChainWalletController walletController = PirateChainWalletController.getInstance();
walletController.initWithEntropy58(entropy58);
walletController.ensureInitialized();
walletController.ensureSynchronized();
List<SimpleTransaction> transactions = new ArrayList<>();
// Get transactions list
String response = LiteWalletJni.execute("list", "");
JSONArray transactionsJson = new JSONArray(response);
if (transactionsJson != null) {
for (int i = 0; i < transactionsJson.length(); i++) {
JSONObject transactionJson = transactionsJson.getJSONObject(i);
if (transactionJson.has("txid")) {
String txId = transactionJson.getString("txid");
Long timestamp = transactionJson.getLong("datetime");
Long amount = transactionJson.getLong("amount");
Long fee = transactionJson.getLong("fee");
if (transactionJson.has("incoming_metadata")) {
JSONArray incomingMetadatas = transactionJson.getJSONArray("incoming_metadata");
if (incomingMetadatas != null) {
for (int j = 0; j < incomingMetadatas.length(); j++) {
JSONObject incomingMetadata = incomingMetadatas.getJSONObject(i);
if (incomingMetadata.has("value")) {
//String address = incomingMetadata.getString("address");
Long value = incomingMetadata.getLong("value");
//String memo = incomingMetadata.getString("memo");
amount = value; // TODO: figure out how to parse transactions with multiple incomingMetadata entries
}
}
}
}
// TODO: JSONArray outgoingMetadatas = transactionJson.getJSONArray("outgoing_metadata");
SimpleTransaction transaction = new SimpleTransaction(txId, Math.toIntExact(timestamp), amount, fee, null, null);
transactions.add(transaction);
}
}
}
return transactions;
}
}
public String getWalletAddress(String entropy58) throws ForeignBlockchainException {
synchronized (this) {
PirateChainWalletController walletController = PirateChainWalletController.getInstance();
walletController.initWithEntropy58(entropy58);
walletController.ensureInitialized();
walletController.ensureSynchronized();
return walletController.getCurrentWallet().getWalletAddress();
}
}
public String sendCoins(PirateChainSendRequest pirateChainSendRequest) throws ForeignBlockchainException {
PirateChainWalletController walletController = PirateChainWalletController.getInstance();
walletController.initWithEntropy58(pirateChainSendRequest.entropy58);
walletController.ensureInitialized();
walletController.ensureSynchronized();
// Unlock wallet
walletController.getCurrentWallet().unlock();
// Build spend
JSONObject txn = new JSONObject();
txn.put("input", walletController.getCurrentWallet().getWalletAddress());
//txn.put("fee", pirateChainSendRequest.feePerByte); // We likely need to specify total fee, instead of per byte
JSONObject output = new JSONObject();
output.put("address", pirateChainSendRequest.receivingAddress);
output.put("amount", pirateChainSendRequest.arrrAmount);
output.put("memo", pirateChainSendRequest.memo);
JSONArray outputs = new JSONArray();
outputs.put(output);
txn.put("output", outputs);
String txnString = txn.toString();
// Send the coins
String response = LiteWalletJni.execute("send", txnString);
JSONObject json = new JSONObject(response);
try {
if (json.has("txid")) { // Success
return json.getString("txid");
}
else if (json.has("error")) {
String error = json.getString("error");
throw new ForeignBlockchainException(error);
}
} catch (JSONException e) {
throw new ForeignBlockchainException(e.getMessage());
}
throw new ForeignBlockchainException("Something went wrong");
}
public String getSyncStatus(String entropy58) throws ForeignBlockchainException {
synchronized (this) {
PirateChainWalletController walletController = PirateChainWalletController.getInstance();
walletController.initWithEntropy58(entropy58);
return walletController.getSyncStatus();
}
}
}

View File

@ -0,0 +1,334 @@
package org.qortal.crosschain;
import com.google.common.io.Resources;
import com.rust.litewalletjni.LiteWalletJni;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.bouncycastle.util.encoders.Base64;
import org.bouncycastle.util.encoders.DecoderException;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.qortal.crypto.Crypto;
import org.qortal.utils.Base58;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.Objects;
public class PirateWallet {
protected static final Logger LOGGER = LogManager.getLogger(PirateWallet.class);
private byte[] entropyBytes;
private boolean ready = false;
private final String params;
private final String saplingOutput64;
private final String saplingSpend64;
private static String SERVER_URI = "https://lightd.pirate.black:443/";
private static String COIN_PARAMS_RESOURCE = "piratechain/coinparams.json";
private static String SAPLING_OUTPUT_RESOURCE = "piratechain/saplingoutput_base64";
private static String SAPLING_SPEND_RESOURCE = "piratechain/saplingspend_base64";
public PirateWallet(byte[] entropyBytes) throws IOException {
this.entropyBytes = entropyBytes;
final URL paramsUrl = Resources.getResource(COIN_PARAMS_RESOURCE);
this.params = Resources.toString(paramsUrl, StandardCharsets.UTF_8);
final URL saplingOutput64Url = Resources.getResource(SAPLING_OUTPUT_RESOURCE);
this.saplingOutput64 = Resources.toString(saplingOutput64Url, StandardCharsets.ISO_8859_1);
final URL saplingSpend64Url = Resources.getResource(SAPLING_SPEND_RESOURCE);
this.saplingSpend64 = Resources.toString(saplingSpend64Url, StandardCharsets.ISO_8859_1);
this.ready = this.initialize();
}
private boolean initialize() {
try {
LiteWalletJni.initlogging();
String wallet = this.load();
if (wallet == null) {
// Wallet doesn't exist, so create a new one
// Pirate library uses base64 encoding
String entropy64 = Base64.toBase64String(this.entropyBytes);
// Derive seed phrase from entropy bytes
String inputSeedResponse = LiteWalletJni.getseedphrasefromentropyb64(entropy64);
JSONObject inputSeedJson = new JSONObject(inputSeedResponse);
String inputSeedPhrase = null;
if (inputSeedJson.has("seedPhrase")) {
inputSeedPhrase = inputSeedJson.getString("seedPhrase");
}
// Initialize new wallet
String outputSeedResponse = LiteWalletJni.initfromseed(SERVER_URI, this.params, inputSeedPhrase, "1886500", this.saplingOutput64, this.saplingSpend64); // Thread-safe.
JSONObject outputSeedJson = new JSONObject(outputSeedResponse);
String outputSeedPhrase = null;
if (outputSeedJson.has("seedPhrase")) {
outputSeedJson.getString("seedPhrase");
}
// Ensure seed phrase in response matches supplied seed phrase
if (inputSeedPhrase == null || !Objects.equals(inputSeedPhrase, outputSeedPhrase)) {
LOGGER.info("Unable to initialize Pirate Chain wallet: seed phrases do not match, or are null");
return false;
}
} else {
// Restore existing wallet
String walletSeed = LiteWalletJni.initfromb64(SERVER_URI, params, wallet, saplingOutput64, saplingSpend64);
}
// Check that we're able to communicate with the library
Integer ourHeight = this.getHeight();
return (ourHeight != null && ourHeight > 0);
} catch (IOException | JSONException e) {
LOGGER.info("Unable to initialize Pirate Chain wallet: {}", e.getMessage());
}
return false;
}
public void setReady(boolean ready) {
this.ready = ready;
}
public boolean entropyBytesEqual(byte[] testEntropyBytes) {
return Arrays.equals(testEntropyBytes, this.entropyBytes);
}
private void encrypt() {
if (this.isEncrypted()) {
// Nothing to do
return;
}
String encryptionKey = this.getEncryptionKey();
if (encryptionKey == null) {
// Can't encrypt without a key
return;
}
this.doEncrypt(encryptionKey);
}
private void decrypt() {
if (!this.isEncrypted()) {
// Nothing to do
return;
}
String encryptionKey = this.getEncryptionKey();
if (encryptionKey == null) {
// Can't encrypt without a key
return;
}
this.doDecrypt(encryptionKey);
}
public void unlock() {
if (!this.isEncrypted()) {
// Nothing to do
return;
}
String encryptionKey = this.getEncryptionKey();
if (encryptionKey == null) {
// Can't encrypt without a key
return;
}
this.doUnlock(encryptionKey);
}
public boolean save() throws IOException {
if (!isInitialized()) {
LOGGER.info("Error: can't save wallet, because no wallet it initialized");
return false;
}
// Encrypt first (will do nothing if already encrypted)
this.encrypt();
String wallet64 = LiteWalletJni.save();
byte[] wallet;
try {
wallet = Base64.decode(wallet64);
}
catch (DecoderException e) {
LOGGER.info("Unable to decode wallet");
return false;
}
if (wallet == null) {
LOGGER.info("Unable to save wallet");
return false;
}
Path walletPath = this.getCurrentWalletPath();
Files.createDirectories(walletPath.getParent());
Files.write(walletPath, wallet, StandardOpenOption.CREATE);
LOGGER.debug("Saved Pirate Chain wallet");
return true;
}
public String load() throws IOException {
Path walletPath = this.getCurrentWalletPath();
if (!Files.exists(walletPath)) {
return null;
}
byte[] wallet = Files.readAllBytes(walletPath);
if (wallet == null) {
return null;
}
String wallet64 = Base64.toBase64String(wallet);
return wallet64;
}
private String getEntropyHash58() {
if (this.entropyBytes == null) {
return null;
}
byte[] entropyHash = Crypto.digest(this.entropyBytes);
return Base58.encode(entropyHash);
}
private String getEncryptionKey() {
if (this.entropyBytes == null) {
return null;
}
// Prefix the bytes with a (deterministic) string, to ensure that the resulting hash is different
String prefix = "ARRRWalletEncryption";
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
try {
outputStream.write(prefix.getBytes(StandardCharsets.UTF_8));
outputStream.write(this.entropyBytes);
} catch (IOException e) {
return null;
}
byte[] encryptionKeyHash = Crypto.digest(outputStream.toByteArray());
return Base58.encode(encryptionKeyHash);
}
private Path getCurrentWalletPath() {
String entropyHash58 = this.getEntropyHash58();
String filename = String.format("wallet-%s.dat", entropyHash58);
return Paths.get("wallets", "PirateChain", filename);
}
public boolean isInitialized() {
return this.entropyBytes != null && this.ready;
}
public boolean isSynchronized() {
Integer height = this.getHeight();
Integer chainTip = this.getChainTip();
if (height == null || chainTip == null) {
return false;
}
// Assume synchronized if within 2 blocks of the chain tip
return height >= (chainTip - 2);
}
// APIs
public Integer getHeight() {
String response = LiteWalletJni.execute("height", "");
JSONObject json = new JSONObject(response);
if (json.has("height")) {
return json.getInt("height");
}
return null;
}
public Integer getChainTip() {
String response = LiteWalletJni.execute("info", "");
JSONObject json = new JSONObject(response);
if (json.has("latest_block_height")) {
return json.getInt("latest_block_height");
}
return null;
}
public Boolean isEncrypted() {
String response = LiteWalletJni.execute("encryptionstatus", "");
JSONObject json = new JSONObject(response);
if (json.has("encrypted")) {
return json.getBoolean("encrypted");
}
return null;
}
public boolean doEncrypt(String key) {
String response = LiteWalletJni.execute("encrypt", key);
JSONObject json = new JSONObject(response);
String result = json.getString("result");
if (json.has("result")) {
return (Objects.equals(result, "success"));
}
return false;
}
public boolean doDecrypt(String key) {
String response = LiteWalletJni.execute("decrypt", key);
JSONObject json = new JSONObject(response);
String result = json.getString("result");
if (json.has("result")) {
return (Objects.equals(result, "success"));
}
return false;
}
public boolean doUnlock(String key) {
String response = LiteWalletJni.execute("unlock", key);
JSONObject json = new JSONObject(response);
String result = json.getString("result");
if (json.has("result")) {
return (Objects.equals(result, "success"));
}
return false;
}
public String getWalletAddress() {
// Get balance, which also contains wallet addresses
String response = LiteWalletJni.execute("balance", "");
JSONObject json = new JSONObject(response);
String address = null;
if (json.has("z_addresses")) {
JSONArray z_addresses = json.getJSONArray("z_addresses");
if (z_addresses != null && !z_addresses.isEmpty()) {
JSONObject firstAddress = z_addresses.getJSONObject(0);
if (firstAddress.has("address")) {
address = firstAddress.getString("address");
}
}
}
return address;
}
}

View File

@ -0,0 +1,8 @@
{
"coin_type": "141",
"hrp_sapling_extended_spending_key": "secret-extended-key-main",
"hrp_sapling_extended_full_viewing_key": "zxviews",
"hrp_sapling_payment_address": "zs",
"b58_pubkey_address_prefix": "1cb8",
"b58_script_address_prefix": "1cbd"
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long