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:
parent
184984c16f
commit
d073b9da65
1
.gitignore
vendored
1
.gitignore
vendored
@ -28,6 +28,7 @@
|
||||
/WindowsInstaller/Install Files/qortal.jar
|
||||
/*.7z
|
||||
/tmp
|
||||
/wallets
|
||||
/data*
|
||||
/src/test/resources/arbitrary/*/.qortal/cache
|
||||
apikey.txt
|
||||
|
64
src/main/java/com/rust/litewalletjni/LiteWalletJni.java
Normal file
64
src/main/java/com/rust/litewalletjni/LiteWalletJni.java
Normal 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");
|
||||
}
|
||||
|
||||
}
|
@ -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() {
|
||||
}
|
||||
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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";
|
||||
}
|
||||
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
334
src/main/java/org/qortal/crosschain/PirateWallet.java
Normal file
334
src/main/java/org/qortal/crosschain/PirateWallet.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
8
src/main/resources/piratechain/coinparams.json
Normal file
8
src/main/resources/piratechain/coinparams.json
Normal 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"
|
||||
}
|
1
src/main/resources/piratechain/saplingoutput_base64
Normal file
1
src/main/resources/piratechain/saplingoutput_base64
Normal file
File diff suppressed because one or more lines are too long
1
src/main/resources/piratechain/saplingspend_base64
Normal file
1
src/main/resources/piratechain/saplingspend_base64
Normal file
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user