From 4665d318dd9174e49a17adeba636ae7e8a54d1d8 Mon Sep 17 00:00:00 2001 From: akibabu Date: Fri, 29 Mar 2019 20:17:51 +0900 Subject: [PATCH] move to public repo --- .gitignore | 5 + LICENSE | 21 + README.md | 211 ++++++ build.gradle | 31 + gradle.properties | 21 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54329 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 172 +++++ gradlew.bat | 84 +++ settings.gradle | 1 + slpwallet/.gitignore | 2 + slpwallet/build.gradle | 85 +++ slpwallet/proguard-rules.pro | 21 + slpwallet/src/main/AndroidManifest.xml | 6 + .../main/java/com/bitcoin/wallet/Network.kt | 15 + .../main/java/com/bitcoin/wallet/SLPWallet.kt | 96 +++ .../com/bitcoin/wallet/SLPWalletConfig.kt | 5 + .../java/com/bitcoin/wallet/TaskScheduler.kt | 152 ++++ .../java/com/bitcoin/wallet/WalletBalance.kt | 9 + .../java/com/bitcoin/wallet/WalletService.kt | 32 + .../com/bitcoin/wallet/WalletServiceImpl.kt | 173 +++++ .../com/bitcoin/wallet/address/Address.kt | 70 ++ .../com/bitcoin/wallet/address/AddressCash.kt | 42 ++ .../wallet/address/AddressFormatException.kt | 6 + .../bitcoin/wallet/address/AddressLegacy.kt | 29 + .../com/bitcoin/wallet/address/AddressSLP.kt | 42 ++ .../bitcoin/wallet/address/AddressType.java | 31 + .../bitcoin/wallet/address/KeyAddressPair.kt | 8 + .../wallet/bitcoinj/AddressCashUtil.java | 424 +++++++++++ .../wallet/bitcoinj/AddressCashValidator.java | 55 ++ .../com/bitcoin/wallet/bitcoinj/Mnemonic.kt | 49 ++ .../wallet/bitcoinj/NetworkInstance.kt | 15 + .../wallet/encoding/Base58CheckEncoding.kt | 42 ++ .../com/bitcoin/wallet/encoding/ByteUtils.kt | 48 ++ .../com/bitcoin/wallet/encoding/Sha256Hash.kt | 27 + .../com/bitcoin/wallet/persistence/DaoBase.kt | 17 + .../wallet/persistence/SlpTokenBalance.kt | 15 + .../wallet/persistence/SlpTokenBalanceDao.kt | 23 + .../wallet/persistence/TypeConverters.kt | 32 + .../wallet/persistence/WalletDatabase.kt | 22 + .../wallet/persistence/WalletDatabaseImpl.kt | 57 ++ .../wallet/presentation/BalanceInfo.kt | 11 + .../wallet/presentation/BalanceInfoImpl.kt | 24 + .../bitcoin/wallet/presentation/Blockie.kt | 8 + .../wallet/presentation/ProgressTask.kt | 30 + .../wallet/presentation/TokenNumberFormat.kt | 24 + .../wallet/rest/AddressUtxosRequest.kt | 13 + .../wallet/rest/AddressUtxosResponse.kt | 15 + .../bitcoin/wallet/rest/BitcoinRestClient.kt | 39 + .../java/com/bitcoin/wallet/rest/Retrofit.kt | 47 ++ .../wallet/rest/SlpValidateTxRequest.kt | 8 + .../wallet/rest/SlpValidateTxResponse.kt | 8 + .../bitcoin/wallet/rest/TxDetailsRequest.kt | 8 + .../com/bitcoin/wallet/rest/TxResponse.kt | 16 + .../com/bitcoin/wallet/slp/SLPWalletImpl.kt | 183 +++++ .../com/bitcoin/wallet/slp/SlpOpReturn.kt | 65 ++ .../bitcoin/wallet/slp/SlpOpReturnGenesis.kt | 37 + .../com/bitcoin/wallet/slp/SlpOpReturnMint.kt | 26 + .../com/bitcoin/wallet/slp/SlpOpReturnSend.kt | 59 ++ .../com/bitcoin/wallet/slp/SlpTokenDetails.kt | 41 ++ .../bitcoin/wallet/slp/SlpTokenDetailsDao.kt | 16 + .../wallet/slp/SlpTokenDetailsFacade.kt | 55 ++ .../java/com/bitcoin/wallet/slp/SlpTokenId.kt | 25 + .../com/bitcoin/wallet/slp/SlpTokenType.kt | 28 + .../bitcoin/wallet/slp/SlpTransactionType.kt | 26 + .../java/com/bitcoin/wallet/slp/SlpUtxo.kt | 16 + .../java/com/bitcoin/wallet/slp/SlpUtxoDao.kt | 22 + .../java/com/bitcoin/wallet/slp/SlpValidTx.kt | 15 + .../com/bitcoin/wallet/slp/SlpValidTxDao.kt | 16 + .../main/java/com/bitcoin/wallet/tx/Script.kt | 23 + .../java/com/bitcoin/wallet/tx/TxBuilder.kt | 94 +++ .../main/java/com/bitcoin/wallet/tx/Utxo.kt | 22 + .../java/com/bitcoin/wallet/tx/UtxoDao.kt | 22 + .../java/com/bitcoin/wallet/tx/UtxoFacade.kt | 215 ++++++ .../bitcoin/wallet/util/SingletonHolder.kt | 25 + .../com/bitcoin/wallet/util/TaskScheduler.kt | 121 +++ slpwallet/src/main/res/values/strings.xml | 5 + .../bitcoin/wallet/WalletDatabaseInMemory.kt | 148 ++++ .../bitcoin/wallet/WalletServiceImplTest.kt | 163 ++++ .../bitcoin/wallet/address/AddressCashTest.kt | 55 ++ .../bitcoin/wallet/address/AddressSLPTest.kt | 57 ++ .../bitcoin/wallet/bitcoinj/MnemonicTest.kt | 33 + .../wallet/rest/BitcoinRestClientMock.kt | 61 ++ .../rest/BitcoinRestClientMockAarAngBch.kt | 60 ++ .../rest/BitcoinRestClientMockAarBch.kt | 54 ++ .../com/bitcoin/wallet/slp/SlpOpReturnTest.kt | 90 +++ .../bitcoin/wallet/slp/SlpTokenDetailsTest.kt | 63 ++ .../src/test/resources/rest_txdetails.json | 693 ++++++++++++++++++ .../resources/rest_txdetails_aar_ang_bch.json | 580 +++++++++++++++ .../resources/rest_txdetails_aar_bch.json | 508 +++++++++++++ .../resources/rest_txdetails_aar_genesis.json | 87 +++ .../resources/rest_txdetails_ang_genesis.json | 87 +++ .../resources/rest_txdetails_xrp_genesis.json | 74 ++ slpwallet/src/test/resources/rest_utxos.json | 41 ++ .../resources/rest_utxos_aar_ang_bch.json | 33 + .../test/resources/rest_utxos_aar_bch.json | 25 + 96 files changed, 6522 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 slpwallet/.gitignore create mode 100644 slpwallet/build.gradle create mode 100644 slpwallet/proguard-rules.pro create mode 100644 slpwallet/src/main/AndroidManifest.xml create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/Network.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/SLPWallet.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/SLPWalletConfig.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/TaskScheduler.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/WalletBalance.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/WalletService.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/WalletServiceImpl.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/Address.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/AddressCash.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/AddressFormatException.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/AddressLegacy.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/AddressSLP.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/AddressType.java create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/address/KeyAddressPair.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashUtil.java create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashValidator.java create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/Mnemonic.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/NetworkInstance.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/encoding/Base58CheckEncoding.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/encoding/ByteUtils.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/encoding/Sha256Hash.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/persistence/DaoBase.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalance.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalanceDao.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/persistence/TypeConverters.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabase.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabaseImpl.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfo.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfoImpl.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/presentation/Blockie.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/presentation/ProgressTask.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/presentation/TokenNumberFormat.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosRequest.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosResponse.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/BitcoinRestClient.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/Retrofit.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxRequest.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxResponse.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/TxDetailsRequest.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/rest/TxResponse.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SLPWalletImpl.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturn.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnGenesis.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnMint.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnSend.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetails.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsDao.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsFacade.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenId.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenType.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTransactionType.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxo.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxoDao.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTx.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTxDao.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/tx/Script.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/tx/TxBuilder.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/tx/Utxo.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoDao.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoFacade.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/util/SingletonHolder.kt create mode 100644 slpwallet/src/main/java/com/bitcoin/wallet/util/TaskScheduler.kt create mode 100644 slpwallet/src/main/res/values/strings.xml create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/WalletDatabaseInMemory.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/WalletServiceImplTest.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/address/AddressCashTest.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/address/AddressSLPTest.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/bitcoinj/MnemonicTest.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMock.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarAngBch.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarBch.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpOpReturnTest.kt create mode 100644 slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpTokenDetailsTest.kt create mode 100644 slpwallet/src/test/resources/rest_txdetails.json create mode 100644 slpwallet/src/test/resources/rest_txdetails_aar_ang_bch.json create mode 100644 slpwallet/src/test/resources/rest_txdetails_aar_bch.json create mode 100644 slpwallet/src/test/resources/rest_txdetails_aar_genesis.json create mode 100644 slpwallet/src/test/resources/rest_txdetails_ang_genesis.json create mode 100644 slpwallet/src/test/resources/rest_txdetails_xrp_genesis.json create mode 100644 slpwallet/src/test/resources/rest_utxos.json create mode 100644 slpwallet/src/test/resources/rest_utxos_aar_ang_bch.json create mode 100644 slpwallet/src/test/resources/rest_utxos_aar_bch.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d8c23c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.iml +.gradle +.idea/ +/local.properties +/build diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c0e8c65 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 bitcoin portal + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..28ebf2d --- /dev/null +++ b/README.md @@ -0,0 +1,211 @@ +# SLPWallet Android SDK + +![Platform](https://img.shields.io/badge/platform-android-lightgrey.svg) +![License](https://img.shields.io/badge/License-MIT-black.svg) + +## Supported Android Versions +5.0+ + +### Warning +On Android versions prior to Android 6.0 Marshmallow, disabling the secure lock screen (reconfiguring it to None, Swipe, or another mode which does not authenicate the user) will have the following conquences: +- Loss of the BCH and tokens held at the wallet address. +- Loss of access to the private key that controls the BCH and tokens held at the wallet address. + + +Tokens and any extra BCH at the wallet address can only be recovered if the mnemonic has been previously backed up. + + +## Installation + +### Gradle +Add JitPack to the list of repositories in your top level `build.gradle` file for the project: +```groovy +allprojects { + repositories { + google() + jcenter() + maven { url 'https://jitpack.io' } // Add this repository + } +} +``` + +In the module 'build.gradle' file, add the dependency: +```groovy +dependencies { + // ... + + implementation 'com.github.Bitcoin-com:slp-wallet-sdk-android:0.4' + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' + +} +``` +Excluding guava is required to avoid conflicts. + +#### Binary compatibility +In the current version of the SDK, some items need to be removed for binary compatibility. + +Add these packaging options to your module `build.gradle`. +```groovy +android { + // ... + + packagingOptions { + exclude 'lib/x86_64/darwin/libscrypt.dylib' + exclude 'lib/x86_64/freebsd/libscrypt.so' + exclude 'lib/x86_64/linux/libscrypt.so' + } +} +``` + + +## Get Started + +```kotlin +import com.bitcoin.slpwallet.SLPWallet + +// Create a new wallet on mainnet, or load one previously created. +val slpWallet: SLPWallet = SLPWallet.loadOrCreate(context, Network.MAIN) + +val slpWalletFromPhrase: SLPWallet = SLPWallet.fromMnemonic( + context, + Network.MAIN + "rare genre crumble sport burger laugh lecture reject exhaust hello express pass" + ) + +// A wallet is created on mainnet if one does not exist already. +val slpWallet: SLPWallet = SLPWallet.getInstance(context) +``` + + +## Addresses + Mnemonic +The wallet resuses two addresses that shares mnemonic. + +* The SLP address on m/44'/245'/0'/0/0. +* The BCH address on m/44'/145'/0'/0/0. + +All BCH change will be sent to the BCH address while all token change is sent to the SLP address, separating the two if they were not already. This helps protect against accidental spending of BCH that contains SLP, by wallets are not aware of SLP, which would result in loss of coins. + +```kotlin +slpWallet.mnemonic // "rare", "genre", "crumble", "sport", "burger", "laugh", "lecture", "reject", "exhaust", "hello", "express", "pass" +slpWallet.slpAddress // simpleledger:qr6wa5eemn0fl3vghvk5cr480s3fqtgnevkaxny9x7 +slpWallet.bchAddress // bitcoincash:qr6wa5eemn0fl3vghvk5cr480s3fqtgnev6xdg39cq + +``` + +### Token and BCH Balances +The balances, including both tokens and BCH, are available as LiveData. +```kotlin +slpWallet.balance.observe(this, Observer { balanceList: List -> + var balances = "" + for (balance in balanceList) { + val nf = getTokenNumberFormat(balance.decimals, balance.ticker) + balances += "${nf.format(balance.amount)}\n" + } + balancesText.text = balances +}) +``` +The BCH balance item has an emtpy `tokenId` of `""`. + +```kotlin +interface BalanceInfo { + var tokenId: String + var amount: BigDecimal + var ticker: String? + var name: String? + var decimals: Int? +} +``` + +To refresh the current balance: + +```kotlin +slpWallet.refreshBalance() +``` + +## Send Token + +```kotlin +private val compositeDisposable = CompositeDisposable() + +// ... + +val tokenId = "73bf34eb6cd6879fc75b0e91ad82ef61a6bf2f10adb38a067a25b30f9a644cea" +val amount = BigDecimal(1) +val toAddress = "simpleledger:qpfp0tfafxfq52mdpperlyschmmh6scfgse80v7a4p" + +slpWallet.sendToken(tokenId, amount, toAddress) + .subscribeOn(Schedulers.io()) + .subscribe( + { txid: String -> + Timber.d("sendToken() was successful, with txid: $txid") + }, + { e: Throwable -> + Timber.e("Error when sending. $e") + } + ).addTo(compositeDisposable) +``` + The example above uses Rx, but the status of the send task is also available as LiveData: +```kotlin +slpWallet.sendStatus.observe(this, Observer { task: ProgressTask -> + var sendStatus = "" + when (task.status) { + TaskStatus.IDLE -> { + sendStatus = "" + } + TaskStatus.UNDERWAY -> { + sendStatus = "Sending..." + } + TaskStatus.SUCCESS -> { + sendStatus = "Sent tx ${task.result}" + } + TaskStatus.ERROR -> { + sendStatus = "Error. ${task.message}" + } + } + sendStatusText.text = sendStatus +}) +``` + +Once a send has been completed, you can reset the status to `IDLE`: +```kotlin +slpWallet.clearSendStatus() +``` + +## UI +Some convenience methods are included to make it easier to display tokens in your UI. + +### Formatting Amounts +This will display the amount to the full number of decimal places permitted by the coin, preceded by the ticker. +```kotlin +import com.bitcoin.slpwallet.getTokenNumberFormat + +val nf: NumberFormat = getTokenNumberFormat(decimals, ticker) +val text: String = nf.format(amount) // "AAR 123.45" +``` + +## Logging +This library uses [Timber](https://github.com/JakeWharton/timber) for logging, but does not plant it's own tree. Plant a tree like this when your application starts to see the logs: + +```kotlin +override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + + // ... +} +``` + +## Authors & Maintainers +- [akibabu](https://github.com/akibabu) +- [brendoncoin](https://github.com/brendoncoin) + + +## References +- [Simple Ledger Protocol (SLP)](https://github.com/simpleledger/slp-specifications/blob/master/slp-token-type-1.md) + +## License + +SLPWallet Android SDK is available under the MIT license. See the LICENSE file for more info. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..3aa162a --- /dev/null +++ b/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.21' + ext.lifecycle_version = "2.0.0" + ext.room_version = "1.1.1" + ext.retrofit_version = "2.5.0" + + repositories { + google() + jcenter() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.3.2' + classpath 'com.github.dcendents:android-maven-gradle-plugin:2.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.0.0" + } +} + +allprojects { + repositories { + google() + jcenter() + mavenCentral() + maven { url 'https://jitpack.io' } + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..23339e0 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..f6b961fd5a86aa5fbfe90f707c3138408be7c718 GIT binary patch literal 54329 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

<5G2M1WZP5 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 F{|7XC?P&l2 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..0b9b14f --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Mar 28 22:17:42 JST 2019 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e95643d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..4c88a86 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':slpwallet' diff --git a/slpwallet/.gitignore b/slpwallet/.gitignore new file mode 100644 index 0000000..65e6869 --- /dev/null +++ b/slpwallet/.gitignore @@ -0,0 +1,2 @@ +/build +src/test/java/com/bitcoin/ScratchPad.kt \ No newline at end of file diff --git a/slpwallet/build.gradle b/slpwallet/build.gradle new file mode 100644 index 0000000..abf0e9e --- /dev/null +++ b/slpwallet/build.gradle @@ -0,0 +1,85 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-android' +apply plugin: 'com.github.dcendents.android-maven' + +group = 'com.github.Bitcoin-com' + +sourceCompatibility = 1.8 + +android { + compileSdkVersion 28 + + splits { + abi { + enable true + reset() + include 'x86', 'x86_64', 'armeabi-v7a' + universalApk true + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 28 + versionCode 9 + versionName "0.9" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + testOptions { + unitTests.returnDefaultValues = true + unitTests.includeAndroidResources = true + } + +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1" + + // Android + implementation "android.arch.persistence.room:runtime:$room_version" + kapt "android.arch.persistence.room:compiler:$room_version" + + // Android X + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.0.0' + implementation 'androidx.appcompat:appcompat:1.1.0-alpha03' + + // 3rd party + implementation 'com.github.Bitcoin-com:securepreferences-android:1.0.0' + implementation "com.squareup.retrofit2:retrofit:$retrofit_version" + implementation "com.squareup.retrofit2:converter-gson:$retrofit_version" + implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit_version" + implementation 'io.reactivex.rxjava2:rxkotlin:2.3.0' + implementation('cash.bitcoinj:bitcoinj-core:0.14.5.2') { + exclude group: 'org.bitcoinj', module: 'orchid' + exclude group: 'com.squareup.okhttp', module: 'okhttp' + exclude group: 'net.jcip', module: 'jcip-annotations' + exclude group: 'com.google.code.findbugs', module: 'jsr305' + } + implementation 'com.jakewharton.timber:timber:4.7.1' + + // Test + testImplementation 'junit:junit:4.12' + testImplementation 'androidx.test:core:1.1.0' + testImplementation 'com.android.support.test:runner:1.0.2' + androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2' + androidTestImplementation "android.arch.persistence.room:testing:$room_version" + +} \ No newline at end of file diff --git a/slpwallet/proguard-rules.pro b/slpwallet/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/slpwallet/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/slpwallet/src/main/AndroidManifest.xml b/slpwallet/src/main/AndroidManifest.xml new file mode 100644 index 0000000..878e955 --- /dev/null +++ b/slpwallet/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/Network.kt b/slpwallet/src/main/java/com/bitcoin/wallet/Network.kt new file mode 100644 index 0000000..0211657 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/Network.kt @@ -0,0 +1,15 @@ +package com.bitcoin.wallet + +import com.bitcoin.wallet.bitcoinj.NetworkInstance + +/** + * @author akibabu + */ +enum class Network { + + MAIN, + TEST; + + internal val instance: NetworkInstance by lazy { NetworkInstance(this) } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/SLPWallet.kt b/slpwallet/src/main/java/com/bitcoin/wallet/SLPWallet.kt new file mode 100644 index 0000000..7cd28d9 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/SLPWallet.kt @@ -0,0 +1,96 @@ +package com.bitcoin.wallet + +import android.content.Context +import androidx.lifecycle.LiveData +import com.bitcoin.securepreferences.SecurePreferences +import com.bitcoin.wallet.bitcoinj.Mnemonic +import com.bitcoin.wallet.persistence.WalletDatabaseImpl +import com.bitcoin.wallet.presentation.BalanceInfo +import com.bitcoin.wallet.presentation.ProgressTask +import com.bitcoin.wallet.slp.SLPWalletImpl +import io.reactivex.Single +import timber.log.Timber +import java.math.BigDecimal +import java.util.concurrent.ConcurrentHashMap + +/** + * @author akibabu + */ +interface SLPWallet { + + val bchAddress: String + val slpAddress: String + val mnemonic: List + val balance: LiveData> + val sendStatus: LiveData> + + fun clearSendStatus() + fun refreshBalance() + fun sendToken(tokenId: String, amount: BigDecimal, toAddress: String): Single + + companion object { + private const val PREFS_NAMESPACE: String = "com.bitcoin.wallet.SLPWallet" + private const val PREFS_KEY_MNEMONIC: String = "mnemonic" + private const val WALLET_KEY = true + + // computeIfAbsent is not available in api 21 so we need extra synchronization below. See code for getOrPut + private val slpWallet: ConcurrentHashMap = ConcurrentHashMap() + + fun fromMnemonic(context: Context, network: Network, mnemonic: String, isNew: Boolean = true): SLPWallet { + return fromMnemonic(context, network, mnemonic.split(" "), isNew) + } + + fun fromMnemonic(context: Context, network: Network, mnemonic: List, isNew: Boolean = true): SLPWallet { + + // TODO: Check if mnemonic is valid + + if (isNew) { + val mnemonicPhrase: String = mnemonic.joinToString(" ") + val securePrefs = SecurePreferences(context, PREFS_NAMESPACE) + val prefsEditor: SecurePreferences.Editor = securePrefs.edit() + prefsEditor.putString(PREFS_KEY_MNEMONIC, mnemonicPhrase) + if (!prefsEditor.commit()) { + throw Exception("Failed to save mnemonic.") + } + } + + synchronized(slpWallet) { + return if (isNew) { + slpWallet[WALLET_KEY] = newWallet(context, network, mnemonic).clearDatabase() + slpWallet[WALLET_KEY]!! + } else { + slpWallet.getOrPut(WALLET_KEY) { newWallet(context, network, mnemonic) } + } + } + } + + private fun newWallet(context: Context, network: Network, mnemonic: List): SLPWalletImpl { + return SLPWalletImpl(network, Mnemonic(mnemonic), WalletDatabaseImpl.getInstance(context)) + } + + fun getInstance(context: Context, network: Network = Network.MAIN): SLPWallet { + synchronized(slpWallet) { + return slpWallet.getOrPut(WALLET_KEY) { loadOrCreate(context, network) } + } + } + + private fun loadOrCreate(context: Context, network: Network): SLPWallet { + var isNew = false + val securePrefs = SecurePreferences(context, PREFS_NAMESPACE) + var mnemonicPhrase: String? = null + try { + mnemonicPhrase = securePrefs.getString(PREFS_KEY_MNEMONIC) + } catch (e: Exception) { + Timber.e(e, "Exception when loading mnemonic.") + } + + if (mnemonicPhrase == null) { + val mnemonicList: List = Mnemonic.generate().mnemonic + mnemonicPhrase = mnemonicList.joinToString(" ") + isNew = true + } + return fromMnemonic(context, network, mnemonicPhrase, isNew) + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/SLPWalletConfig.kt b/slpwallet/src/main/java/com/bitcoin/wallet/SLPWalletConfig.kt new file mode 100644 index 0000000..b68e16b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/SLPWalletConfig.kt @@ -0,0 +1,5 @@ +package com.bitcoin.wallet + +object SLPWalletConfig { + var restAPIKey: String? = null +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/TaskScheduler.kt b/slpwallet/src/main/java/com/bitcoin/wallet/TaskScheduler.kt new file mode 100644 index 0000000..a38396f --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/TaskScheduler.kt @@ -0,0 +1,152 @@ +package com.bitcoin.wallet + +/* + * Copyright 2018 Google LLC + * + * 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 + * + * https://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. + */ + +import android.os.Handler +import android.os.Looper +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private const val NUMBER_OF_THREADS = 4 // TODO: Make this depend on device's hw + +interface Scheduler { + + fun execute(task: () -> Unit) + + fun postToMainThread(task: () -> Unit) + + fun postDelayedToMainThread(delay: Long, task: () -> Unit) +} + +/** + * A shim [Scheduler] that by default handles operations in the [AsyncScheduler]. + */ +object DefaultScheduler : Scheduler { + + private var delegate: Scheduler = AsyncScheduler + + /** + * Sets the new delegate scheduler, null to revert to the default async one. + */ + fun setDelegate(newDelegate: Scheduler?) { + delegate = newDelegate ?: AsyncScheduler + } + + override fun execute(task: () -> Unit) { + delegate.execute(task) + } + + override fun postToMainThread(task: () -> Unit) { + delegate.postToMainThread(task) + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + delegate.postDelayedToMainThread(delay, task) + } +} + +/** + * Runs tasks in a [ExecutorService] with a fixed thread of pools + */ +internal object AsyncScheduler : Scheduler { + + private val executorService: ExecutorService = Executors.newFixedThreadPool(NUMBER_OF_THREADS) + + override fun execute(task: () -> Unit) { + executorService.execute(task) + } + + override fun postToMainThread(task: () -> Unit) { + if (isMainThread()) { + task() + } else { + val mainThreadHandler = Handler(Looper.getMainLooper()) + mainThreadHandler.post(task) + } + } + + private fun isMainThread(): Boolean { + return Looper.getMainLooper().thread === Thread.currentThread() + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + val mainThreadHandler = Handler(Looper.getMainLooper()) + mainThreadHandler.postDelayed(task, delay) + } +} + +/** + * Runs tasks in a [ExecutorService] with a single thread + */ +object SerialScheduler : Scheduler { + + private val executorService: ExecutorService = Executors.newFixedThreadPool(1) + + override fun execute(task: () -> Unit) { + executorService.execute(task) + } + + override fun postToMainThread(task: () -> Unit) { + if (isMainThread()) { + task() + } else { + val mainThreadHandler = Handler(Looper.getMainLooper()) + mainThreadHandler.post(task) + } + } + + private fun isMainThread(): Boolean { + return Looper.getMainLooper().thread === Thread.currentThread() + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + val mainThreadHandler = Handler(Looper.getMainLooper()) + mainThreadHandler.postDelayed(task, delay) + } +} + + +/** + * Runs tasks synchronously. + */ +object SyncScheduler : Scheduler { + private val postDelayedTasks = mutableListOf<() -> Unit>() + + override fun execute(task: () -> Unit) { + task() + } + + override fun postToMainThread(task: () -> Unit) { + task() + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + postDelayedTasks.add(task) + } + + fun runAllScheduledPostDelayedTasks() { + val tasks = postDelayedTasks.toList() + clearScheduledPostdelayedTasks() + for (task in tasks) { + task() + } + } + + fun clearScheduledPostdelayedTasks() { + postDelayedTasks.clear() + } +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/WalletBalance.kt b/slpwallet/src/main/java/com/bitcoin/wallet/WalletBalance.kt new file mode 100644 index 0000000..3df7449 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/WalletBalance.kt @@ -0,0 +1,9 @@ +package com.bitcoin.wallet + +import com.bitcoin.wallet.slp.SlpTokenDetails +import java.math.BigDecimal + +/** + * @author akibabu + */ +internal data class WalletBalance(val nativeBalance: Long, val tokenBalance: Map) \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/WalletService.kt b/slpwallet/src/main/java/com/bitcoin/wallet/WalletService.kt new file mode 100644 index 0000000..4f7c7fc --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/WalletService.kt @@ -0,0 +1,32 @@ +package com.bitcoin.wallet + +import com.bitcoin.wallet.slp.SlpTokenId +import io.reactivex.Single +import java.math.BigDecimal + +/** + * @author akibabu + */ +internal interface WalletService { + + /** + * Get BCH and all SLP token balances + */ + fun refreshBalance(): Single + + + /** + * @param tokenId 32 byte hash of the token genesis transaction in hexadecimal. + * Example: 3257135d7Singlec351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503m + * + * @param toAddress SLP address format. + * Example: simpleledger:qzk92nt0xdxc9qy3yj53h9rjw8dk0s9cqqsrzm5cny + * + * @param numTokens Number of tokens in human readable format (not accounting for token decimal precision). + * Example: 134.12 + * + * @return txid if successful, or stack trace if error + */ + fun sendTokenRx(tokenId: SlpTokenId, amount: BigDecimal, toAddress: String): Single + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/WalletServiceImpl.kt b/slpwallet/src/main/java/com/bitcoin/wallet/WalletServiceImpl.kt new file mode 100644 index 0000000..4b720b9 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/WalletServiceImpl.kt @@ -0,0 +1,173 @@ +package com.bitcoin.wallet + +import com.bitcoin.wallet.address.AddressSLP +import com.bitcoin.wallet.persistence.WalletDatabase +import com.bitcoin.wallet.rest.BitcoinRestClient +import com.bitcoin.wallet.slp.* +import com.bitcoin.wallet.tx.TxBuilder +import com.bitcoin.wallet.tx.Utxo +import com.bitcoin.wallet.tx.UtxoFacade +import com.google.gson.JsonParser +import io.reactivex.Single +import timber.log.Timber +import java.math.BigDecimal + +/** + * @author akibabu + */ +internal class WalletServiceImpl(private val wallet: SLPWalletImpl, private val bitcoinClient: BitcoinRestClient, + database: WalletDatabase) : WalletService { + + constructor(wallet: SLPWalletImpl, database: WalletDatabase) : this(wallet, + BitcoinRestClient.getInstance(wallet.network), database) + + private val txBuilder = TxBuilder(wallet) + + private val tokenDetailsFacade = SlpTokenDetailsFacade(database.slpTokenDetailsDao(), bitcoinClient) + private val utxoFacade = UtxoFacade(wallet, bitcoinClient, database.utxoDao(), database.slpUtxoDao(), + database.slpValidTxDao()) + + override fun refreshBalance(): Single { + return Single.fromCallable { // Wrap for now to protect against blocking non reactive calls + val utxos = utxoFacade.getUtxos() + val slpGenesisTxIds = utxos.slpUtxos + .map { it.tokenId } + .toSet() + + val tokenDetails = tokenDetailsFacade.getTokenDetails(slpGenesisTxIds).blockingGet() + .groupBy { it.tokenId } + .mapValues { it.value[0] } + + val nativeBalance = utxos.bchUtxos.map { it.satoshi }.sum() + val slpBalance = utxos.slpUtxos + .groupBy { it.tokenId } + .mapKeys { tokenDetails.getValue(it.key) } + .mapValues { + val balance = it.value.map { it.numTokensRaw }.reduce(BigDecimal::add) + it.key.toReadableAmount(balance) + } + WalletBalance(nativeBalance, slpBalance) + } + } + + internal fun sendTokenUtxoSelection(tokenId: SlpTokenId, numTokens: BigDecimal): Single { + return Single.fromCallable { // Wrap for now to protect against blocking non reactive calls + val tokenDetailsList: List = tokenDetailsFacade.getTokenDetails(setOf(tokenId)) + .blockingGet() + val tokenDetails: SlpTokenDetails = tokenDetailsList[0] + val sendTokensRaw = tokenDetails.toRawAmount(numTokens) + var sendSatoshi = TxBuilder.DUST_LIMIT // At least one dust limit output to the token receiver + + val utxos = utxoFacade.getUtxos() + + // First select enough token utxo's and just take what we get in terms of BCH + var inputTokensRaw = ULong.MIN_VALUE + var inputSatoshi = 0L + val selectedUtxos = utxos.slpUtxos + .filter { it.tokenId == tokenId } + .sortedBy { it.numTokensRaw } + .takeWhile { + val amountTooLow = inputTokensRaw < sendTokensRaw + if (amountTooLow) { + inputTokensRaw += it.numTokensRaw.toLong().toULong() + inputSatoshi += (it.utxo.satoshi - 148) // Deduct input fee + } + amountTooLow + } + .map { it.utxo } + .toMutableList() + if (inputTokensRaw < sendTokensRaw) { + throw RuntimeException("insufficient token balance=$inputTokensRaw") + } else if (inputTokensRaw > sendTokensRaw) { + // If there's token change we need at least another dust limit worth of BCH + sendSatoshi += TxBuilder.DUST_LIMIT + } + + val propagationExtraFee = 50 // When too close 1sat/byte tx's don't propagate well + val numOutputs = 3 // Assume three outputs in addition to the op return. + val numQuanitites = 2 // Assume one token receiver and the token receiver + val fee = TxBuilder.outputFee(numOutputs) + SlpOpReturn.sizeInBytes(numQuanitites) + propagationExtraFee + + // If we can not yet afford the fee + dust limit to send, use pure BCH utxo's + selectedUtxos.addAll(utxos.bchUtxos + .sortedBy { it.satoshi } + .takeWhile { + val amountTooLow = inputSatoshi <= (sendSatoshi + fee) + if (amountTooLow) { + inputSatoshi += (it.satoshi - 148) // Deduct input fee + } + amountTooLow + }) + + val changeSatoshi = inputSatoshi - sendSatoshi - fee + if (changeSatoshi < 0) { + throw IllegalArgumentException("Insufficient BCH balance=$inputSatoshi required $sendSatoshi + fees") + } + + // We have enough tokens and BCH. Create the transaction + val quantities = mutableListOf(sendTokensRaw) + val changeTokens = inputTokensRaw - sendTokensRaw + if (changeTokens > 0u) { + quantities.add(changeTokens) + } + + SendTokenUtxoSelection(tokenId, quantities, changeSatoshi, selectedUtxos) + } + } + + internal data class SendTokenUtxoSelection( + val tokenId: SlpTokenId, val quantities: List, val changeSatoshi: Long, + val selectedUtxos: List + ) + + override fun sendTokenRx(tokenId: SlpTokenId, amount: BigDecimal, toAddress: String): Single { + return sendTokenUtxoSelection(tokenId, amount) + .map { + val toAddress = AddressSLP.parse(wallet.network, toAddress) // Validate as SLP address + + // Add OP RETURN and receiver output + val tx = txBuilder.Builder() + tx + .addOutput(SlpOpReturn.send(it.tokenId, it.quantities)) + .addOutput(TxBuilder.DUST_LIMIT, toAddress) + + // Send our token change back to our SLP address + if (it.quantities.size == 2) { + tx.addOutput(TxBuilder.DUST_LIMIT, wallet.slpKeyAddress.address) + } + + // Send our BCH change back to our BCH address + if (it.changeSatoshi >= TxBuilder.DUST_LIMIT) { + tx.addOutput(it.changeSatoshi, wallet.bchAddressKey.address) + } + + it.selectedUtxos.forEach { tx.addInput(it) } + + val hex = tx.serialize() + Timber.d("Broadcasting serialized tx $hex") + hex + } + .flatMap { + sendTx(it) + .doOnError { Timber.e(it) } + .doOnSuccess { Timber.d("Broadcasted txid=$it") } + } + } + + private val jsonParser = JsonParser() + + /** + * Returns the txid as a string if present, other responses throws an exception + */ + private fun sendTx(hex: String): Single { + return bitcoinClient.sendRawTransaction(hex) + .map { + val json = jsonParser.parse(it) + if (json.isJsonPrimitive && json.asString.length == 64) { + return@map json.asString + } + throw RuntimeException(json.toString()) + } + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/Address.kt b/slpwallet/src/main/java/com/bitcoin/wallet/address/Address.kt new file mode 100644 index 0000000..a6017ee --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/Address.kt @@ -0,0 +1,70 @@ +package com.bitcoin.wallet.address + + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.encoding.Base58CheckEncoding + +/** + * @author akibabu + */ +abstract class Address(val network: Network, version: Int, bytes: ByteArray) : + Base58CheckEncoding(assertVersion(network, version), assertLength(bytes)) { + + open fun toSlp(): AddressSLP { + return AddressSLP(network, version, bytes) + } + + open fun toCash(): AddressCash { + return AddressCash(network, version, bytes) + } + + open fun toLegacy(): AddressLegacy { + return AddressLegacy(network, version, bytes) + } + + companion object { + private const val length = 20 + + private fun assertVersion(network: Network, version: Int): Int { + if (network.instance.parameters.acceptableAddressCodes.any { it == version }) { + return version + } + throw AddressFormatException("Bad version $version") + } + + private fun assertLength(bytes: ByteArray): ByteArray { + if (bytes.size != length) { + throw AddressFormatException("Bad address length ${bytes.size}") + } + return bytes + } + + fun parse(network: Network, address: String): Address { + if (address.startsWith(AddressCash.prefix(network))) { + return AddressCash.parse(network, address) + } else if (address.startsWith(AddressSLP.prefix(network))) { + return AddressSLP.parse(network, address) + } + return AddressLegacy.parse(network, address) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + if (!super.equals(other)) return false + + other as Address + + if (network != other.network) return false + + return true + } + + override fun hashCode(): Int { + var result = super.hashCode() + result = 31 * result + network.hashCode() + return result + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressCash.kt b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressCash.kt new file mode 100644 index 0000000..a9fe15a --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressCash.kt @@ -0,0 +1,42 @@ +package com.bitcoin.wallet.address + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.bitcoinj.AddressCashUtil + +/** + * @author akibabu + */ +class AddressCash internal constructor(network: Network, version: Int, bytes: ByteArray) : + Address(network, version, bytes) { + + private val type: AddressType = AddressType.fromVersion(version.toByte()) + + override fun toCash(): AddressCash { + return this + } + + override fun toString(): String { + return AddressCashUtil.encodeCashAddress(prefix(network), AddressCashUtil.packAddressData(bytes, type.byte)) + } + + companion object { + + fun parse(network: Network, address: String): AddressCash { + val parts = address.split(":") + val prefix = prefix(network) + if (parts.size != 2 || parts[0] != prefix) { + throw AddressFormatException("Not cash address $address") + } + val bytes = AddressCashUtil.decode(prefix, address) + return AddressCash(network, bytes.version.toInt(), bytes.bytes) + } + + internal fun prefix(network: Network): String { + return when (network) { + Network.MAIN -> "bitcoincash" + Network.TEST -> "bchtest" + } + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressFormatException.kt b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressFormatException.kt new file mode 100644 index 0000000..fbf5fa9 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressFormatException.kt @@ -0,0 +1,6 @@ +package com.bitcoin.wallet.address + +/** + * @author akibabu + */ +class AddressFormatException(message: String) : IllegalArgumentException(message) diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressLegacy.kt b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressLegacy.kt new file mode 100644 index 0000000..5ac6e39 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressLegacy.kt @@ -0,0 +1,29 @@ +package com.bitcoin.wallet.address + +import com.bitcoin.wallet.Network + +/** + * @author akibabu + */ +class AddressLegacy internal constructor(network: Network, version: Int, bytes: ByteArray) : + Address(network, version, bytes) { + + override fun toLegacy(): AddressLegacy { + return this + } + + override fun toString(): String { + return toBase58() + } + + companion object { + fun parse(network: Network, base58: String): AddressLegacy { + try { + val address = org.bitcoinj.core.Address.fromBase58(network.instance.parameters, base58) + return AddressLegacy(network, address.version, address.hash160) + } catch (e: org.bitcoinj.core.AddressFormatException) { + throw AddressFormatException(e.message.toString()) + } + } + } +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressSLP.kt b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressSLP.kt new file mode 100644 index 0000000..ea71a72 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressSLP.kt @@ -0,0 +1,42 @@ +package com.bitcoin.wallet.address + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.bitcoinj.AddressCashUtil + +/** + * @author akibabu + */ +class AddressSLP internal constructor(network: Network, version: Int, bytes: ByteArray) : + Address(network, version, bytes) { + + private val type: AddressType = AddressType.fromVersion(version.toByte()) + + override fun toSlp(): AddressSLP { + return this + } + + override fun toString(): String { + return AddressCashUtil.encodeCashAddress(prefix(network), AddressCashUtil.packAddressData(bytes, type.byte)) + } + + companion object { + + fun parse(network: Network, address: String): AddressSLP { + val parts = address.split(":") + val prefix = prefix(network) + if (parts.size != 2 || parts[0] != prefix) { + throw AddressFormatException("Not SLP address $address") + } + val bytes = AddressCashUtil.decode(prefix, address) + return AddressSLP(network, bytes.version.toInt(), bytes.bytes) + } + + internal fun prefix(network: Network): String { + return when (network) { + Network.MAIN -> "simpleledger" + Network.TEST -> "slptest" + } + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressType.java b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressType.java new file mode 100644 index 0000000..bc13e59 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/AddressType.java @@ -0,0 +1,31 @@ +package com.bitcoin.wallet.address; + +/** + * @author akibabu + */ +enum AddressType { + + Pubkey((byte) 0), + Script((byte) 1); + private final byte value; + + AddressType(byte value) { + this.value = value; + } + + public byte getByte() { + return value; + } + + public static AddressType fromVersion(byte version) { + switch (version >> 3 & 0x1f) { + case 0: + return AddressType.Pubkey; + case 1: + return AddressType.Script; + default: + throw new AddressFormatException("version=" + (version & 0xFF)); + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/address/KeyAddressPair.kt b/slpwallet/src/main/java/com/bitcoin/wallet/address/KeyAddressPair.kt new file mode 100644 index 0000000..78b0c75 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/address/KeyAddressPair.kt @@ -0,0 +1,8 @@ +package com.bitcoin.wallet.address + +import java.math.BigInteger + +/** + * @author akibabu + */ +internal data class KeyAddressPair(val address: Address, val privateKey: BigInteger) diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashUtil.java b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashUtil.java new file mode 100644 index 0000000..9015a4e --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashUtil.java @@ -0,0 +1,424 @@ +/* + * Copyright 2018 the bitcoinj-cash developers + * + * 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. + */ +package com.bitcoin.wallet.bitcoinj; + +import org.bitcoinj.core.AddressFormatException; + +/** + * From BitcoinJ + * Created by Hash Engineering on 1/19/2018. + */ +public class AddressCashUtil { + + /** + * The cashaddr character set for encoding. + */ + final static String CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + + /** + * The cashaddr character set for decoding. + */ + final static byte CHARSET_REV[] = { + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 15, -1, 10, 17, 21, 20, 26, 30, 7, + 5, -1, -1, -1, -1, -1, -1, -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, + 31, 27, 19, -1, 1, 0, 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, + -1, -1, 29, -1, 24, 13, 25, 9, 8, 23, -1, 18, 22, 31, 27, 19, -1, 1, 0, + 3, 16, 11, 28, 12, 14, 6, 4, 2, -1, -1, -1, -1, -1}; + + /** + * Concatenate two byte arrays. + */ + static byte[] concatenateByteArrays(final byte[] x, final byte[] y) { + byte[] z = new byte[x.length + y.length]; + System.arraycopy(x, 0, z, 0, x.length); + System.arraycopy(y, 0, z, x.length, y.length); + return z; + } + + /** + * This function will compute what 8 5-bit values to XOR into the last 8 input + * values, in order to make the checksum 0. These 8 values are packed together + * in a single 40-bit integer. The higher bits correspond to earlier values. + */ + static long computePolyMod(final byte[] v) { + /** + * The input is interpreted as a list of coefficients of a polynomial over F + * = GF(32), with an implicit 1 in front. If the input is [v0,v1,v2,v3,v4], + * that polynomial is v(x) = 1*x^5 + v0*x^4 + v1*x^3 + v2*x^2 + v3*x + v4. + * The implicit 1 guarantees that [v0,v1,v2,...] has a distinct checksum + * from [0,v0,v1,v2,...]. + * + * The output is a 40-bit integer whose 5-bit groups are the coefficients of + * the remainder of v(x) mod g(x), where g(x) is the cashaddr generator, x^8 + * + {19}*x^7 + {3}*x^6 + {25}*x^5 + {11}*x^4 + {25}*x^3 + {3}*x^2 + {19}*x + * + {1}. g(x) is chosen in such a way that the resulting code is a BCH + * code, guaranteeing detection of up to 4 errors within a window of 1025 + * characters. Among the various possible BCH codes, one was selected to in + * fact guarantee detection of up to 5 errors within a window of 160 + * characters and 6 erros within a window of 126 characters. In addition, + * the code guarantee the detection of a burst of up to 8 errors. + * + * Note that the coefficients are elements of GF(32), here represented as + * decimal numbers between {}. In this finite field, addition is just XOR of + * the corresponding numbers. For example, {27} + {13} = {27 ^ 13} = {22}. + * Multiplication is more complicated, and requires treating the bits of + * values themselves as coefficients of a polynomial over a smaller field, + * GF(2), and multiplying those polynomials mod a^5 + a^3 + 1. For example, + * {5} * {26} = (a^2 + 1) * (a^4 + a^3 + a) = (a^4 + a^3 + a) * a^2 + (a^4 + + * a^3 + a) = a^6 + a^5 + a^4 + a = a^3 + 1 (mod a^5 + a^3 + 1) = {9}. + * + * During the course of the loop below, `c` contains the bitpacked + * coefficients of the polynomial constructed from just the values of v that + * were processed so far, mod g(x). In the above example, `c` initially + * corresponds to 1 mod (x), and after processing 2 inputs of v, it + * corresponds to x^2 + v0*x + v1 mod g(x). As 1 mod g(x) = 1, that is the + * starting value for `c`. + */ + long c = 1; + for (byte d : v) { + /** + * We want to update `c` to correspond to a polynomial with one extra + * term. If the initial value of `c` consists of the coefficients of + * c(x) = f(x) mod g(x), we modify it to correspond to + * c'(x) = (f(x) * x + d) mod g(x), where d is the next input to + * process. + * + * Simplifying: + * c'(x) = (f(x) * x + d) mod g(x) + * ((f(x) mod g(x)) * x + d) mod g(x) + * (c(x) * x + d) mod g(x) + * If c(x) = c0*x^5 + c1*x^4 + c2*x^3 + c3*x^2 + c4*x + c5, we want to + * compute + * c'(x) = (c0*x^5 + c1*x^4 + c2*x^3 + c3*x^2 + c4*x + c5) * x + d + * mod g(x) + * = c0*x^6 + c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + d + * mod g(x) + * = c0*(x^6 mod g(x)) + c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + + * c5*x + d + * If we call (x^6 mod g(x)) = k(x), this can be written as + * c'(x) = (c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + d) + c0*k(x) + */ + + // First, determine the value of c0: + byte c0 = (byte) (c >> 35); + + // Then compute c1*x^5 + c2*x^4 + c3*x^3 + c4*x^2 + c5*x + d: + c = ((c & 0x07ffffffffL) << 5) ^ d; + + // Finally, for each set bit n in c0, conditionally add {2^n}k(x): + if ((c0 & 0x01) != 0) { + // k(x) = {19}*x^7 + {3}*x^6 + {25}*x^5 + {11}*x^4 + {25}*x^3 + + // {3}*x^2 + {19}*x + {1} + c ^= 0x98f2bc8e61L; + } + + if ((c0 & 0x02) != 0) { + // {2}k(x) = {15}*x^7 + {6}*x^6 + {27}*x^5 + {22}*x^4 + {27}*x^3 + + // {6}*x^2 + {15}*x + {2} + c ^= 0x79b76d99e2L; + } + + if ((c0 & 0x04) != 0) { + // {4}k(x) = {30}*x^7 + {12}*x^6 + {31}*x^5 + {5}*x^4 + {31}*x^3 + + // {12}*x^2 + {30}*x + {4} + c ^= 0xf33e5fb3c4L; + } + + if ((c0 & 0x08) != 0) { + // {8}k(x) = {21}*x^7 + {24}*x^6 + {23}*x^5 + {10}*x^4 + {23}*x^3 + + // {24}*x^2 + {21}*x + {8} + c ^= 0xae2eabe2a8L; + } + + if ((c0 & 0x10) != 0) { + // {16}k(x) = {3}*x^7 + {25}*x^6 + {7}*x^5 + {20}*x^4 + {7}*x^3 + + // {25}*x^2 + {3}*x + {16} + c ^= 0x1e4f43e470L; + } + } + + /** + * computePolyMod computes what value to xor into the final values to make the + * checksum 0. However, if we required that the checksum was 0, it would be + * the case that appending a 0 to a valid list of values would result in a + * new valid list. For that reason, cashaddr requires the resulting checksum + * to be 1 instead. + */ + return c ^ 1; + } + + /** + * Expand the address prefix for the checksum computation. + */ + static byte[] expandPrefix(String prefix) { + byte[] ret = new byte[prefix.length() + 1]; + + byte[] prefixBytes = prefix.getBytes(); + + for (int i = 0; i < prefix.length(); ++i) { + ret[i] = (byte) (prefixBytes[i] & 0x1f); + } + + ret[prefix.length()] = 0; + return ret; + } + + static boolean verifyChecksum(String prefix, byte[] payload) { + return computePolyMod(concatenateByteArrays(expandPrefix(prefix), payload)) == 0; + } + + static byte[] createChecksum(String prefix, final byte[] payload) { + byte[] enc = concatenateByteArrays(expandPrefix(prefix), payload); + // Append 8 zeroes. + byte[] enc2 = new byte[enc.length + 8]; + System.arraycopy(enc, 0, enc2, 0, enc.length); + // Determine what to XOR into those 8 zeroes. + long mod = computePolyMod(enc2); + byte[] ret = new byte[8]; + for (int i = 0; i < 8; ++i) { + // Convert the 5-bit groups in mod to checksum values. + ret[i] = (byte) ((mod >> (5 * (7 - i))) & 0x1f); + } + + return ret; + } + + public static String encodeCashAddress(String prefix, byte[] payload) { + byte[] checksum = createChecksum(prefix, payload); + byte[] combined = concatenateByteArrays(payload, checksum); + StringBuilder ret = new StringBuilder(prefix + ':'); + + //ret.setLength(ret.length() + combined.length); + for (byte c : combined) { + ret.append(CHARSET.charAt(c)); + } + + return ret.toString(); + } + + public static byte[] decodeCashAddress(String str, String defaultPrefix) { + // Go over the string and do some sanity checks. + boolean lower = false, upper = false, hasNumber = false; + int prefixSize = 0; + for (int i = 0; i < str.length(); ++i) { + char c = str.charAt(i); + if (c >= 'a' && c <= 'z') { + lower = true; + continue; + } + + if (c >= 'A' && c <= 'Z') { + upper = true; + continue; + } + + if (c >= '0' && c <= '9') { + // We cannot have numbers in the prefix. + hasNumber = true; + continue; + } + + if (c == ':') { + // The separator cannot be the first character, cannot have number + // and there must not be 2 separators. + if (hasNumber || i == 0 || prefixSize != 0) { + throw new AddressFormatException("cashaddr: " + str + ": The separator cannot be the first character, cannot have number and there must not be 2 separators"); + } + + prefixSize = i; + continue; + } + + // We have an unexpected character. + throw new AddressFormatException("cashaddr: " + str + ": Unexpected character at pos " + i); + } + + // We can't have both upper case and lowercase. + if (upper && lower) { + throw new AddressFormatException("cashaddr: " + str + ": Cannot contain both upper and lower case letters"); + } + + // Get the prefix. + StringBuilder prefix; + if (prefixSize == 0) { + prefix = new StringBuilder(defaultPrefix); + } else { + prefix = new StringBuilder(str.substring(0, prefixSize).toLowerCase()); + + // Now add the ':' in the size. + prefixSize++; + } + + // Decode values. + final int valuesSize = str.length() - prefixSize; + byte[] values = new byte[valuesSize]; + for (int i = 0; i < valuesSize; ++i) { + char c = str.charAt(i + prefixSize); + // We have an invalid char in there. + if (c > 127 || CHARSET_REV[c] == -1) { + throw new AddressFormatException("cashaddr: " + str + ": Unexpected character at pos " + i); + } + + values[i] = CHARSET_REV[c]; + } + + // Verify the checksum. + if (!verifyChecksum(prefix.toString(), values)) { + throw new AddressFormatException("cashaddr: " + str + ": Invalid Checksum "); + } + + byte[] result = new byte[values.length - 8]; + System.arraycopy(values, 0, result, 0, values.length - 8); + return result; + } + + public static class AddressVersionAndBytes { + private final byte version; + private final byte[] bytes; + + public AddressVersionAndBytes(byte version, byte[] bytes) { + this.version = version; + this.bytes = bytes; + } + + public byte getVersion() { + return version; + } + + public byte[] getBytes() { + return bytes; + } + } + + public static AddressVersionAndBytes decode(String prefix, String address) { + byte[] bytes = AddressCashUtil.decodeCashAddress(address, prefix); + AddressCashValidator.checkNonEmptyPayload(bytes); + + byte extraBits = (byte) (bytes.length * 5 % 8); + AddressCashValidator.checkAllowedPadding(extraBits); + + byte last = bytes[bytes.length - 1]; + byte mask = (byte) ((1 << extraBits) - 1); + AddressCashValidator.checkNonZeroPadding(last, mask); + + byte[] data = new byte[bytes.length * 5 / 8]; + convertBits(data, bytes, 5, 8, false); + + byte versionByte = data[0]; + AddressCashValidator.checkFirstBitIsZero(versionByte); + + int hashSize = calculateHashSizeFromVersionByte(versionByte); + AddressCashValidator.checkDataLength(data, hashSize); + + byte[] result = new byte[data.length - 1]; + System.arraycopy(data, 1, result, 0, data.length - 1); + return new AddressVersionAndBytes(versionByte, result); + } + + private static int calculateHashSizeFromVersionByte(byte version) { + int hash_size = 20 + 4 * (version & 0x03); + if ((version & 0x04) != 0) { + hash_size *= 2; + } + return hash_size; + } + + public static byte[] packAddressData(byte[] payload, byte type) { + byte version_byte = (byte) (type << 3); + int size = payload.length; + byte encoded_size; + switch (size * 8) { + case 160: + encoded_size = 0; + break; + case 192: + encoded_size = 1; + break; + case 224: + encoded_size = 2; + break; + case 256: + encoded_size = 3; + break; + case 320: + encoded_size = 4; + break; + case 384: + encoded_size = 5; + break; + case 448: + encoded_size = 6; + break; + case 512: + encoded_size = 7; + break; + default: + throw new AddressFormatException("Error packing cashaddr: invalid address length"); + } + version_byte |= encoded_size; + byte[] data = new byte[1 + payload.length]; + data[0] = version_byte; + System.arraycopy(payload, 0, data, 1, payload.length); + + // Reserve the number of bytes required for a 5-bit packed version of a + // hash, with version byte. Add half a byte(4) so integer math provides + // the next multiple-of-5 that would fit all the data. + + byte[] converted = new byte[((size + 1) * 8 + 4) / 5]; + convertBits(converted, data, 8, 5, true); + + return converted; + } + + /** + * Convert from one power-of-2 number base to another. + *

+ * If padding is enabled, this always return true. If not, then it returns true + * of all the bits of the input are encoded in the output. + */ + public static boolean convertBits(byte[] out, byte[] it, int frombits, int tobits, boolean pad) { + int acc = 0; + int bits = 0; + final int maxv = (1 << tobits) - 1; + final int max_acc = (1 << (frombits + tobits - 1)) - 1; + int x = 0; + for (int i = 0; i < it.length; ++i) { + acc = ((acc << frombits) | (it[i] & 0xff)) & max_acc; + bits += frombits; + while (bits >= tobits) { + bits -= tobits; + out[x] = (byte) ((acc >> bits) & maxv); + ++x; + } + } + + // We have remaining bits to encode but do not pad. + if (!pad && bits != 0) { + return false; + } + + // We have remaining bits to encode so we do pad. + if (pad && bits != 0) { + out[x] = (byte) ((acc << (tobits - bits)) & maxv); + ++x; + } + + return true; + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashValidator.java b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashValidator.java new file mode 100644 index 0000000..5b4d3fa --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/AddressCashValidator.java @@ -0,0 +1,55 @@ +/* + * Copyright 2018 the bitcoinj-cash developers + * + * 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. + */ +package com.bitcoin.wallet.bitcoinj; + +import com.bitcoin.wallet.address.AddressFormatException; + +/** + * From BitcoinJ + */ +class AddressCashValidator { + + static void checkNonEmptyPayload(byte[] payload) { + if (payload.length == 0) { + throw new AddressFormatException("No payload"); + } + } + + static void checkAllowedPadding(byte extraBits) { + if (extraBits >= 5) { + throw new AddressFormatException("More than allowed padding"); + } + } + + static void checkNonZeroPadding(byte last, byte mask) { + if ((last & mask) != 0) { + throw new AddressFormatException("Nonzero padding bytes"); + } + } + + static void checkFirstBitIsZero(byte versionByte) { + if ((versionByte & 0x80) != 0) { + throw new AddressFormatException("First bit is reserved"); + } + } + + static void checkDataLength(byte[] data, int hashSize) { + if (data.length != hashSize + 1) { + throw new AddressFormatException("Data length " + data.length + " != hash size " + hashSize); + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/Mnemonic.kt b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/Mnemonic.kt new file mode 100644 index 0000000..dfb492a --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/Mnemonic.kt @@ -0,0 +1,49 @@ +package com.bitcoin.wallet.bitcoinj + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.address.Address +import com.bitcoin.wallet.address.KeyAddressPair +import org.bitcoinj.crypto.ChildNumber +import org.bitcoinj.crypto.HDKeyDerivation +import org.bitcoinj.crypto.MnemonicCode +import org.bitcoinj.wallet.DeterministicSeed +import java.security.SecureRandom + +/** + * @author akibabu + */ +internal class Mnemonic(val mnemonic: List) { + + init { + if (mnemonic.size != 12) { + throw IllegalArgumentException("mnemonic should be 12 words long") + } + } + + // Master key without password protection + private val masterKey = HDKeyDerivation.createMasterPrivateKey(MnemonicCode.toSeed(mnemonic, "")) + + companion object { + private val mnemonic = MnemonicCode() + private val random = SecureRandom() + + fun generate(): Mnemonic { + val words = mnemonic.toMnemonic( + random.generateSeed(DeterministicSeed.DEFAULT_SEED_ENTROPY_BITS / 8) + ) + return Mnemonic(words) + } + } + + fun getAddress(network: Network, vararg hardenedPath: Int): KeyAddressPair { + var key = masterKey + for (path in hardenedPath) { + key = key.derive(path) + } + key = HDKeyDerivation.deriveChildKey(HDKeyDerivation.deriveChildKey( + key, ChildNumber(0, false)), ChildNumber(0, false)) + val address = Address.parse(network, key.toAddress(network.instance.parameters).toBase58()) + return KeyAddressPair(address, key.privKey) + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/NetworkInstance.kt b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/NetworkInstance.kt new file mode 100644 index 0000000..c78a43a --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/bitcoinj/NetworkInstance.kt @@ -0,0 +1,15 @@ +package com.bitcoin.wallet.bitcoinj + +import com.bitcoin.wallet.Network +import org.bitcoinj.core.NetworkParameters +import org.bitcoinj.params.MainNetParams +import org.bitcoinj.params.TestNet3Params + +/** + * @author akibabu + */ +internal class NetworkInstance(val network: Network) { + + val parameters: NetworkParameters = if (network == Network.MAIN) MainNetParams.get() else TestNet3Params.get() + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/encoding/Base58CheckEncoding.kt b/slpwallet/src/main/java/com/bitcoin/wallet/encoding/Base58CheckEncoding.kt new file mode 100644 index 0000000..6414bae --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/encoding/Base58CheckEncoding.kt @@ -0,0 +1,42 @@ +package com.bitcoin.wallet.encoding + +/** + * https://github.com/bitcoinbook/bitcoinbook/blob/develop/images/mbc2_0406.png + * + * @author akibabu + */ +open class Base58CheckEncoding(protected val version: Int, protected val bytes: ByteArray) { + + fun toBase58(): String { + val result = ByteArray(versionLength + bytes.size + checksumLength) + result[0] = version.toByte() + System.arraycopy(bytes, 0, result, 1, bytes.size) + val checksum = Sha256Hash.hashTwice(result, 0, versionLength + this.bytes.size) + System.arraycopy(checksum, 0, result, versionLength + bytes.size, checksumLength) + return ByteUtils.Base58.encode(result) + } + + companion object { + private const val versionLength = 1 + private const val checksumLength = 4 + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Base58CheckEncoding + + if (version != other.version) return false + if (!bytes.contentEquals(other.bytes)) return false + + return true + } + + override fun hashCode(): Int { + var result = version + result = 31 * result + bytes.contentHashCode() + return result + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/encoding/ByteUtils.kt b/slpwallet/src/main/java/com/bitcoin/wallet/encoding/ByteUtils.kt new file mode 100644 index 0000000..0e112c8 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/encoding/ByteUtils.kt @@ -0,0 +1,48 @@ +package com.bitcoin.wallet.encoding + +import org.spongycastle.util.encoders.DecoderException +import java.nio.ByteBuffer + +/** + * @author akibabu + */ +object ByteUtils { + + fun toULong(bytes: ByteArray): ULong { + return ByteBuffer.wrap(bytes).long.toULong() + } + + fun toULong(bytes: ByteArray?): ULong? { + return bytes?.let { ByteBuffer.wrap(it).long.toULong() } + } + + fun toInt(bytes: ByteArray): Int { + val buffer = ByteBuffer.allocate(Int.SIZE_BYTES) + .position(Int.SIZE_BYTES - bytes.size) + as ByteBuffer + buffer.put(bytes) + return buffer.getInt(0) + } + + object Hex { + + fun encode(bytes: ByteArray): String { + return org.spongycastle.util.encoders.Hex.toHexString(bytes) + } + + fun decode(hex: String): ByteArray { + try { + return org.spongycastle.util.encoders.Hex.decode(hex) + } catch (e: DecoderException) { + throw RuntimeException(e) + } + } + } + + object Base58 { + fun encode(input: ByteArray): String { + return org.bitcoinj.core.Base58.encode(input) + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/encoding/Sha256Hash.kt b/slpwallet/src/main/java/com/bitcoin/wallet/encoding/Sha256Hash.kt new file mode 100644 index 0000000..441761f --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/encoding/Sha256Hash.kt @@ -0,0 +1,27 @@ +package com.bitcoin.wallet.encoding + +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +/** + * @author akibabu + */ +internal object Sha256Hash { + + private val sha256: MessageDigest + get() { + try { + return MessageDigest.getInstance("SHA-256") + } catch (e: NoSuchAlgorithmException) { + throw RuntimeException(e) + } + + } + + fun hashTwice(bytes: ByteArray, offset: Int, length: Int): ByteArray { + val sha256 = sha256 + sha256.update(bytes, offset, length) + return sha256.digest(sha256.digest()) + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/persistence/DaoBase.kt b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/DaoBase.kt new file mode 100644 index 0000000..d7bb349 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/DaoBase.kt @@ -0,0 +1,17 @@ +package com.bitcoin.wallet.persistence + +import androidx.room.Insert +import androidx.room.OnConflictStrategy + +/** + * @author akibabu + */ +interface DaoBase { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(vararg values: T) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun save(values: Collection) + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalance.kt b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalance.kt new file mode 100644 index 0000000..8904d88 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalance.kt @@ -0,0 +1,15 @@ +package com.bitcoin.wallet.persistence + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.math.BigDecimal + +@Entity +data class SlpTokenBalance ( + @PrimaryKey var tokenId: String, + @ColumnInfo var amount: BigDecimal, + @ColumnInfo var ticker: String?, + @ColumnInfo var name: String?, + @ColumnInfo var decimals: Int +) \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalanceDao.kt b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalanceDao.kt new file mode 100644 index 0000000..4074cad --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/SlpTokenBalanceDao.kt @@ -0,0 +1,23 @@ +package com.bitcoin.wallet.persistence + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface SlpTokenBalanceDao { + @Query("Delete FROM SlpTokenBalance WHERE tokenId = :tokenId") + fun delete(vararg tokenId: String): Int + + @Query("SELECT * FROM SlpTokenBalance ORDER BY name") + fun getBalances(): List + + @Query("SELECT * FROM SlpTokenBalance ORDER BY name") + fun getBalancesLive(): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun upsertBalance(vararg balance: SlpTokenBalance) + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/persistence/TypeConverters.kt b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/TypeConverters.kt new file mode 100644 index 0000000..db00334 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/TypeConverters.kt @@ -0,0 +1,32 @@ +package com.bitcoin.wallet.persistence + +import androidx.room.TypeConverter +import com.bitcoin.wallet.slp.SlpTokenId +import java.math.BigDecimal + +class TypeConverters { + + @TypeConverter + fun tokenIdReadConverter(value: SlpTokenId): String { + return value.hex + } + + @TypeConverter + fun tokenIdWriteConverter(value: String): SlpTokenId { + return SlpTokenId(value) + } + + @TypeConverter + fun bigDecimalFromString(value: String?): BigDecimal? { + return if (value !== null) { + BigDecimal(value) + } else { + null + } + } + + @TypeConverter + fun stringFromBigDecimal(number: BigDecimal?): String? { + return number?.toPlainString() + } +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabase.kt b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabase.kt new file mode 100644 index 0000000..d4c2fe2 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabase.kt @@ -0,0 +1,22 @@ +package com.bitcoin.wallet.persistence + +import com.bitcoin.wallet.slp.SlpTokenDetailsDao +import com.bitcoin.wallet.slp.SlpUtxoDao +import com.bitcoin.wallet.slp.SlpValidTxDao +import com.bitcoin.wallet.tx.UtxoDao + +/** + * @author akibabu + */ +internal interface WalletDatabase { + + fun tokenBalanceDao(): SlpTokenBalanceDao + fun utxoDao(): UtxoDao + fun slpUtxoDao(): SlpUtxoDao + fun slpTokenDetailsDao(): SlpTokenDetailsDao + fun slpValidTxDao(): SlpValidTxDao + + fun awaitReady(): Boolean + fun newWalletClear() + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabaseImpl.kt b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabaseImpl.kt new file mode 100644 index 0000000..83eb12c --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/persistence/WalletDatabaseImpl.kt @@ -0,0 +1,57 @@ +package com.bitcoin.wallet.persistence + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters +import com.bitcoin.wallet.slp.* +import com.bitcoin.wallet.tx.Utxo +import com.bitcoin.wallet.tx.UtxoDao +import com.bitcoin.wallet.util.SingletonHolder +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean + +@Database(entities = [SlpTokenBalance::class, Utxo::class, SlpUtxo::class, SlpTokenDetails::class, SlpValidTx::class], + version = 5) +@TypeConverters(com.bitcoin.wallet.persistence.TypeConverters::class) +internal abstract class WalletDatabaseImpl : RoomDatabase(), WalletDatabase { + + private var initialized = AtomicBoolean(true) + abstract override fun tokenBalanceDao(): SlpTokenBalanceDao + abstract override fun utxoDao(): UtxoDao + abstract override fun slpUtxoDao(): SlpUtxoDao + abstract override fun slpTokenDetailsDao(): SlpTokenDetailsDao + abstract override fun slpValidTxDao(): SlpValidTxDao + + override fun newWalletClear() { + initialized.set(false) + val start = System.currentTimeMillis() + Schedulers.io().scheduleDirect { + clearAllTables() + Timber.d("Cleared all database tables in ${System.currentTimeMillis() - start} ms") + initialized.set(true) + } + } + + override fun awaitReady(): Boolean { + for (i in 1..100) { + if (initialized.get()) { + return true + } + Thread.sleep(30) + } + Timber.e("timeout waiting for database to initialize") + return false + } + + companion object : SingletonHolder({ + Room.databaseBuilder( + it.applicationContext, WalletDatabaseImpl::class.java, + "com.bitcoin.wallet.slp.SLPWallet-db" + ).fallbackToDestructiveMigration().build() + }) + +} + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfo.kt b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfo.kt new file mode 100644 index 0000000..dc3cc46 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfo.kt @@ -0,0 +1,11 @@ +package com.bitcoin.wallet.presentation + +import java.math.BigDecimal + +interface BalanceInfo { + var tokenId: String + var amount: BigDecimal + var ticker: String? + var name: String? + var decimals: Int? +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfoImpl.kt b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfoImpl.kt new file mode 100644 index 0000000..6fe174b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/BalanceInfoImpl.kt @@ -0,0 +1,24 @@ +package com.bitcoin.wallet.presentation + +import com.bitcoin.wallet.persistence.SlpTokenBalance +import java.math.BigDecimal + +data class BalanceInfoImpl( + override var tokenId: String, + override var amount: BigDecimal, + override var ticker: String?, + override var name: String?, + override var decimals: Int? +) : BalanceInfo { + companion object { + fun fromSlpTokenBalance(slpTokenBalance: SlpTokenBalance): BalanceInfoImpl { + return BalanceInfoImpl( + slpTokenBalance.tokenId, + slpTokenBalance.amount, + slpTokenBalance.ticker, + slpTokenBalance.name, + slpTokenBalance.decimals + ) + } + } +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/presentation/Blockie.kt b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/Blockie.kt new file mode 100644 index 0000000..ca8583f --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/Blockie.kt @@ -0,0 +1,8 @@ +package com.bitcoin.wallet.presentation + +/** + * Because of bug in Badger Wallet expecting the token ID to be an address prefixed with "bitcoincash:" + */ +fun blockieAddressFromTokenId(tokenId: String): String { + return tokenId.slice(IntRange(12, tokenId.count() - 1)) +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/presentation/ProgressTask.kt b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/ProgressTask.kt new file mode 100644 index 0000000..d5a702e --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/ProgressTask.kt @@ -0,0 +1,30 @@ +package com.bitcoin.wallet.presentation + +enum class TaskStatus { + IDLE, + UNDERWAY, + SUCCESS, + ERROR +} + +data class ProgressTask(var status: TaskStatus, var result: ResultType? = null, var message: String? = null) { + + companion object { + fun idle(): ProgressTask { + return ProgressTask(TaskStatus.IDLE) + } + + fun underway(result: ResultType): ProgressTask { + return ProgressTask(TaskStatus.UNDERWAY, result) + } + + fun success(result: ResultType): ProgressTask { + return ProgressTask(TaskStatus.SUCCESS, result) + } + + fun error(message: String): ProgressTask { + return ProgressTask(TaskStatus.ERROR, message = message) + } + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/presentation/TokenNumberFormat.kt b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/TokenNumberFormat.kt new file mode 100644 index 0000000..c8d750d --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/presentation/TokenNumberFormat.kt @@ -0,0 +1,24 @@ +package com.bitcoin.wallet.presentation + +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.text.NumberFormat + +fun getTokenNumberFormat(decimals: Int?, ticker: String?): NumberFormat { + + val nf: NumberFormat = NumberFormat.getCurrencyInstance() + nf.isGroupingUsed = true + + val decimalFormat: DecimalFormat? = nf as? DecimalFormat + if (decimalFormat != null) { + var decimalFormatSymbols: DecimalFormatSymbols = decimalFormat.decimalFormatSymbols + decimalFormatSymbols.currencySymbol = if (ticker != null) { " ${ticker} " } else { "" } + nf.decimalFormatSymbols = decimalFormatSymbols + } + + nf.maximumFractionDigits = decimals ?: 0 + nf.minimumFractionDigits = decimals ?: 0 + + + return nf +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosRequest.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosRequest.kt new file mode 100644 index 0000000..a4af9ff --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosRequest.kt @@ -0,0 +1,13 @@ +package com.bitcoin.wallet.rest + +import com.bitcoin.wallet.address.AddressCash + +/** + * https://rest.bitcoin.com/#/address/utxoBulk + * + * @author akibabu + */ +internal data class AddressUtxosRequest private constructor(val addresses: List) { + + constructor(addresses: Collection) : this(addresses.map { it.toString() }) +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosResponse.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosResponse.kt new file mode 100644 index 0000000..30017df --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/AddressUtxosResponse.kt @@ -0,0 +1,15 @@ +package com.bitcoin.wallet.rest + +/** + * https://rest.bitcoin.com/#/address/utxoBulk + * + * @author akibabu + */ +internal data class AddressUtxosResponse(val utxos: List, val cashAddress: String) { + data class UtxoResponse(val txid: String, val vout: Int, val satoshis: Long, val cashAddress: String?) + + fun flatMapEnrichWithAddress(): List { + return utxos + .map { it.copy(cashAddress = cashAddress) } + } +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/BitcoinRestClient.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/BitcoinRestClient.kt new file mode 100644 index 0000000..5cd653f --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/BitcoinRestClient.kt @@ -0,0 +1,39 @@ +package com.bitcoin.wallet.rest + +import com.bitcoin.wallet.Network +import io.reactivex.Single +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import java.util.concurrent.ConcurrentHashMap + +/** + * @author akibabu + */ +internal interface BitcoinRestClient { + + @POST("address/utxo") + fun getUtxos(@Body request: AddressUtxosRequest): Single> + + @GET("rawtransactions/sendRawTransaction/{hex}") + fun sendRawTransaction(@Path("hex") hex: String): Single + + @POST("transaction/details") + fun getTransactions(@Body request: TxDetailsRequest): Single> + + @POST("slp/validateTxid") + fun validateSlpTxs(@Body request: SlpValidateTxRequest): Single> + + + companion object { + private val clients : ConcurrentHashMap = ConcurrentHashMap() + + @Synchronized + fun getInstance(network: Network) : BitcoinRestClient { + // computeIfAbsent not present in API 21. getOrPut is not thread safe so the method is synchronized + return clients.getOrPut(network) { Retrofit.getInstance(network).create(BitcoinRestClient::class.java) } + } + + } +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/Retrofit.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/Retrofit.kt new file mode 100644 index 0000000..d07f64b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/Retrofit.kt @@ -0,0 +1,47 @@ +package com.bitcoin.wallet.rest + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.SLPWalletConfig +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.ConcurrentHashMap + +internal object Retrofit { + + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .addInterceptor { + var request = it.request() + SLPWalletConfig.restAPIKey?.let { + request = request.newBuilder().addHeader("Authorization", "BITBOX:$it").build() + } + it.proceed(request) + } + .build() + } + + private val retrofit : ConcurrentHashMap = ConcurrentHashMap() + + @Synchronized + fun getInstance(network: Network): Retrofit { + val baseUrl = when (network) { + Network.MAIN -> "https://rest.bitcoin.com/v2/" + Network.TEST -> "https://trest.bitcoin.com/v2/" + } + // computeIfAbsent not present in API 21. getOrPut is not thread safe so the method is synchronized + return retrofit.getOrPut(network) { retrofit(baseUrl)} + } + + private fun retrofit(baseUrl: String): Retrofit { + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build() + } + + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxRequest.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxRequest.kt new file mode 100644 index 0000000..fd8923b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxRequest.kt @@ -0,0 +1,8 @@ +package com.bitcoin.wallet.rest + +/** + * https://rest.bitcoin.com/#/slp/validateTxidBulk + * + * @author akibabu + */ +internal data class SlpValidateTxRequest(val txids: List) \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxResponse.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxResponse.kt new file mode 100644 index 0000000..51e5c23 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/SlpValidateTxResponse.kt @@ -0,0 +1,8 @@ +package com.bitcoin.wallet.rest + +/** + * https://rest.bitcoin.com/#/slp/validateTxidBulk + * + * @author akibabu + */ +internal data class SlpValidateTxResponse(val txid: String, val valid: Boolean) \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/TxDetailsRequest.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/TxDetailsRequest.kt new file mode 100644 index 0000000..1b310f6 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/TxDetailsRequest.kt @@ -0,0 +1,8 @@ +package com.bitcoin.wallet.rest + +/** + * https://rest.bitcoin.com/v2/transaction/details + * + * @author akibabu + */ +internal data class TxDetailsRequest(val txids: List) \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/rest/TxResponse.kt b/slpwallet/src/main/java/com/bitcoin/wallet/rest/TxResponse.kt new file mode 100644 index 0000000..364477b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/rest/TxResponse.kt @@ -0,0 +1,16 @@ +package com.bitcoin.wallet.rest + +/** + * https://rest.bitcoin.com/v2/transaction/details + * + * @author akibabu + */ +internal data class TxResponse(val txid: String, val vout: List) { + + data class TxOutputResponse(val value: String, val n: Int, val scriptPubKey: ScriptPubKeyResponse) { + + data class ScriptPubKeyResponse(val hex: String) + + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SLPWalletImpl.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SLPWalletImpl.kt new file mode 100644 index 0000000..3e22c83 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SLPWalletImpl.kt @@ -0,0 +1,183 @@ +package com.bitcoin.wallet.slp + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Transformations +import com.bitcoin.wallet.* +import com.bitcoin.wallet.bitcoinj.Mnemonic +import com.bitcoin.wallet.persistence.SlpTokenBalance +import com.bitcoin.wallet.persistence.WalletDatabase +import com.bitcoin.wallet.presentation.BalanceInfo +import com.bitcoin.wallet.presentation.BalanceInfoImpl +import com.bitcoin.wallet.presentation.ProgressTask +import io.reactivex.Single +import io.reactivex.schedulers.Schedulers +import timber.log.Timber +import java.math.BigDecimal +import java.util.concurrent.atomic.AtomicBoolean + +/** + * @author akibabu + */ +internal class SLPWalletImpl(val network: Network, m: Mnemonic, private val database: WalletDatabase) : SLPWallet { + + internal val bchAddressKey = m.getAddress(network, 44, 145, 0) + internal val slpKeyAddress = m.getAddress(network, 44, 245, 0) + + override val bchAddress = bchAddressKey.address.toCash().toString() + override val slpAddress = slpKeyAddress.address.toSlp().toString() + + private val bchAddressAsCash = bchAddressKey.address.toCash().toString() + // UTXO's come with cash address not SLP + internal val slpAddressAsCash = slpKeyAddress.address.toCash().toString() + + override val mnemonic: List = m.mnemonic + override val balance: LiveData> + get() = getBalanceInfo() + + internal val service: WalletService = WalletServiceImpl(this, database) + + private val scheduler: Scheduler = DefaultScheduler + + private val mSendStatus: MutableLiveData> = MutableLiveData>() + .apply { ProgressTask.idle() } + override val sendStatus: LiveData> = mSendStatus + + private val balanceIsBeingRefreshed: AtomicBoolean = AtomicBoolean(false) + + private val balanceDao = database.tokenBalanceDao() + + fun isMyCashAddress(address: String): Boolean { + return address == bchAddressAsCash || address == slpAddressAsCash + } + + private fun getBalanceInfo(): LiveData> { + return Transformations.map(balanceDao.getBalancesLive()) { data -> + val newList: MutableList = mutableListOf() + for (slpTokenBalance: SlpTokenBalance in data) { + newList.add(BalanceInfoImpl.fromSlpTokenBalance(slpTokenBalance)) + } + newList + } + } + + override fun clearSendStatus() { + mSendStatus.postValue(ProgressTask.idle()) + } + + @Synchronized + override fun refreshBalance() { + if (!database.awaitReady()) { + return + } + if (balanceIsBeingRefreshed.compareAndSet(false, true)) { + Timber.d("Starting balance refresh.") + + scheduler.execute { + service.refreshBalance() + .observeOn(Schedulers.io()) + .subscribe( + { walletBalance: WalletBalance -> + Timber.d("Received balance, now processing.") + + val existingBalances: List = balanceDao.getBalances() + val oldBalanceInNewData: MutableMap = mutableMapOf() + for (existingBalance: SlpTokenBalance in existingBalances) { + oldBalanceInNewData.set(existingBalance.tokenId, false) + } + + + val tokenBalances: MutableList = mutableListOf() + for (balance: Map.Entry in walletBalance.tokenBalance.entries) { + val tokenDetails: SlpTokenDetails = balance.key + val tokenBalance = SlpTokenBalance( + tokenDetails.tokenId.hex, + balance.value, + tokenDetails.ticker, + tokenDetails.name, + tokenDetails.decimals + ) + tokenBalances.add(tokenBalance) + oldBalanceInNewData[tokenDetails.tokenId.hex] = true + } + + var bchBalance = SlpTokenBalance( + "", + BigDecimal(walletBalance.nativeBalance).divide(BigDecimal(1e8)), + "BCH", + "Bitcoin Cash", + 8 + ) + tokenBalances.add(bchBalance) + + val tokensToRemove: MutableList = mutableListOf() + for (tokenId: String in oldBalanceInNewData.keys) { + if (tokenId.isNotEmpty() && oldBalanceInNewData[tokenId] == false) { + Timber.d("Adding token to remove with key: \"$tokenId\"") + tokensToRemove.add(tokenId) + } + } + + balanceDao.upsertBalance(*tokenBalances.toTypedArray()) + if (tokensToRemove.count() > 0) { + // I should not need to do this individually, but in one circumstance running on a physical + // phone, it would crash if I did not. More investigation required. + for (tokenIdToRemove: String in tokensToRemove) { + Timber.d("Removing token ID \"$tokenIdToRemove\"") + val tokensRemoved: Int = balanceDao.delete(tokenIdToRemove) + Timber.d("tokensRemoved: $tokensRemoved") + } + } + Timber.d("Finished storing new balance.") + + balanceIsBeingRefreshed.set(false) + + }, + { error: Throwable -> + Timber.e("Error refreshing balance. $error") + balanceIsBeingRefreshed.set(false) + } + + ) + } + + } else { + Timber.d("Skipped refreshing balance because it is already in progress.") + } + } + + override fun sendToken(tokenId: String, amount: BigDecimal, toAddress: String): Single + { + Timber.d("sendToken()") + return Single.fromCallable { + Timber.d("sendToken() checking db.") + if (!database.awaitReady()) { + throw Exception("Database not ready.") + } + } + .observeOn(Schedulers.io()) + .flatMap { + Timber.d("sendToken() setting status to be underway.") + mSendStatus.postValue(ProgressTask.underway(null)) + + service.sendTokenRx(SlpTokenId(tokenId), amount, toAddress) + } + .doOnSuccess { txid: String? -> + Timber.d("sendToken() completed successfully with txid: $txid.") + mSendStatus.postValue(ProgressTask.success(txid)) + refreshBalance() + } + .doOnError { e: Throwable? -> + Timber.e("Error when sending. $e") + mSendStatus.postValue(ProgressTask.error(e?.message ?: "")) + refreshBalance() + } + } + + + fun clearDatabase(): SLPWallet { + database.newWalletClear() + return this + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturn.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturn.kt new file mode 100644 index 0000000..1a7dcee --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturn.kt @@ -0,0 +1,65 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils +import com.bitcoin.wallet.tx.Script +import java.util.* + +/** + * @author akibabu + */ +internal open class SlpOpReturn( + open val tokenType: SlpTokenType, + val transactionType: SlpTransactionType, + open val tokenId: SlpTokenId) { + + // Interface of GENESIS and MINT transaction + internal interface BatonAndMint { + val batonVout: UInt? // May be null in which case the baton is destroyed + val mintedAmount: ULong // The minted amount received by vout[1] of this transaction + } + + companion object { + + internal val LOKAD: ByteArray = byteArrayOf(83, 76, 80, 0) // "SLP" 4 byte zero padding + private const val MIN_CHUNKS: Int = 6 // Number of required script chunks for smallest (SEND) SLP transaction + internal const val MAX_QUANTITIES: Int = 19 + + private const val OP_RETURN_NUM_BYTES_BASE = 55 // OP return script with lokad, tokentype, 'SEND', token id, satoshi value 0 + private const val QUANTITY_NUM_BYTES = 9 // 8 unsigned bytes + size byte + fun tryParse(txId: String, scriptHex: String): SlpOpReturn? { + return tryParse(txId, Script(ByteUtils.Hex.decode(scriptHex))) + } + + private fun tryParse(txId: String, script: Script): SlpOpReturn? { + if (!script.isOpReturn) { + return null + } + val chunks = script.chunks + if (chunks.size < MIN_CHUNKS || !Arrays.equals(LOKAD, chunks[1])) { + return null + } + val tokenType = chunks[2]?.let { SlpTokenType.tryParse(it) } ?: return null + val transactionType = chunks[3]?.let { SlpTransactionType.tryParse(it) } ?: return null + val tokenId = if (transactionType == SlpTransactionType.GENESIS) { + SlpTokenId(txId) + } else { + chunks[4]?.let { SlpTokenId.tryParse(it) } ?: return null + } + return when (transactionType) { + SlpTransactionType.SEND -> SlpOpReturnSend.create(tokenType, tokenId, chunks) + SlpTransactionType.MINT -> SlpOpReturnMint.create(tokenType, tokenId, chunks) + SlpTransactionType.GENESIS -> SlpOpReturnGenesis.create(tokenType, tokenId, chunks) + } + } + + fun sizeInBytes(numQuantities: Int): Int { + return OP_RETURN_NUM_BYTES_BASE + numQuantities * QUANTITY_NUM_BYTES + } + + fun send(tokenId: SlpTokenId, quantities: List): SlpOpReturnSend { + return SlpOpReturnSend(SlpTokenType.PERMISSIONLESS, tokenId, quantities) + } + + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnGenesis.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnGenesis.kt new file mode 100644 index 0000000..cc60b27 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnGenesis.kt @@ -0,0 +1,37 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils +import com.bitcoin.wallet.slp.SlpOpReturn.BatonAndMint + +/** + * Except for containing token details a GENESIS transaction works exactly like MINT + * + * @author akibabu + */ +internal data class SlpOpReturnGenesis( + override val tokenType: SlpTokenType, + override val tokenId: SlpTokenId, + val ticker: String, + val name: String, + val decimals: Int, + override val batonVout: UInt?, // May be null in which case the baton is destroyed + override val mintedAmount: ULong // The minted amount received by vout[1] of this transaction +) : + SlpOpReturn(tokenType, SlpTransactionType.SEND, tokenId), BatonAndMint { + + val toDetails by lazy { SlpTokenDetails(tokenId, ticker, name, decimals) } + + companion object { + + fun create(tokenType: SlpTokenType, tokenId: SlpTokenId, chunks: List): SlpOpReturnGenesis? { + val ticker = chunks[4]?.let { String(it) } ?: "" + val name = chunks[5]?.let { String(it) } ?: "" + val decimals = chunks[8]?.let { ByteUtils.toInt(it) } ?: return null + val batonByte: Byte? = chunks[9]?.let { it.getOrNull(0) } + val mintedAmount = chunks[10]?.let { it }.let { ByteUtils.toULong(it) } ?: return null + return SlpOpReturnGenesis(tokenType, tokenId, ticker, name, decimals, batonByte?.toUInt(), mintedAmount) + } + } + +} + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnMint.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnMint.kt new file mode 100644 index 0000000..f57e8dd --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnMint.kt @@ -0,0 +1,26 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils +import com.bitcoin.wallet.slp.SlpOpReturn.BatonAndMint + +/** + * @author akibabu + */ +internal class SlpOpReturnMint( + tokenType: SlpTokenType, + tokenId: SlpTokenId, + override val batonVout: UInt?, + override val mintedAmount: ULong +) : SlpOpReturn(tokenType, SlpTransactionType.SEND, tokenId), BatonAndMint { + + companion object { + + fun create(tokenType: SlpTokenType, tokenId: SlpTokenId, chunks: List): SlpOpReturnMint? { + val batonByte: Byte? = chunks[4]?.let { it[0] } ?: return null + val mintedAmount = chunks[5]?.let { it }.let { ByteUtils.toULong(it) } ?: return null + return SlpOpReturnMint(tokenType, tokenId, batonByte?.toUInt(), mintedAmount) + } + } + +} + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnSend.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnSend.kt new file mode 100644 index 0000000..866ea7b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpOpReturnSend.kt @@ -0,0 +1,59 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils +import org.bitcoinj.script.ScriptBuilder +import org.bitcoinj.script.ScriptChunk +import org.bitcoinj.script.ScriptOpCodes +import timber.log.Timber +import java.nio.ByteBuffer + +/** + * @author akibabu + */ +internal class SlpOpReturnSend(tokenType: SlpTokenType, tokenId: SlpTokenId, val quantities: List) : + SlpOpReturn(tokenType, SlpTransactionType.SEND, tokenId) { + + init { + if (quantities.isEmpty() || quantities.size > MAX_QUANTITIES) { + throw IllegalArgumentException("SLP SEND with ${quantities.size} quantities") + } + quantities + .filter { it == ULong.MIN_VALUE } + .firstOrNull { throw IllegalArgumentException("0 quantity") } + } + + companion object { + private const val MAX_CHUNKS_SEND = MAX_QUANTITIES + 5 + + fun create(tokenType: SlpTokenType, tokenId: SlpTokenId, chunks: List): SlpOpReturnSend? { + if (chunks.size > MAX_CHUNKS_SEND) { + Timber.w("SLP SEND with more than $MAX_QUANTITIES quantities. Forced to ignore") + return null + } + val quantities = chunks + .filter { chunk -> chunk?.size == ULong.SIZE_BYTES } + .map { ByteUtils.toULong(it!!) } + + return try { + SlpOpReturnSend(tokenType, tokenId, quantities) + } catch (e: IllegalArgumentException) { + Timber.e(e) + null + } + } + } + + fun createScript(): org.bitcoinj.script.Script { + val builder = ScriptBuilder() + .op(ScriptOpCodes.OP_RETURN) + .data(LOKAD) + // .data() would not figure out this is 1 byte push-data + .addChunk(ScriptChunk(tokenType.bytes.size, tokenType.bytes)) + .data(transactionType.bytes) + .data(tokenId.bytes) + quantities.forEach { builder.data(ByteBuffer.allocate(Long.SIZE_BYTES).putLong(it.toLong()).array()) } + return builder.build() + } + +} + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetails.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetails.kt new file mode 100644 index 0000000..5d6d953 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetails.kt @@ -0,0 +1,41 @@ +package com.bitcoin.wallet.slp + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import java.math.BigDecimal + +/** + * @author akibabu + */ +@Entity(tableName = "token_details") +data class SlpTokenDetails( + @PrimaryKey @ColumnInfo(name = "token_id") val tokenId: SlpTokenId, + @ColumnInfo(name = "ticker") val ticker: String, + @ColumnInfo(name = "name") val name: String, + @ColumnInfo(name = "decimals") val decimals: Int) { + + /** + * Example with 6 decimals: 12.53 -> 12530000 + */ + fun toRawAmount(amount: BigDecimal): ULong { + if (amount > maxRawAmount) { + throw IllegalArgumentException("amount larger than 8 unsigned bytes") + } else if (amount.scale() > decimals) { + throw IllegalArgumentException("$ticker supports maximum $decimals decimals but amount is $amount") + } + return amount.scaleByPowerOfTen(decimals).toLong().toULong() + } + + /** + * Example with 6 decimals: 12530000 -> 12.53 + */ + fun toReadableAmount(rawAmount: BigDecimal): BigDecimal { + return rawAmount.scaleByPowerOfTen(-decimals).stripTrailingZeros() + } + + companion object { + val maxRawAmount = BigDecimal(ULong.MAX_VALUE.toString()) + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsDao.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsDao.kt new file mode 100644 index 0000000..78f2011 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsDao.kt @@ -0,0 +1,16 @@ +package com.bitcoin.wallet.slp + +import androidx.room.Dao +import androidx.room.Query +import com.bitcoin.wallet.persistence.DaoBase + +/** + * @author akibabu + */ +@Dao +internal interface SlpTokenDetailsDao : DaoBase { + + @Query("SELECT * FROM token_details where token_id in (:tokenIds)") + fun findByIds(tokenIds: Set): List + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsFacade.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsFacade.kt new file mode 100644 index 0000000..e6fac84 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenDetailsFacade.kt @@ -0,0 +1,55 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.rest.BitcoinRestClient +import com.bitcoin.wallet.rest.TxDetailsRequest +import io.reactivex.Flowable +import io.reactivex.Single +import timber.log.Timber + +/** + * @author akibabu + */ +internal class SlpTokenDetailsFacade(private val dao: SlpTokenDetailsDao, + private val bitcoinClient: BitcoinRestClient) { + + fun getTokenDetails(tokenIds: Set): Single> { + + val storedDetails = dao.findByIds(tokenIds) + + val tokenIdsInRepo = storedDetails.map { it.tokenId }.toSet() + + val txIds = tokenIds + .filter { !tokenIdsInRepo.contains(it) } + .map { it.hex } + + if (txIds.isEmpty()) { + return Single.just(storedDetails) + } + + val singles = txIds + .chunked(20) + .map { TxDetailsRequest(it) } + .map { + bitcoinClient.getTransactions(it) + .retry(3) + .flattenAsFlowable{ txs -> txs + .mapNotNull { SlpOpReturn.tryParse(it.txid, it.vout[0].scriptPubKey.hex) } + } + .filter { slpOpReturn -> + if (slpOpReturn !is SlpOpReturnGenesis) { + Timber.w("Unable to handle genesis tokenId=${slpOpReturn.tokenId}") + return@filter false + } + true + } + .map { (it as SlpOpReturnGenesis).toDetails } + .toList() // Get back to list to be able to save any successful requests as soon as possible + .doOnSuccess { dao.save(*it.toTypedArray()) } + } + .toMutableList() + singles.add(Single.just(storedDetails)) + + return Flowable.concat(singles.map { it.flattenAsFlowable { it } }).toList() + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenId.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenId.kt new file mode 100644 index 0000000..f2eeb72 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenId.kt @@ -0,0 +1,25 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils + +/** + * @author akibabu + */ +data class SlpTokenId(val hex: String) { + + companion object { + fun tryParse(bytes: ByteArray): SlpTokenId? { + if (bytes.size != 32) { + return null + } + return SlpTokenId(ByteUtils.Hex.encode(bytes)) + } + } + + override fun toString(): String { + return hex + } + + val bytes: ByteArray by lazy { ByteUtils.Hex.decode(hex) } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenType.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenType.kt new file mode 100644 index 0000000..c376aa8 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTokenType.kt @@ -0,0 +1,28 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils + +/** + * 1 to 2 byte integer + * + * @author akibabu + */ +internal enum class SlpTokenType(private val value: Int) { + + PERMISSIONLESS(1); + + // We will never have to work with the up to 2 bytes in the specification + val bytes: ByteArray by lazy { byteArrayOf(value.toByte()) } + + companion object { + private val deserializer = SlpTokenType.values().associateBy({ it.value }, { it }) + + fun tryParse(bytes: ByteArray): SlpTokenType? { + if (bytes.isEmpty() || bytes.size > 2) { + return null + } + return deserializer[ByteUtils.toInt(bytes)] + } + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTransactionType.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTransactionType.kt new file mode 100644 index 0000000..32ee1a7 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpTransactionType.kt @@ -0,0 +1,26 @@ +package com.bitcoin.wallet.slp + +/** + * Minimum 4 bytes ASCII + * + * @author akibabu + */ +internal enum class SlpTransactionType(private val text: String) { + + SEND("SEND"), + MINT("MINT"), + GENESIS("GENESIS"); + + val bytes: ByteArray by lazy { text.toByteArray(Charsets.US_ASCII) } + + companion object { + private val deserializer = SlpTransactionType.values().associateBy({ it.text }, { it }) + + fun tryParse(bytes: ByteArray): SlpTransactionType? { + return deserializer[bytes.toString(Charsets.US_ASCII)] + } + } + +} + + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxo.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxo.kt new file mode 100644 index 0000000..3940706 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxo.kt @@ -0,0 +1,16 @@ +package com.bitcoin.wallet.slp + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import androidx.room.Entity +import com.bitcoin.wallet.tx.Utxo +import java.math.BigDecimal + +/** + * @author akibabu + */ +@Entity(tableName = "slp_utxo", primaryKeys = ["tx_id", "index"]) +internal data class SlpUtxo( + @ColumnInfo(name = "token_id") val tokenId: SlpTokenId, + @ColumnInfo(name = "num_tokens") val numTokensRaw: BigDecimal, + @Embedded val utxo: Utxo) diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxoDao.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxoDao.kt new file mode 100644 index 0000000..455b59d --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpUtxoDao.kt @@ -0,0 +1,22 @@ +package com.bitcoin.wallet.slp + +import androidx.room.Dao +import androidx.room.Query +import com.bitcoin.wallet.persistence.DaoBase + +/** + * @author akibabu + */ +@Dao +internal interface SlpUtxoDao : DaoBase { + + @Query("DELETE FROM slp_utxo WHERE tx_id = :txId AND `index` = :index") + fun delete(txId: String, index: Int) + + @Query("SELECT * FROM slp_utxo") + fun findAll(): List + + @Query("SELECT tx_id FROM slp_utxo") + fun findAllTxIds(): List + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTx.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTx.kt new file mode 100644 index 0000000..23a240b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTx.kt @@ -0,0 +1,15 @@ +package com.bitcoin.wallet.slp + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * @author akibabu + */ +@Entity(tableName = "slp_valid_tx") +data class SlpValidTx( + @PrimaryKey @ColumnInfo(name = "tx_id") val txId: String, + @ColumnInfo(name = "valid") val valid: Boolean) + + diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTxDao.kt b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTxDao.kt new file mode 100644 index 0000000..0011012 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/slp/SlpValidTxDao.kt @@ -0,0 +1,16 @@ +package com.bitcoin.wallet.slp + +import androidx.room.Dao +import androidx.room.Query +import com.bitcoin.wallet.persistence.DaoBase + +/** + * @author akibabu + */ +@Dao +internal interface SlpValidTxDao : DaoBase { + + @Query("SELECT * FROM slp_valid_tx where tx_id in (:txIds)") + fun findByIds(txIds: Set): List + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/tx/Script.kt b/slpwallet/src/main/java/com/bitcoin/wallet/tx/Script.kt new file mode 100644 index 0000000..1f64f15 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/tx/Script.kt @@ -0,0 +1,23 @@ +package com.bitcoin.wallet.tx + +/** + * @author akibabu + */ +internal class Script { + + private val script: org.bitcoinj.script.Script + val bytes: ByteArray + + constructor(bytes: ByteArray) : this(org.bitcoinj.script.Script(bytes)) + + constructor(script: org.bitcoinj.script.Script) { + this.script = script + this.bytes = script.program + } + + val isOpReturn: Boolean + get() = script.isOpReturn + val chunks: List + get() = script.chunks.map { it.data } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/tx/TxBuilder.kt b/slpwallet/src/main/java/com/bitcoin/wallet/tx/TxBuilder.kt new file mode 100644 index 0000000..1987e28 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/tx/TxBuilder.kt @@ -0,0 +1,94 @@ +package com.bitcoin.wallet.tx + +import com.bitcoin.wallet.address.Address +import com.bitcoin.wallet.encoding.ByteUtils +import com.bitcoin.wallet.slp.SLPWalletImpl +import com.bitcoin.wallet.slp.SlpOpReturnSend +import org.bitcoinj.core.* +import org.bitcoinj.core.Transaction.SigHash +import org.bitcoinj.script.Script +import org.bitcoinj.script.ScriptBuilder +import timber.log.Timber + +/** + * @author akibabu + */ +internal class TxBuilder(val wallet: SLPWalletImpl) { + + val params = wallet.network.instance.parameters + + // Our source of UTXO's come with the cash address so we use that also to find the SLP key + val cashAddressKeyMap = mapOf( + Pair(wallet.bchAddressKey.address.toCash().toString(), ECKey.fromPrivate(wallet.bchAddressKey.privateKey)), + Pair(wallet.slpKeyAddress.address.toCash().toString(), ECKey.fromPrivate(wallet.slpKeyAddress.privateKey))) + + internal inner class Builder { + val tx = Transaction(params) + + // We need to sign everything last so store the input together with address so we can find the right key + inner class InputWithAddress(val address: String, val input: TransactionInput) + private val inputs = ArrayList() + + init { + tx.setVersion(2) + } + + fun serialize(): String { + inputs.forEach { tx.addInput(it.input) } + inputs.forEachIndexed { index, inputWithAddress -> run { + tx.getInput(index.toLong()).scriptSig = inputSignature(index, inputWithAddress.input, + inputWithAddress.address) + } } + Timber.d("Serialized tx $tx") + tx.verify() + return ByteUtils.Hex.encode(tx.bitcoinSerialize()) + } + + fun addInput(utxo: Utxo): Builder { + inputs.add(InputWithAddress(utxo.cashAddress, createInput(utxo))) + return this + } + + fun addOutput(satoshi: Long, address: Address): Builder { + tx.addOutput(Coin.valueOf(satoshi), org.bitcoinj.core.Address.fromBase58(params, address.toBase58())) + return this + } + + fun addOutput(opReturn: SlpOpReturnSend): Builder { + tx.addOutput(Coin.ZERO, opReturn.createScript()) + return this + } + + private fun inputSignature(inputIndex: Int, input: TransactionInput, cashAddress: String): Script { + val privateKey = cashAddressKeyMap.getValue(cashAddress) + val signature = tx.calculateWitnessSignature( + inputIndex, privateKey, + input.scriptSig.chunks[0].data, + input.value, SigHash.ALL, false) + return ScriptBuilder() + .data(signature.encodeToBitcoin()) + .data(privateKey.pubKeyPoint.getEncoded(true)) + .build() + } + + private fun createInput(utxo: Utxo): TransactionInput { + val inputScript = ScriptBuilder() + .data(utxo.scriptBytes) + .build().program + return TransactionInput( + params, tx, inputScript, + TransactionOutPoint(params, utxo.index.toLong(), Sha256Hash.wrap(utxo.txId)), + Coin.valueOf(utxo.satoshi)) + } + + } + + companion object { + fun outputFee(numOutputs: Int): Long { + return numOutputs.toLong() * 34 + } + + const val DUST_LIMIT: Long = 546 + } + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/tx/Utxo.kt b/slpwallet/src/main/java/com/bitcoin/wallet/tx/Utxo.kt new file mode 100644 index 0000000..eaeed4b --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/tx/Utxo.kt @@ -0,0 +1,22 @@ +package com.bitcoin.wallet.tx + +import androidx.room.ColumnInfo +import androidx.room.Entity +import com.bitcoin.wallet.encoding.ByteUtils + +/** + * @author akibabu + */ +@Entity(tableName = "utxo", primaryKeys = ["tx_id", "index"]) +internal data class Utxo( + @ColumnInfo(name = "tx_id") val txId: String, + @ColumnInfo(name = "index") val index: Int, + @ColumnInfo(name = "cash_address") val cashAddress: String, + @ColumnInfo(name = "script_hex") val scriptHex: String, + @ColumnInfo(name = "satoshi") val satoshi: Long +) { + + val scriptBytes: ByteArray + get() = ByteUtils.Hex.decode(scriptHex) + +} diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoDao.kt b/slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoDao.kt new file mode 100644 index 0000000..9091119 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoDao.kt @@ -0,0 +1,22 @@ +package com.bitcoin.wallet.tx + +import androidx.room.Dao +import androidx.room.Query +import com.bitcoin.wallet.persistence.DaoBase + +/** + * @author akibabu + */ +@Dao +internal interface UtxoDao : DaoBase { + + @Query("DELETE FROM utxo WHERE tx_id = :txId AND `index` = :index") + fun delete(txId: String, index: Int) + + @Query("SELECT * FROM utxo") + fun findAll(): List + + @Query("SELECT tx_id FROM utxo") + fun findAllTxIds(): List + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoFacade.kt b/slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoFacade.kt new file mode 100644 index 0000000..40033f8 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/tx/UtxoFacade.kt @@ -0,0 +1,215 @@ +package com.bitcoin.wallet.tx + +import com.bitcoin.wallet.rest.* +import com.bitcoin.wallet.slp.* +import org.bitcoinj.core.Coin +import timber.log.Timber +import java.math.BigDecimal + +/** + * @author akibabu + */ +internal class UtxoFacade(private val wallet: SLPWalletImpl, private val bitcoinClient: BitcoinRestClient, + private val utxoDao: UtxoDao, private val slpUtxoDao: SlpUtxoDao, + private val validTxDao: SlpValidTxDao +) { + + private val addressUtxosRequest = AddressUtxosRequest(listOf( + wallet.bchAddressKey.address.toCash(), + wallet.slpKeyAddress.address.toCash() + )) + + internal data class Utxos(val bchUtxos: List, val slpUtxos: List) + + private fun getUtxosOfWalletAddresses(): List { + return bitcoinClient.getUtxos(addressUtxosRequest) + .retry(3) + .blockingGet() + // Put address on each UTXO so we can easier distinguish between BCH and SLP + .flatMap { it.flatMapEnrichWithAddress() } + } + + private fun getUnseenTxDetails(txIds: Set): List { + val seenTxIds = utxoDao.findAllTxIds().plus(slpUtxoDao.findAllTxIds()).toSet() + return txIds + .filter { !seenTxIds.contains(it) } + .chunked(20) + .map { TxDetailsRequest(it) } + .flatMap { + bitcoinClient.getTransactions(it) + .retry(3) + .blockingGet() + } + } + + @Synchronized + fun getUtxos(): Utxos { + // Get all UTXO's for our 2 addresses + val utxosRaw: List = getUtxosOfWalletAddresses() + + val currentUtxos = utxoDao.findAll() + val currentSlpUtxos = slpUtxoDao.findAll() + + // Later we are forced by the API to fetch the entire TX, so we need to know what UTXO's in each TX are ours + val voutsInTx = utxosRaw + .groupBy { it.txid } + .mapValues { + it.value + .map { it.vout } + .toSet() + } + + currentUtxos.forEach { + if (!(voutsInTx[it.txId] ?: emptySet()).contains(it.index)) { + utxoDao.delete(it.txId, it.index) + } + } + + currentSlpUtxos.forEach { + if (!(voutsInTx[it.utxo.txId] ?: emptySet()).contains(it.utxo.index)) { + slpUtxoDao.delete(it.utxo.txId, it.utxo.index) + } + } + + /* + * TX details doesn't come with a reliable output address so we need a lookup from (txid, output index) -> address + * Output index is included to account for the theoretical possibility of having both our addresses present in the same TX + */ + val txIdToVoutToCashAddressLookup = utxosRaw + .groupBy { it.txid } + .mapValues { + it.value + .groupBy { it.vout } + .mapValues { + if (it.value.size != 1) { + throw IllegalStateException("Expecting exactly one UTXO per (txid,vout) key") + } + it.value[0].cashAddress!! + } + } + + // Ignore all tx's we already stored the utxo's for + val txs = getUnseenTxDetails(voutsInTx.keys) + + val utxos = txs.flatMap { tx -> + tx.vout + .filter { + // Keep our utxo's plus any valid SLP op returns + val isOurUtxo = voutsInTx[tx.txid]?.contains(it.n) ?: false + val isSlpOpReturn = SlpOpReturn.tryParse(tx.txid, it.scriptPubKey.hex) is SlpOpReturnSend + isOurUtxo || isSlpOpReturn + } + .map { output -> + val address = txIdToVoutToCashAddressLookup.getValue(tx.txid).getOrElse(output.n) { + // When we reach an SLP op return we don't have an address, so we assume the SLP address + // TODO a full validation could happen here, i.e ensure output 1 is sent to the SLP address... + if (output.n != 0) { + throw IllegalStateException("txid=${tx.txid} output=${output.n} without known address") + } + wallet.slpAddressAsCash + } + Utxo(tx.txid, output.n, address, output.scriptPubKey.hex, Coin.parseCoin(output.value).value) + } + } + + val parsedUtxos = parseUtxos(utxos) + utxoDao.save(parsedUtxos.bchUtxos) + slpUtxoDao.save(parsedUtxos.slpUtxos) + return parsedUtxos + } + + private fun parseUtxos(utxos: List): Utxos { + val nativeUtxos = utxoDao.findAll().toMutableList() + val tokenUtxos = slpUtxoDao.findAll().toMutableList() + + val utxoEntries = utxos + .groupBy { it.txId } + .mapValues { it.value + .groupBy { it.index } + .mapValues { it.value[0] } + } + + utxoEntries.forEach { + val slpScript = it.value[0]?.scriptHex?.let { script -> SlpOpReturn.tryParse(it.key, script) } + + if (slpScript == null) { + // Because the store only holds our utxo's and SLP scripts, these are all our utxo's + nativeUtxos.addAll(it.value.values) + } else { + if (slpScript.transactionType == SlpTransactionType.SEND) { + + // Add all SLP send quantities that are sent to us (likely 1 but in theory several or 0) + val sendOutput: SlpOpReturnSend = slpScript as SlpOpReturnSend + val tokenUtxosInTx = sendOutput.quantities + // TODO ignoring non existing utxos which is a protocol breach + .mapIndexedNotNull { index, numTokensRaw -> + // +1 skip OP_RETURN + it.value[index + 1]?.let { utxo -> + SlpUtxo(slpScript.tokenId, BigDecimal(numTokensRaw.toString()), utxo) + } + } + tokenUtxos.addAll(tokenUtxosInTx) + + // Add utxo's coming after the token quantity utxos + it.value.values + .filter { utxo: Utxo -> + val isMyAddress = wallet.isMyCashAddress(utxo.cashAddress) + val exceedsSlpUtxosSpecified: Boolean = utxo.index > sendOutput.quantities.size + isMyAddress && exceedsSlpUtxosSpecified + } + .forEach { nativeUtxo: Utxo -> + nativeUtxos.add(nativeUtxo) + } + } else if (slpScript is SlpOpReturn.BatonAndMint) { // Handle GENESIS and MINT the same way + val batonAndMint = slpScript as SlpOpReturn.BatonAndMint + // We are the receiver of the minted value which is always vout[1]. Add it as a token utxo + it.value[1]?.let { utxo -> + SlpUtxo(slpScript.tokenId, BigDecimal(batonAndMint.mintedAmount.toString()), utxo) + }?.let { tokenUtxos.add(it) } + + // Add any potential non SLP utxos, most importantly excluding the baton if it was not destroyed + it.value + .filterKeys { it > 1 && it.toUInt() != batonAndMint.batonVout } + .forEach { nativeUtxos.add(it.value) } + } + } + } + return Utxos(nativeUtxos, filterValidSlpUtxos(tokenUtxos)) + } + + private fun filterValidSlpUtxos(utxos: List): List { + val txIds = utxos.map { it.utxo.txId } + .toSet() + + val txIdsValidity = validTxDao.findByIds(txIds) + .groupBy { it.txId } + .mapValues { it.value[0] } // txId is primary key + .toMutableMap() + + val newTxIdsValidity = txIds + .filter { !txIdsValidity.contains(it) } + .chunked(20) + .flatMap { + bitcoinClient.validateSlpTxs(SlpValidateTxRequest(it)) + .retry(3) + .blockingGet() + } + .groupBy { it.txid } + .mapValues { SlpValidTx(it.key, it.value[0].valid) } + + validTxDao.save(newTxIdsValidity.values) + + txIdsValidity.putAll(newTxIdsValidity) + return utxos + .filter { + if (txIdsValidity[it.utxo.txId]?.valid == true) { + return@filter true + } else { + // Ignore invalid tx's, using them as BCH balance is too uncertain + Timber.w("Invalid SLP txid=${it.utxo.txId}") + return@filter false + } + } + } + +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/util/SingletonHolder.kt b/slpwallet/src/main/java/com/bitcoin/wallet/util/SingletonHolder.kt new file mode 100644 index 0000000..8334b0e --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/util/SingletonHolder.kt @@ -0,0 +1,25 @@ +package com.bitcoin.wallet.util + +open class SingletonHolder(creator: (A) -> T) { + private var creator: ((A) -> T)? = creator + @Volatile private var instance: T? = null + + fun getInstance(arg: A): T { + val i = instance + if (i != null) { + return i + } + + return synchronized(this) { + val i2 = instance + if (i2 != null) { + i2 + } else { + val created = creator!!(arg) + instance = created + creator = null + created + } + } + } +} \ No newline at end of file diff --git a/slpwallet/src/main/java/com/bitcoin/wallet/util/TaskScheduler.kt b/slpwallet/src/main/java/com/bitcoin/wallet/util/TaskScheduler.kt new file mode 100644 index 0000000..1dcae95 --- /dev/null +++ b/slpwallet/src/main/java/com/bitcoin/wallet/util/TaskScheduler.kt @@ -0,0 +1,121 @@ +package com.bitcoin.wallet.util + +/* + * Copyright 2018 Google LLC + * + * 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 + * + * https://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. + */ + +import android.os.Handler +import android.os.Looper +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors + +private const val NUMBER_OF_THREADS = 4 // TODO: Make this depend on device's hw + +interface Scheduler { + + fun execute(task: () -> Unit) + + fun postToMainThread(task: () -> Unit) + + fun postDelayedToMainThread(delay: Long, task: () -> Unit) +} + +/** + * A shim [Scheduler] that by default handles operations in the [AsyncScheduler]. + */ +object DefaultScheduler : Scheduler { + + private var delegate: Scheduler = AsyncScheduler + + /** + * Sets the new delegate scheduler, null to revert to the default async one. + */ + fun setDelegate(newDelegate: Scheduler?) { + delegate = newDelegate ?: AsyncScheduler + } + + override fun execute(task: () -> Unit) { + delegate.execute(task) + } + + override fun postToMainThread(task: () -> Unit) { + delegate.postToMainThread(task) + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + delegate.postDelayedToMainThread(delay, task) + } +} + +/** + * Runs tasks in a [ExecutorService] with a fixed thread of pools + */ +internal object AsyncScheduler : Scheduler { + + private val executorService: ExecutorService = Executors.newFixedThreadPool(NUMBER_OF_THREADS) + + override fun execute(task: () -> Unit) { + executorService.execute(task) + } + + override fun postToMainThread(task: () -> Unit) { + if (isMainThread()) { + task() + } else { + val mainThreadHandler = Handler(Looper.getMainLooper()) + mainThreadHandler.post(task) + } + } + + private fun isMainThread(): Boolean { + return Looper.getMainLooper().thread === Thread.currentThread() + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + val mainThreadHandler = Handler(Looper.getMainLooper()) + mainThreadHandler.postDelayed(task, delay) + } +} + +/** + * Runs tasks synchronously. + */ +object SyncScheduler : Scheduler { + private val postDelayedTasks = mutableListOf<() -> Unit>() + + override fun execute(task: () -> Unit) { + task() + } + + override fun postToMainThread(task: () -> Unit) { + task() + } + + override fun postDelayedToMainThread(delay: Long, task: () -> Unit) { + postDelayedTasks.add(task) + } + + fun runAllScheduledPostDelayedTasks() { + val tasks = postDelayedTasks.toList() + clearScheduledPostdelayedTasks() + for (task in tasks) { + task() + } + } + + fun clearScheduledPostdelayedTasks() { + postDelayedTasks.clear() + } +} diff --git a/slpwallet/src/main/res/values/strings.xml b/slpwallet/src/main/res/values/strings.xml new file mode 100644 index 0000000..fd64697 --- /dev/null +++ b/slpwallet/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + SLPWallet + + Cannot use Secure Preferences because the device does not have a screen lock. + diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/WalletDatabaseInMemory.kt b/slpwallet/src/test/java/com/bitcoin/wallet/WalletDatabaseInMemory.kt new file mode 100644 index 0000000..12dfd3f --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/WalletDatabaseInMemory.kt @@ -0,0 +1,148 @@ +package com.bitcoin.wallet + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.bitcoin.wallet.persistence.DaoBase +import com.bitcoin.wallet.persistence.SlpTokenBalance +import com.bitcoin.wallet.persistence.SlpTokenBalanceDao +import com.bitcoin.wallet.persistence.WalletDatabase +import com.bitcoin.wallet.slp.* +import com.bitcoin.wallet.tx.Utxo +import com.bitcoin.wallet.tx.UtxoDao +import java.util.concurrent.ConcurrentHashMap + +/** + * @author akibabu + */ +internal class WalletDatabaseInMemory : WalletDatabase { + + override fun slpValidTxDao(): SlpValidTxDao { + return SlpValidTxDaoInMemory() + } + + override fun tokenBalanceDao(): SlpTokenBalanceDao { + return TokenBalanceDaoInMemory() + } + + override fun utxoDao(): UtxoDao { + return UtxoDaoInMemory() + } + + override fun slpUtxoDao(): SlpUtxoDao { + return SlpUtxoDaoInMemory() + } + + override fun slpTokenDetailsDao(): SlpTokenDetailsDao { + return SlpTokenDetailsDaoInMemory() + } + + override fun newWalletClear() { + + } + + override fun awaitReady(): Boolean { + return true + } +} + +internal abstract class DaoInMemoryBase : DaoBase { + + override fun save(vararg values: T) { + save(values.toList()) + } +} + +internal class SlpValidTxDaoInMemory : SlpValidTxDao, DaoInMemoryBase() { + + private val repo: MutableMap = ConcurrentHashMap() + + override fun findByIds(txIds: Set): List { + return txIds.mapNotNull { repo[it] } + } + + override fun save(values: Collection) { + values.forEach { repo[it.txId] = it } + } + +} + +internal class SlpTokenDetailsDaoInMemory : SlpTokenDetailsDao, DaoInMemoryBase() { + + private val repo: MutableMap = ConcurrentHashMap() + + override fun findByIds(tokenIds: Set): List { + return tokenIds + .mapNotNull { repo[it] } + } + + override fun save(details: Collection) { + details.forEach { + repo[it.tokenId] = it + } + } +} + +internal class SlpUtxoDaoInMemory : SlpUtxoDao, DaoInMemoryBase() { + + private val repo: MutableMap, SlpUtxo> = ConcurrentHashMap() + + override fun delete(txId: String, index: Int) { + repo.remove(Pair(txId, index)) + } + + override fun findAll(): List { + return repo.values.toList() + } + + override fun findAllTxIds(): List { + return repo.values.map { it.utxo.txId } + } + + override fun save(utxos: Collection) { + utxos.forEach { + repo[Pair(it.utxo.txId, it.utxo.index)] = it + } + } +} + +internal class UtxoDaoInMemory : UtxoDao, DaoInMemoryBase() { + + private val repo: MutableMap, Utxo> = ConcurrentHashMap() + + override fun delete(txId: String, index: Int) { + repo.remove(Pair(txId, index)) + } + + override fun findAll(): List { + return repo.values.toList() + } + + override fun findAllTxIds(): List { + return repo.values.map { it.txId } + } + + override fun save(utxos: Collection) { + utxos.forEach { + repo[Pair(it.txId, it.index)] = it + } + } +} + +internal class TokenBalanceDaoInMemory : SlpTokenBalanceDao { + + override fun delete(vararg tokenId: String): Int { + return 0 + } + + override fun getBalances(): List { + return listOf() + } + + override fun getBalancesLive(): LiveData> { + return MutableLiveData() + } + + override fun upsertBalance(vararg balance: SlpTokenBalance) { + } + +} \ No newline at end of file diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/WalletServiceImplTest.kt b/slpwallet/src/test/java/com/bitcoin/wallet/WalletServiceImplTest.kt new file mode 100644 index 0000000..ea0acbf --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/WalletServiceImplTest.kt @@ -0,0 +1,163 @@ +package com.bitcoin.wallet + +import com.bitcoin.wallet.rest.BitcoinRestClientMock +import com.bitcoin.wallet.rest.BitcoinRestClientMockAarAngBch +import com.bitcoin.wallet.rest.BitcoinRestClientMockAarBch +import com.bitcoin.wallet.slp.SlpTokenDetails +import com.bitcoin.wallet.slp.SlpTokenId +import com.bitcoin.wallet.tx.TxBuilder +import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.math.BigDecimal + +/** + * @author akibabu + */ +class WalletServiceImplTest { + + @Test + fun testSameBalanceAsExplorer() { + val client = BitcoinRestClientMock() + val service = WalletServiceImpl(client.wallet, client, WalletDatabaseInMemory()) + val tokenBalances = client.tokenBalances + + // Run 3 times to test the UTXOStore of corruption + repeat(3) { + val (bch, tokens) = service.refreshBalance().blockingGet() + // explorer.bitcoin.com includes the dust limit for a token which we don't + //assertEquals(client.bchBalance, bch + TxBuilder.DUST_LIMIT) // TODO calculate what we expect on bch path + + assertEquals(3, tokens.size) + tokens.forEach { (details, balance) -> + run { + assertEquals(tokenBalances[details.tokenId.hex], balance) + } + } + } + } + + @Test + fun sendToken_insufficientBalance() { + val client = BitcoinRestClientMock() + val service = WalletServiceImpl(client.wallet, client, WalletDatabaseInMemory()) + val xrpToken = SlpTokenId("263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e") + val tokenDetails = SlpTokenDetails(xrpToken, "XRP", "Ripple", 6) + val tokenBalance = service.refreshBalance().blockingGet().tokenBalance[tokenDetails] + + service.sendTokenUtxoSelection(xrpToken, tokenBalance!!.plus(BigDecimal.ONE)) + .subscribe({ Assert.fail("succeeded") }, { + assertTrue(it.message!!.contains("insufficient")) + + }) + } + + @Test + fun sendToken_sendEntireBalance() { + val client = BitcoinRestClientMock() + val service = WalletServiceImpl(client.wallet, client, WalletDatabaseInMemory()) + val xrpToken = SlpTokenId("263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e") + val tokenDetails = SlpTokenDetails(xrpToken, "XRP", "Ripple", 6) + val tokenBalance = service.refreshBalance().blockingGet().tokenBalance[tokenDetails] + + service.sendTokenUtxoSelection(xrpToken, tokenBalance!!) + .subscribe({ }, { + it.printStackTrace() + Assert.fail("couldn't send") + }) + } + + @Test + fun sendToken_assertSelection() { + val client = BitcoinRestClientMock() + val service = WalletServiceImpl(client.wallet, client, WalletDatabaseInMemory()) + val xrpToken = SlpTokenId("263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e") + service.refreshBalance().blockingGet() + val tokenDetails = SlpTokenDetails(xrpToken, "XRP", "Ripple", 6) + val tokenBalance = service.refreshBalance().blockingGet().tokenBalance[tokenDetails] + val sendAmount = BigDecimal("25.5") + + val selection = service.sendTokenUtxoSelection(xrpToken, sendAmount).blockingGet() + // Check token amount to send and change + assertEquals(selection.quantities[0], tokenDetails.toRawAmount(sendAmount)) + assertEquals(selection.quantities[1], tokenDetails.toRawAmount(tokenBalance!!.minus(sendAmount))) + + // Sanity check BCH cost is reasonable + val selectedSatoshis = selection.selectedUtxos.map { it.satoshi }.sum() + val netLossBch = selectedSatoshis - selection.changeSatoshi + assertTrue(netLossBch > TxBuilder.DUST_LIMIT + 200) + assertTrue(netLossBch < TxBuilder.DUST_LIMIT + 2000) + } + + @Test + fun givenSlpUtxoNearDustLimit_whenSendToken_thenHaveOutputForTokenChange() { + // GIVEN + val client = BitcoinRestClientMockAarBch() + val service = WalletServiceImpl(client.wallet, client, WalletDatabaseInMemory()) + val tokenId = SlpTokenId("b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772") + + // WHEN + val sendAmount = BigDecimal("1.1") + val selection: WalletServiceImpl.SendTokenUtxoSelection = service.sendTokenUtxoSelection(tokenId, sendAmount).blockingGet() + + // THEN + // Check token amount to send and change + assertEquals(110.toULong(), selection.quantities[0]) + assertEquals(572.toULong(), selection.quantities[1]) + + assertEquals(2, selection.selectedUtxos.size) + // Sanity check BCH cost is reasonable + val selectedSatoshis = selection.selectedUtxos.map { it.satoshi }.sum() + val netLossBch = selectedSatoshis - selection.changeSatoshi + assertTrue(netLossBch > TxBuilder.DUST_LIMIT + 200) + assertTrue(netLossBch < TxBuilder.DUST_LIMIT + 2000) + } + + @Test + fun givenSlpUtxoNearDustLimitAndBchAndOtherTokenUtxo_whenSendToken_thenBchUsedAndOtherTokenRemains() { + // GIVEN + val client = BitcoinRestClientMockAarAngBch() + val service = WalletServiceImpl(client.wallet, client, WalletDatabaseInMemory()) + val tokenId = SlpTokenId("b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772") + + // WHEN + val sendAmount = BigDecimal("1.1") + val selection: WalletServiceImpl.SendTokenUtxoSelection = service.sendTokenUtxoSelection(tokenId, sendAmount).blockingGet() + + // THEN + // Check token amount to send and change + assertEquals(110.toULong(), selection.quantities[0]) + assertEquals(572.toULong(), selection.quantities[1]) + + val availableUxtos = mapOf( + "AAR" to mapOf( + "txid" to "71c81c6904d517d9057ec76b5b8188401df479ff7673a3c3ea7128480f54ca44", + "vout" to 2 + ), + "ANG" to mapOf( + "txid" to "2460c85e7f782bd3b7c9816687c6c2736900367fd1c6efee60953c3a23f010ac", + "vout" to 2 + ), + "BCH" to mapOf( + "txid" to "4e9a367ef6a1692a6a76e670d131ee13c3e5810b2373bc20a0e91d6710479a18", + "vout" to 1 + ) + ) + + assertEquals(2, selection.selectedUtxos.size) + + assertEquals(availableUxtos["AAR"]?.get("txid") as String, selection.selectedUtxos[0].txId) + assertEquals(availableUxtos["AAR"]?.get("vout") as Int, selection.selectedUtxos[0].index) + + assertEquals(availableUxtos["BCH"]?.get("txid") as String, selection.selectedUtxos[1].txId) + assertEquals(availableUxtos["BCH"]?.get("vout") as Int, selection.selectedUtxos[1].index) + + // Sanity check BCH cost is reasonable + val selectedSatoshis = selection.selectedUtxos.map { it.satoshi }.sum() + val netLossBch = selectedSatoshis - selection.changeSatoshi + assertTrue(netLossBch > TxBuilder.DUST_LIMIT + 200) + assertTrue(netLossBch < TxBuilder.DUST_LIMIT + 2000) + } + +} \ No newline at end of file diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/address/AddressCashTest.kt b/slpwallet/src/test/java/com/bitcoin/wallet/address/AddressCashTest.kt new file mode 100644 index 0000000..1905eb3 --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/address/AddressCashTest.kt @@ -0,0 +1,55 @@ +package com.bitcoin.wallet.address + +import com.bitcoin.wallet.Network +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * @author akibabu + */ +class AddressCashTest { + + private val network = Network.MAIN + private val addressSlp = "simpleledger:qrkn34tllug35tfs655e649asx4udw4dcc3lrh0wxa" + private val addressCash = "bitcoincash:qrkn34tllug35tfs655e649asx4udw4dccaygv6wcr" + private val addressLegacy = "1NdKEMnDXhUeyCLERGkJxZMEQeKG7CsUd9" + + @Test(expected = AddressFormatException::class) + fun parseSLPAddressFails() { + AddressCash.parse(network, addressSlp) + } + + @Test(expected = AddressFormatException::class) + fun parseLegacyAddressFails() { + AddressCash.parse(network, addressLegacy) + } + + @Test + fun parseAddressCashFromAddress() { + val address = Address.parse(network, addressCash) + assertTrue(address is AddressCash) + assertEquals(addressCash, address.toString()) + } + + @Test + fun parseAddressCash() { + val address = AddressCash.parse(network, addressCash) + assertEquals(addressCash, address.toString()) + } + + @Test + fun addressCashToAddressLegacy() { + val address = AddressCash.parse(network, addressCash).toLegacy() + assertEquals(addressLegacy, address.toString()) + assertEquals(addressLegacy, address.toBase58()) + } + + @Test + fun addressCashToAddressSlp() { + val address = AddressCash.parse(network, addressCash).toSlp() + assertEquals(addressSlp, address.toString()) + assertEquals(addressLegacy, address.toBase58()) + } + +} \ No newline at end of file diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/address/AddressSLPTest.kt b/slpwallet/src/test/java/com/bitcoin/wallet/address/AddressSLPTest.kt new file mode 100644 index 0000000..9c3bba5 --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/address/AddressSLPTest.kt @@ -0,0 +1,57 @@ +package com.bitcoin.wallet.address + +import com.bitcoin.wallet.Network +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * @author akibabu + */ +class AddressSLPTest { + + private val network = Network.MAIN + private val addressSlp = "simpleledger:qrkn34tllug35tfs655e649asx4udw4dcc3lrh0wxa" + private val addressCash = "bitcoincash:qrkn34tllug35tfs655e649asx4udw4dccaygv6wcr" + private val addressLegacy = "1NdKEMnDXhUeyCLERGkJxZMEQeKG7CsUd9" + + @Test(expected = AddressFormatException::class) + fun parseCashAddressFails() { + AddressSLP.parse(network, addressCash) + } + + @Test(expected = AddressFormatException::class) + fun parseLegacyAddressFails() { + AddressSLP.parse(network, addressLegacy) + } + + @Test + fun parseAddressSlpFromAddress() { + val address = Address.parse(network, addressSlp) + assertTrue(address is AddressSLP) + assertEquals(addressSlp, address.toString()) + assertEquals(addressLegacy, address.toBase58()) + } + + @Test + fun parseAddressSlp() { + val address = AddressSLP.parse(network, addressSlp) + assertEquals(addressSlp, address.toString()) + assertEquals(addressLegacy, address.toBase58()) + } + + @Test + fun addressSlpToAddressLegacy() { + val legacy = AddressSLP.parse(network, addressSlp).toLegacy() + assertEquals(addressLegacy, legacy.toString()) + assertEquals(addressLegacy, legacy.toBase58()) + } + + @Test + fun addressSlpToAddressCash() { + val cash = AddressSLP.parse(network, addressSlp).toCash() + assertEquals(addressCash, cash.toString()) + assertEquals(addressLegacy, cash.toBase58()) + } + +} diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/bitcoinj/MnemonicTest.kt b/slpwallet/src/test/java/com/bitcoin/wallet/bitcoinj/MnemonicTest.kt new file mode 100644 index 0000000..30d94ce --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/bitcoinj/MnemonicTest.kt @@ -0,0 +1,33 @@ +package com.bitcoin.wallet.bitcoinj + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.address.Address +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +class MnemonicTest { + + @Test + fun generate() { + assertEquals(12, Mnemonic.generate().mnemonic.size) + } + + @Test(expected = IllegalArgumentException::class) + fun requireExactly12Words() { + Mnemonic(listOf("poem", "ranch")) + } + + @Test + fun toPrivatePublicPair() { + val words = "poem ranch right divorce orphan swim join bleak theme punch feature place".split(" ") + // private key known to work on livenet + val expectedPrivateKey = BigInteger("1177172391896708055110291093735331024855236963476189947481813722291187889324") + val expectedAddress = Address.parse(Network.MAIN, "bitcoincash:qpfsv39800lr75qzhwqjzqtqlqp9r4pk4spq58nxdk") + + val bchAddress = Mnemonic(words).getAddress(Network.MAIN, 44, 245, 0) + + assertEquals(expectedAddress.toSlp(), bchAddress.address.toSlp()) + assertEquals(expectedPrivateKey, bchAddress.privateKey) + } +} \ No newline at end of file diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMock.kt b/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMock.kt new file mode 100644 index 0000000..ea3944f --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMock.kt @@ -0,0 +1,61 @@ +package com.bitcoin.wallet.rest + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.WalletDatabaseInMemory +import com.bitcoin.wallet.address.Address +import com.bitcoin.wallet.bitcoinj.Mnemonic +import com.bitcoin.wallet.slp.SLPWalletImpl +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.reactivex.Single +import java.math.BigDecimal + +/** + * Snapshot of and address with expected balances + * + * @author akibabu + */ +internal class BitcoinRestClientMock : BitcoinRestClient { + + val mnemonic = "poem ranch right divorce orphan swim join bleak theme punch feature place".split(" ") + + val address = Address.parse(Network.MAIN, "bitcoincash:qpfsv39800lr75qzhwqjzqtqlqp9r4pk4spq58nxdk") + + // Taken from explorer.bitcoin.com at the time + val tokenBalances = mapOf( + "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503" to BigDecimal("16"), + "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d" to BigDecimal("23456"), + "263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e" to BigDecimal("799956") + ) + val bchBalance = BigDecimal("0.01067926").scaleByPowerOfTen(8).toLong() + + val wallet = SLPWalletImpl(Network.MAIN, Mnemonic(mnemonic), WalletDatabaseInMemory()) + private val gson = Gson() + + + override fun getUtxos(request: AddressUtxosRequest): Single> { + val json = resource("rest_utxos.json") + return Single.just(gson.fromJson>(json, object: TypeToken>() {}.type)) + } + + override fun sendRawTransaction(hex: String): Single { + return Single.just("f0bb52d0023e3a81d6d95c4772ae003541554da6f2a962ddd77f540e6181d9cd") + } + + override fun getTransactions(request: TxDetailsRequest): Single> { + val json = resource("rest_txdetails.json") + val response = gson.fromJson>(json, object : TypeToken>() {}.type) + return Single.just(response) + } + + override fun validateSlpTxs(request: SlpValidateTxRequest): Single> { + val response = request.txids + .map { SlpValidateTxResponse(it, true) } + return Single.just(response) + } + + private fun resource(filename: String) = this.javaClass.classLoader!!.getResource(filename).readText() + +} + + diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarAngBch.kt b/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarAngBch.kt new file mode 100644 index 0000000..ab23be9 --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarAngBch.kt @@ -0,0 +1,60 @@ +package com.bitcoin.wallet.rest + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.WalletDatabaseInMemory +import com.bitcoin.wallet.bitcoinj.Mnemonic +import com.bitcoin.wallet.slp.SLPWalletImpl +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.reactivex.Single + +/** + * Snapshot of and address with expected balances + * + * Has: + * - a low amount of Satoshis in AAR SLP utxos + * - Some BCH + * - Some ANG SLP tokens + * + * @author akibabu + */ +internal class BitcoinRestClientMockAarAngBch : BitcoinRestClient { + + val mnemonic = "fix ranch escape toe brave return amazing average torch faculty vote guard".split(" ") + + val wallet = SLPWalletImpl(Network.MAIN, Mnemonic(mnemonic), WalletDatabaseInMemory()) + private val gson = Gson() + + override fun getUtxos(request: AddressUtxosRequest): Single> { + val json = resource("rest_utxos_aar_ang_bch.json") + return Single.just(gson.fromJson>(json, object: TypeToken>() {}.type)) + } + + override fun sendRawTransaction(hex: String): Single { + return Single.just("f0bb52d0023e3a81d6d95c4772ae003541554da6f2a962ddd77f540e6181d9cd") + } + + override fun getTransactions(request: TxDetailsRequest): Single> { + var json = resource("rest_txdetails_aar_ang_bch.json") + // Check for AAR genesis + if (request.txids.count() == 1 && request.txids[0] == "b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772") { + json = resource("rest_txdetails_aar_genesis.json") + } else // Check for ANG genesis + if (request.txids.count() == 1 && request.txids[0] == "775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e") { + json = resource("rest_txdetails_ang_genesis.json") + } + val response = gson.fromJson>(json, object : TypeToken>() {}.type) + return Single.just(response) + } + + override fun validateSlpTxs(request: SlpValidateTxRequest): Single> { + val response = request.txids + .map { SlpValidateTxResponse(it, true) } + return Single.just(response) + } + + private fun resource(filename: String) = this.javaClass.classLoader!!.getResource(filename).readText() + +} + + diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarBch.kt b/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarBch.kt new file mode 100644 index 0000000..ea4c337 --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/rest/BitcoinRestClientMockAarBch.kt @@ -0,0 +1,54 @@ +package com.bitcoin.wallet.rest + +import com.bitcoin.wallet.Network +import com.bitcoin.wallet.WalletDatabaseInMemory +import com.bitcoin.wallet.bitcoinj.Mnemonic +import com.bitcoin.wallet.slp.SLPWalletImpl +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import io.reactivex.Single + +/** + * Snapshot of and address with expected balances + * + * Has a low amount of Satoshis in SLP utxos + * + * @author akibabu + */ +internal class BitcoinRestClientMockAarBch : BitcoinRestClient { + + val mnemonic = "fix ranch escape toe brave return amazing average torch faculty vote guard".split(" ") + + val wallet = SLPWalletImpl(Network.MAIN, Mnemonic(mnemonic), WalletDatabaseInMemory()) + private val gson = Gson() + + override fun getUtxos(request: AddressUtxosRequest): Single> { + val json = resource("rest_utxos_aar_bch.json") + return Single.just(gson.fromJson>(json, object: TypeToken>() {}.type)) + } + + override fun sendRawTransaction(hex: String): Single { + return Single.just("f0bb52d0023e3a81d6d95c4772ae003541554da6f2a962ddd77f540e6181d9cd") + } + + override fun getTransactions(request: TxDetailsRequest): Single> { + var json = resource("rest_txdetails_aar_bch.json") + // Check for AAR genesis + if (request.txids.count() == 1 && request.txids[0] == "b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772") { + json = resource("rest_txdetails_aar_genesis.json") + } + val response = gson.fromJson>(json, object : TypeToken>() {}.type) + return Single.just(response) + } + + override fun validateSlpTxs(request: SlpValidateTxRequest): Single> { + val response = request.txids + .map { SlpValidateTxResponse(it, true) } + return Single.just(response) + } + + private fun resource(filename: String) = this.javaClass.classLoader!!.getResource(filename).readText() + +} + + diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpOpReturnTest.kt b/slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpOpReturnTest.kt new file mode 100644 index 0000000..cea6974 --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpOpReturnTest.kt @@ -0,0 +1,90 @@ +package com.bitcoin.wallet.slp + +import com.bitcoin.wallet.encoding.ByteUtils +import org.junit.Assert.* +import org.junit.Test +import java.math.BigDecimal + +/** + * @author akibabu + */ +class SlpOpReturnTest { + + // Taken from tx id "3963cb42b65afa4965c80dd265b3a75249681e18ef053a4d244b5ffbd4958303" + private val bchLiveSlpSendOpReturn = + "6a04534c500001010453454e4420263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e08000000ba43b7400008016343d2b750c580" + + private val bchLiveSlpTokenId = SlpTokenId("263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e") + private val bchLiveNetGenesis = + "6a04534c500001010747454e455349530358525006526970706c654c004c0001064c0008016345785d8a0000" + + @Test + fun tryParse_fromHex() { + assertNotNull(SlpOpReturn.tryParse("", bchLiveSlpSendOpReturn)) + } + + @Test + fun tryParse_fromScript() { + assertNotNull(SlpOpReturn.tryParse("", bchLiveSlpSendOpReturn)) + } + + @Test + fun tryParse_genesis() { + val genesis = SlpOpReturn.tryParse("asdf", bchLiveNetGenesis)!! as SlpOpReturnGenesis + assertEquals("asdf", genesis.tokenId.hex) + assertEquals(SlpTokenType.PERMISSIONLESS, genesis.tokenType) + assertEquals("XRP", genesis.ticker) + assertEquals("Ripple", genesis.name) + assertEquals(6, genesis.decimals) + assertEquals(BigDecimal(100000000000000000).toLong().toULong(), genesis.mintedAmount) + assertNull(genesis.batonVout) + } + + @Test + fun isSendTrue() { + assertTrue(SlpOpReturn.tryParse("", bchLiveSlpSendOpReturn) is SlpOpReturnSend) + } + + @Test + fun send_createScript() { + val scriptBytes = SlpOpReturn.send( + bchLiveSlpTokenId, listOf( + 800000000000u, + 99998189030000000u + ) + ).createScript().program + assertEquals(bchLiveSlpSendOpReturn, ByteUtils.Hex.encode(scriptBytes)) + } + + @Test(expected = IllegalArgumentException::class) + fun send_requireAtLeastOneQuantitiy() { + SlpOpReturn.send(bchLiveSlpTokenId, listOf()) + } + + @Test(expected = IllegalArgumentException::class) + fun send_requireAllPositiveQuantities() { + SlpOpReturn.send(bchLiveSlpTokenId, listOf(1u, 2u, 3u, 0u)) + } + + @Test + fun getTokenType() { + assertEquals(SlpTokenType.PERMISSIONLESS, SlpOpReturn.tryParse("", bchLiveSlpSendOpReturn)!!.tokenType) + } + + @Test + fun getTransactionType_send() { + assertEquals(SlpTransactionType.SEND, SlpOpReturn.tryParse("", bchLiveSlpSendOpReturn)!!.transactionType) + } + + @Test + fun getTokenId() { + assertEquals(bchLiveSlpTokenId, SlpOpReturn.tryParse("", bchLiveSlpSendOpReturn)!!.tokenId) + } + + @Test + fun getQuantities() { + val quantities = listOf(800000000000u, 99998189030000000u) + val send = SlpOpReturn.send(bchLiveSlpTokenId, quantities) + assertEquals(quantities, send.quantities) + } +} \ No newline at end of file diff --git a/slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpTokenDetailsTest.kt b/slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpTokenDetailsTest.kt new file mode 100644 index 0000000..d248b50 --- /dev/null +++ b/slpwallet/src/test/java/com/bitcoin/wallet/slp/SlpTokenDetailsTest.kt @@ -0,0 +1,63 @@ +package com.bitcoin.wallet.slp + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigDecimal + +class SlpTokenDetailsTest { + + @Test + fun toRawAmount_happy() { + val decimals = 6 + val details = SlpTokenDetails(SlpTokenId(""), "", "", decimals) + val expectedAmount : ULong = 12530000u + assertEquals(expectedAmount, details.toRawAmount(BigDecimal("12.53"))) + } + + @Test + fun toRawAmount_zeroDecimals() { + val decimals = 0 + val details = SlpTokenDetails(SlpTokenId(""), "", "", decimals) + val expectedAmount : ULong = 12u + assertEquals(expectedAmount, details.toRawAmount(BigDecimal("12"))) + } + + @Test(expected = IllegalArgumentException::class) + fun toRawAmount_tooManyDecimals() { + val details = SlpTokenDetails(SlpTokenId(""), "", "", 0) + details.toRawAmount(BigDecimal("23.1")) + } + + @Test + fun toRawAmount_supportUnsigned8Bytes() { + val decimals = 5 + val details = SlpTokenDetails(SlpTokenId(""), "", "", decimals) + val expectedAmount = ULong.MAX_VALUE - 1000u + assertEquals(expectedAmount, details.toRawAmount(BigDecimal("184467440737095.50615"))) + } + + @Test + fun toReadableAmount_supportUnsigned8Bytes() { + val decimals = 6 + val details = SlpTokenDetails(SlpTokenId(""), "", "", decimals) + val expectedAmount = BigDecimal("18446744073709.550615") + assertEquals(expectedAmount, details.toReadableAmount(BigDecimal("18446744073709550615"))) + } + + @Test + fun toReadableAmount_happy() { + val decimals = 6 + val details = SlpTokenDetails(SlpTokenId(""), "", "", decimals) + val expectedAmount = BigDecimal("12.53") + assertEquals(expectedAmount, details.toReadableAmount(BigDecimal("12530000"))) + } + + @Test + fun toReadableAmount_zeroDecimals() { + val decimals = 0 + val details = SlpTokenDetails(SlpTokenId(""), "", "", decimals) + val expectedAmount = BigDecimal("12") + assertEquals(expectedAmount, details.toReadableAmount(BigDecimal("12"))) + } + +} diff --git a/slpwallet/src/test/resources/rest_txdetails.json b/slpwallet/src/test/resources/rest_txdetails.json new file mode 100644 index 0000000..57862dd --- /dev/null +++ b/slpwallet/src/test/resources/rest_txdetails.json @@ -0,0 +1,693 @@ +[ + { + "txid": "5da23019e80a9a5a83ead96d426070483fed854c92e71637b42daec766c066fe", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "fcca7794b276fda83d6b6e71ca5ad607f0e4f26e21fa8ba97a84370a581239e8", + "vout": 0, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "473044022012ebfd0e81b68cf43e7b06bdd2192a759f3b4783d0b6be1a20f26c6e992012fa0220412f0c8f7812c166d19e12de677a3c5e0ef0fdb79f459198259aa8d404c45a32412102a0e27bb39c6ccfeed632db4da305062f37680967417f7c37b01b60852cace24f", + "asm": "3044022012ebfd0e81b68cf43e7b06bdd2192a759f3b4783d0b6be1a20f26c6e992012fa0220412f0c8f7812c166d19e12de677a3c5e0ef0fdb79f459198259aa8d404c45a32[ALL|FORKID] 02a0e27bb39c6ccfeed632db4da305062f37680967417f7c37b01b60852cace24f" + }, + "value": 890462, + "legacyAddress": "1Q46AbtZGa1VA451BxDpUVkzcvSiZc64AJ", + "cashAddress": "bitcoincash:qr7wquls49hrmukgm4w52ze6w3tylzrqvs76g4ts5d" + } + ], + "vout": [ + { + "value": "0.00077346", + "n": 0, + "scriptPubKey": { + "hex": "76a914530644a77bfe3f5002bb81210160f80251d436ac88ac", + "asm": "OP_DUP OP_HASH160 530644a77bfe3f5002bb81210160f80251d436ac OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00812873", + "n": 1, + "scriptPubKey": { + "hex": "76a9142409bf65ce2374a0f8e4dea9cb53d24093c817e388ac", + "asm": "OP_DUP OP_HASH160 2409bf65ce2374a0f8e4dea9cb53d24093c817e3 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "14HZ1Bgy2UhqvJFM86sMA7iqPmYAvH2WoE" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "0000000000000000016d4a8d398d6318e9622b67bdc9766891b34379261bf31c", + "blockheight": 573722, + "confirmations": 2, + "time": 1552553246, + "blocktime": 1552553246, + "valueOut": 0.00890219, + "size": 225, + "valueIn": 0.00890462, + "fees": 0.00000243 + }, + { + "txid": "962aa9115c5170becb6051c79692217eea1877906ced3a69552e76ef95cb7f0b", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "f0bb52d0023e3a81d6d95c4772ae003541554da6f2a962ddd77f540e6181d9cd", + "vout": 2, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "483045022100c20f52c52e7e8b25ab650f134171bac9f75fe067daae5caf756cf1ee758c1a8d02200e6493e60e0b5e46b2106bacb3d52f9f63fecf9aada5e04b7d34b5e875af3e5a41210383ecbe874098ce0d03ec7ef9b5b30cf262f8249ecaac2496d5e534cb2e305c14", + "asm": "3045022100c20f52c52e7e8b25ab650f134171bac9f75fe067daae5caf756cf1ee758c1a8d02200e6493e60e0b5e46b2106bacb3d52f9f63fecf9aada5e04b7d34b5e875af3e5a[ALL|FORKID] 0383ecbe874098ce0d03ec7ef9b5b30cf262f8249ecaac2496d5e534cb2e305c14" + }, + "value": 6347, + "legacyAddress": "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5", + "cashAddress": "bitcoincash:qpfsv39800lr75qzhwqjzqtqlqp9r4pk4spq58nxdk" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010453454e44203257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503080000000000004e20080000000000027100", + "asm": "OP_RETURN 5262419 1 1145980243 3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503 0000000000004e20 0000000000027100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914b605d649b8b045261dc253874ee11e9a2504eb2388ac", + "asm": "OP_DUP OP_HASH160 b605d649b8b045261dc253874ee11e9a2504eb23 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1HbSyUoWNWMJG8o98TPArnSPuQ3MrzLyQa" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00005461", + "n": 2, + "scriptPubKey": { + "hex": "76a914530644a77bfe3f5002bb81210160f80251d436ac88ac", + "asm": "OP_DUP OP_HASH160 530644a77bfe3f5002bb81210160f80251d436ac OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "0000000000000000016d4a8d398d6318e9622b67bdc9766891b34379261bf31c", + "blockheight": 573722, + "confirmations": 2, + "time": 1552553246, + "blocktime": 1552553246, + "valueOut": 0.00006007, + "size": 299, + "valueIn": 0.00006347, + "fees": 0.0000034 + }, + { + "txid": "3b63d0f1d67ea52f7c57cd130c3118a3a8a30286bf3b7a6bf4b8ad8b885fca69", + "version": 1, + "locktime": 573621, + "vin": [ + { + "txid": "cd6a30097b5df13ce5b5fec1cfe21c531d8270e965f0847f2f39fb80f3ca8d63", + "vout": 1, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "473044022051f07e79cabbf4ddcd77cc42f0143f059c01b87d594f511f6eefa1fce9ec83e60220660b02bfdf5f702fe1d5894a66c78999696a2fd6a7787e973e48be2dca91809741210239dca5f0b5c876db9aa1029b140315980d4d097ae0f8bc2b8f028af46734e177", + "asm": "3044022051f07e79cabbf4ddcd77cc42f0143f059c01b87d594f511f6eefa1fce9ec83e60220660b02bfdf5f702fe1d5894a66c78999696a2fd6a7787e973e48be2dca918097[ALL|FORKID] 0239dca5f0b5c876db9aa1029b140315980d4d097ae0f8bc2b8f028af46734e177" + }, + "value": 546, + "legacyAddress": "1B9sB2f6VLRuRBw3RG365m6t5Ffmzvj1aQ", + "cashAddress": "bitcoincash:qph4uaalws2nlhsrr7x453fet8erzhx4m5lg4svyww" + }, + { + "txid": "91bf2b7fd21b6e63af20b6e7834b8827f91536ce8338e140abbc49153199ff48", + "vout": 3, + "sequence": 4294967294, + "n": 1, + "scriptSig": { + "hex": "483045022100f203d2d88a19fd337307532c116df94488eae5fd77150e7e333b1330876a4ef602206f0e73a179b7650ba34903f1d6887aa47e1b414cb485c6ecdcf058d7cd8adcbc412103d2a8c7830230831f5a167da323742da297e959133279a0c492fdfdb96a7add5b", + "asm": "3045022100f203d2d88a19fd337307532c116df94488eae5fd77150e7e333b1330876a4ef602206f0e73a179b7650ba34903f1d6887aa47e1b414cb485c6ecdcf058d7cd8adcbc[ALL|FORKID] 03d2a8c7830230831f5a167da323742da297e959133279a0c492fdfdb96a7add5b" + }, + "value": 45981, + "legacyAddress": "19mdvmwAErPPf1o5U2r14QbrYmkcCRjBeg", + "cashAddress": "bitcoincash:qpsrygjzy0xjs46lmjp0hpk660e4e6mw4y8teuvf9f" + }, + { + "txid": "cd6a30097b5df13ce5b5fec1cfe21c531d8270e965f0847f2f39fb80f3ca8d63", + "vout": 2, + "sequence": 4294967294, + "n": 2, + "scriptSig": { + "hex": "47304402207d03233b0830133856c7f2e6e113b0a10176936c38fb38a980a05a9a0d23724e0220609e3d72dcf2537571700dbfc736034bb5f31c86195e171ec7358c63ce5a64a6412102597b8a6848e65ae12cab07db65f58e2bf50b65b2b400b70c07438baa2945b5a2", + "asm": "304402207d03233b0830133856c7f2e6e113b0a10176936c38fb38a980a05a9a0d23724e0220609e3d72dcf2537571700dbfc736034bb5f31c86195e171ec7358c63ce5a64a6[ALL|FORKID] 02597b8a6848e65ae12cab07db65f58e2bf50b65b2b400b70c07438baa2945b5a2" + }, + "value": 546, + "legacyAddress": "1EwP9evPJXBnejUyuAPXWABa8HukcjNAJr", + "cashAddress": "bitcoincash:qzvw99smqtew9rqzzxpyy75d0ya6r8vyzscx7p8p5s" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010453454e44208fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d080000000000005ba00800000000075b6b67", + "asm": "OP_RETURN 5262419 1 1145980243 8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d 0000000000005ba0 00000000075b6b67" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914530644a77bfe3f5002bb81210160f80251d436ac88ac", + "asm": "OP_DUP OP_HASH160 530644a77bfe3f5002bb81210160f80251d436ac OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 2, + "scriptPubKey": { + "hex": "76a91456ac707ae4b481d58a0470263283fdf31e24ba0d88ac", + "asm": "OP_DUP OP_HASH160 56ac707ae4b481d58a0470263283fdf31e24ba0d OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "18uHgo8E7oVt9BDFxEHjM4shwcBwcKrsrn" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00045352", + "n": 3, + "scriptPubKey": { + "hex": "76a91456ac707ae4b481d58a0470263283fdf31e24ba0d88ac", + "asm": "OP_DUP OP_HASH160 56ac707ae4b481d58a0470263283fdf31e24ba0d OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "18uHgo8E7oVt9BDFxEHjM4shwcBwcKrsrn" + ], + "type": "pubkeyhash" + }, + "spentTxId": "4759b9469803496f10ef38785c7feaf0e0873da6c090af341d70e39e9c698718", + "spentIndex": 0, + "spentHeight": 573622 + } + ], + "blockhash": "000000000000000000228221c3f8a9917b95fdc4c5e46f5c96d72c53ae500028", + "blockheight": 573622, + "confirmations": 102, + "time": 1552503655, + "blocktime": 1552503655, + "valueOut": 0.00046444, + "size": 627, + "valueIn": 0.00047073, + "fees": 0.00000629 + }, + { + "txid": "e28a36df4d56fd04a715c969e874e8bafefa1ac29772d75ce7de762f8a8cf06f", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "c5abd7ac470b47ad06fafb46f510f488656426ae55f6ec211a6d14e22f5c2cd3", + "vout": 2, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "4730440220143929dd3071c9e8f42acd39fe9c7d327ddb2f57039229994d3602dfbb93b7ef02200b9ee0264bd66564e3aa12cdc7d02d6909ad53162802b46250f159c16839ba1a41210383ecbe874098ce0d03ec7ef9b5b30cf262f8249ecaac2496d5e534cb2e305c14", + "asm": "30440220143929dd3071c9e8f42acd39fe9c7d327ddb2f57039229994d3602dfbb93b7ef02200b9ee0264bd66564e3aa12cdc7d02d6909ad53162802b46250f159c16839ba1a[ALL|FORKID] 0383ecbe874098ce0d03ec7ef9b5b30cf262f8249ecaac2496d5e534cb2e305c14" + }, + "value": 985459, + "legacyAddress": "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5", + "cashAddress": "bitcoincash:qpfsv39800lr75qzhwqjzqtqlqp9r4pk4spq58nxdk" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010453454e4420263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e080000000000b71b0008000000ba4117dd00", + "asm": "OP_RETURN 5262419 1 1145980243 263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e 0000000000b71b00 000000ba4117dd00" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a703166d96e62635722e272211539330705247fa88ac", + "asm": "OP_DUP OP_HASH160 a703166d96e62635722e272211539330705247fa OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GE5YZrvGtJmdQgCbVwg9Jrb5ztsie4SNU" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00984573", + "n": 2, + "scriptPubKey": { + "hex": "76a914530644a77bfe3f5002bb81210160f80251d436ac88ac", + "asm": "OP_DUP OP_HASH160 530644a77bfe3f5002bb81210160f80251d436ac OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "00000000000000000473faa6c7c601df7dd66d22b8b53da80e2068a2ce02ae47", + "blockheight": 573621, + "confirmations": 103, + "time": 1552501974, + "blocktime": 1552501974, + "valueOut": 0.00985119, + "size": 298, + "valueIn": 0.00985459, + "fees": 0.0000034 + }, + { + "txid": "263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e", + "version": 1, + "locktime": 573285, + "vin": [ + { + "txid": "a8ce662c6aea8190b650943fc2d3aeeab31b1bc9e93591ab440b65035a38d678", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540412102829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6", + "asm": "30440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540[ALL|FORKID] 02829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6" + }, + "value": 2111406, + "legacyAddress": "1QCwbchDrw7xt2U83YJSzF7aPSxxsLAPUx", + "cashAddress": "bitcoincash:qrlg6zej97zuywj7ea37dn86ps27z4n6cvj7sjft6r" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530358525006526970706c654c004c0001064c0008016345785d8a0000", + "asm": "OP_RETURN 5262419 1 47454e45534953 5263960 526970706c65 0 0 6 0 016345785d8a0000" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a703166d96e62635722e272211539330705247fa88ac", + "asm": "OP_DUP OP_HASH160 a703166d96e62635722e272211539330705247fa OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GE5YZrvGtJmdQgCbVwg9Jrb5ztsie4SNU" + ], + "type": "pubkeyhash" + }, + "spentTxId": "d283ed88973257c7440aad3446390a0518818d9e0ad050a6dc918a01752e3d96", + "spentIndex": 1, + "spentHeight": 573309 + }, + { + "value": "0.02110581", + "n": 2, + "scriptPubKey": { + "hex": "76a91495461dc0f0c5512ae6e2703fdb2852b0b42c476588ac", + "asm": "OP_DUP OP_HASH160 95461dc0f0c5512ae6e2703fdb2852b0b42c4765 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EcHgLvSkyMPEYQRJxZNt4oAGE5EUWkMew" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "000000000000000001c00f354bc4121c27b21ed9d371368c9f57a7db66a674d0", + "blockheight": 573286, + "confirmations": 1992, + "time": 1552295947, + "blocktime": 1552295947, + "valueOut": 0.02111127, + "size": 278, + "valueIn": 0.02111406, + "fees": 0.00000279 + }, + { + "txid": "b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772", + "version": 1, + "locktime": 573802, + "vin": [ + { + "txid": "05b70a830f9b8e3842c21d2ee2ad022047b646a06deae7a6d88da8da1eaa0872", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "47304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb412102b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605", + "asm": "304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb[ALL|FORKID] 02b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605" + }, + "value": 43568, + "legacyAddress": "1MabofMTV9yJ6CJTYEst5WdoiLAaqm2Zqb", + "cashAddress": "bitcoincash:qrsm6jakv24ghpqyqta0frm7rfkehz4pasayejgjek" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530341415208416172647661726b4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 5390657 416172647661726b 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a85a11b9c81c5761e262d7ab76cc0656fd2f0f4588ac", + "asm": "OP_DUP OP_HASH160 a85a11b9c81c5761e262d7ab76cc0656fd2f0f45 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GMARMBykmPjXm83KmKJckqY4FfKmkx429" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 0, + "spentHeight": 573803 + }, + { + "value": "0.00042741", + "n": 2, + "scriptPubKey": { + "hex": "76a914854bab72529070b256e1bd519d2f63090d94947988ac", + "asm": "OP_DUP OP_HASH160 854bab72529070b256e1bd519d2f63090d949479 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1D9oX8xqpBM9YGxG5tgjstnJXiAj4g7gKC" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 1, + "spentHeight": 573803 + } + ], + "blockhash": "000000000000000001e5209c39b3ab1ec7863efd72f95fb78fa7f468ffdf310e", + "blockheight": 573803, + "confirmations": 1479, + "time": 1552610189, + "blocktime": 1552610189, + "valueOut": 0.00043287, + "size": 280, + "valueIn": 0.00043568, + "fees": 0.00000281 + }, + { + "txid": "775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e", + "version": 1, + "locktime": 573835, + "vin": [ + { + "txid": "abc3fbf2e3c8459cb134a6a44d57c5c99322addba67cd2f099c9571773203309", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a024121030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183", + "asm": "3045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a02[ALL|FORKID] 030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183" + }, + "value": 38672, + "legacyAddress": "185MEfnZKCbVghEXdPR1sdWSKJ55vEZuni", + "cashAddress": "bitcoincash:qpxekmatley3u9ggshfgh8p4stpc6rzwxuluahnush" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e4553495303414e4705416e67656c4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 4673089 416e67656c 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914ab80783acd048d99703eedcbcc895b6bf987c16488ac", + "asm": "OP_DUP OP_HASH160 ab80783acd048d99703eedcbcc895b6bf987c164 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GdpT2msFCQVsMsKNm4MAkB4URR1xV5fYX" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 0, + "spentHeight": 573837 + }, + { + "value": "0.00037848", + "n": 2, + "scriptPubKey": { + "hex": "76a91469fa8f94e2245695f3f593901b9093b69e353bd488ac", + "asm": "OP_DUP OP_HASH160 69fa8f94e2245695f3f593901b9093b69e353bd4 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1AfN7R9ZBSqfstKvaBroxMtBpAbtPh2VjK" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 1, + "spentHeight": 573837 + } + ], + "blockhash": "00000000000000000210dbca65947d6330f8ae05cebd3454738eeabc31973521", + "blockheight": 573837, + "confirmations": 1445, + "time": 1552620880, + "blocktime": 1552620880, + "valueOut": 0.00038394, + "size": 278, + "valueIn": 0.00038672, + "fees": 0.00000278 + }, + { + "txid": "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d", + "version": 1, + "locktime": 571486, + "vin": [ + { + "txid": "583c8fd73a7aea4db3bab433c1410bb2577bc58340fe938a81fd2f1941c5d11d", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b334792321e7217a66fb69621960c2d8d2b64f042919d245b0b8ffaf3b39b9ff02202f95d816f177279e69f69c557154370fa7f8a1d6f75ff76d9f56a77f231d68874121034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450", + "asm": "3045022100b334792321e7217a66fb69621960c2d8d2b64f042919d245b0b8ffaf3b39b9ff02202f95d816f177279e69f69c557154370fa7f8a1d6f75ff76d9f56a77f231d6887[ALL|FORKID] 034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450" + }, + "value": 1111, + "legacyAddress": "1BsLbZEGLNWxFYeY5VKpTDo9w2YbkapRKx", + "cashAddress": "bitcoincash:qpmnv60xzz08weaqurc45g8q4548plpgsqkkcsf205" + }, + { + "txid": "956095f538fe857f86cef79a3e7f5564e9774ae2a9878ba8e9b998eef9d419fe", + "vout": 0, + "sequence": 4294967294, + "n": 1, + "scriptSig": { + "hex": "473044022045c758f5e3147542f13271c8aa67b630c99dd7c320048d23b6c7ae6dd88da11e02203dc7f97bb4caaf8a2e8ef122e2f9f58856bf3cca025ae5c0c0fc6a90a37b7f3e4121034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450", + "asm": "3044022045c758f5e3147542f13271c8aa67b630c99dd7c320048d23b6c7ae6dd88da11e02203dc7f97bb4caaf8a2e8ef122e2f9f58856bf3cca025ae5c0c0fc6a90a37b7f3e[ALL|FORKID] 034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450" + }, + "value": 52373, + "legacyAddress": "1BsLbZEGLNWxFYeY5VKpTDo9w2YbkapRKx", + "cashAddress": "bitcoincash:qpmnv60xzz08weaqurc45g8q4548plpgsqkkcsf205" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530455494f50085468652055494f50136272656e646f6e40626974636f696e2e636f6d4c0001004c000800000000075bcd15", + "asm": "OP_RETURN 5262419 1 47454e45534953 1347373397 5468652055494f50 6272656e646f6e40626974636f696e2e636f6d 0 0 0 00000000075bcd15" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a91494b7e08912767f44d90284941aee3df3f8ec34c688ac", + "asm": "OP_DUP OP_HASH160 94b7e08912767f44d90284941aee3df3f8ec34c6 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EZMHPvTbfsTdE9x3XTcadAXoBuR53qb15" + ], + "type": "pubkeyhash" + }, + "spentTxId": "f23e7a2a718cacc5e2a74339ab3648ef6f7174eaad1183cd2436ae8ffcd612a9", + "spentIndex": 0, + "spentHeight": 571670 + }, + { + "value": "0.00052490", + "n": 2, + "scriptPubKey": { + "hex": "76a9141a57f5dee20963fd45bc4cfe85fd65d2852e155888ac", + "asm": "OP_DUP OP_HASH160 1a57f5dee20963fd45bc4cfe85fd65d2852e1558 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "13QHwaxq9Ze6QhC7rHBCSwdHYptUtVouV7" + ], + "type": "pubkeyhash" + }, + "spentTxId": "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503", + "spentIndex": 0, + "spentHeight": 571487 + } + ], + "blockhash": "000000000000000003c6b560b41276653b46be51a290e76691153c35a0405830", + "blockheight": 571487, + "confirmations": 3795, + "time": 1551215049, + "blocktime": 1551215049, + "valueOut": 0.00053036, + "size": 447, + "valueIn": 0.00053484, + "fees": 0.00000448 + }, + { + "txid": "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503", + "version": 1, + "locktime": 571486, + "vin": [ + { + "txid": "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d", + "vout": 2, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220178c9c0047bb3232c5f1888e9a00bd8ea5ab854e981ec29595017363f4ce070502203d8ba8441530bb153f7b6f36cdca9346180b37992c49a57b0a50123791cff4eb412103cdf15d63def344d01199654c1973ccd25295e9734ee4cc80d605cb8fefc2e85c", + "asm": "30440220178c9c0047bb3232c5f1888e9a00bd8ea5ab854e981ec29595017363f4ce070502203d8ba8441530bb153f7b6f36cdca9346180b37992c49a57b0a50123791cff4eb[ALL|FORKID] 03cdf15d63def344d01199654c1973ccd25295e9734ee4cc80d605cb8fefc2e85c" + }, + "value": 52490, + "legacyAddress": "13QHwaxq9Ze6QhC7rHBCSwdHYptUtVouV7", + "cashAddress": "bitcoincash:qqd90aw7ugyk8l29h3x0ap0avhfg2ts4tq0ejfpvm3" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530555494f50320b5468652055494f50205632136272656e646f6e40626974636f696e2e636f6d4c0001044c00080000011f71fb0450", + "asm": "OP_RETURN 5262419 1 47454e45534953 55494f5032 5468652055494f50205632 6272656e646f6e40626974636f696e2e636f6d 0 4 0 0000011f71fb0450" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a9144a3d99924e2fe34084d152002de16a6ebeea3df188ac", + "asm": "OP_DUP OP_HASH160 4a3d99924e2fe34084d152002de16a6ebeea3df1 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "17mYoEPFPrSgS41w3XdZXbQu4o9MwBB1Gk" + ], + "type": "pubkeyhash" + }, + "spentTxId": "52126b7cb58da053a88ddafacdb6fb97223c0661331a5099a1a04153aace265f", + "spentIndex": 1, + "spentHeight": 571531 + }, + { + "value": "0.00051640", + "n": 2, + "scriptPubKey": { + "hex": "76a9144350d53fbdedc5fc9e7d5eb555130241df997e3988ac", + "asm": "OP_DUP OP_HASH160 4350d53fbdedc5fc9e7d5eb555130241df997e39 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "178w7EEhK3exYmiiuqc4MAWM5meULobznd" + ], + "type": "pubkeyhash" + }, + "spentTxId": "52126b7cb58da053a88ddafacdb6fb97223c0661331a5099a1a04153aace265f", + "spentIndex": 0, + "spentHeight": 571531 + } + ], + "blockhash": "000000000000000003c6b560b41276653b46be51a290e76691153c35a0405830", + "blockheight": 571487, + "confirmations": 3795, + "time": 1551215049, + "blocktime": 1551215049, + "valueOut": 0.00052186, + "size": 303, + "valueIn": 0.0005249, + "fees": 0.00000304 + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_txdetails_aar_ang_bch.json b/slpwallet/src/test/resources/rest_txdetails_aar_ang_bch.json new file mode 100644 index 0000000..c33a69a --- /dev/null +++ b/slpwallet/src/test/resources/rest_txdetails_aar_ang_bch.json @@ -0,0 +1,580 @@ +[ + { + "txid": "71c81c6904d517d9057ec76b5b8188401df479ff7673a3c3ea7128480f54ca44", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "c0c272aa515c797a7f99f1286591427010427985e5176aa3602bcb1541e41982", + "vout": 2, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "483045022100cee10792e33920ac5c33fd3c3bd615d0304dc1fb740ccd99e24d2d69392bcda702202d4335ceb33208c68e0ada04bd10645c5bcf7d09f351f9550e877f70167a395f412102ef1d30bdb7b2b79040630ac9fe4de21146750ac5dd912543c687b4e0d11c7257", + "asm": "3045022100cee10792e33920ac5c33fd3c3bd615d0304dc1fb740ccd99e24d2d69392bcda702202d4335ceb33208c68e0ada04bd10645c5bcf7d09f351f9550e877f70167a395f[ALL|FORKID] 02ef1d30bdb7b2b79040630ac9fe4de21146750ac5dd912543c687b4e0d11c7257" + }, + "value": 2166, + "legacyAddress": "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ", + "cashAddress": "bitcoincash:qqt4kqkhgrr9vwr6hff93ju9fvtssjsz9cd9dqk50f" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010453454e4420b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a98937720800000000000000640800000000000002aa", + "asm": "OP_RETURN 5262419 1 1145980243 b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772 0000000000000064 00000000000002aa" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a9148b76343309322633cd910fda874c59806f09439888ac", + "asm": "OP_DUP OP_HASH160 8b76343309322633cd910fda874c59806f094398 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1DiQXci2QKPMuDPoZDa8YTenKzYszVcv1B" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00001280", + "n": 2, + "scriptPubKey": { + "hex": "76a914175b02d740c656387aba5258cb854b17084a022e88ac", + "asm": "OP_DUP OP_HASH160 175b02d740c656387aba5258cb854b17084a022e OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ" + ], + "type": "pubkeyhash" + }, + "spentTxId": "629c2253a6da69d636579767546cc23407d488e573c39a0be193a38f8cddf8de", + "spentIndex": 0, + "spentHeight": 574433 + } + ], + "blockhash": "000000000000000004bae7930bb23f262fb5032b58d67635f894592bcab1f00b", + "blockheight": 574428, + "confirmations": 88, + "time": 1552974990, + "blocktime": 1552974990, + "valueOut": 0.00001826, + "size": 299, + "valueIn": 0.00002166, + "fees": 0.0000034 + }, + { + "txid": "2460c85e7f782bd3b7c9816687c6c2736900367fd1c6efee60953c3a23f010ac", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "59d51f7170f0f42971e44e994ee5fdefc54593598aa3bdf93b714f22a7c751bd", + "vout": 2, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "483045022100f72bda96cc5f807f8c1a16f5d90e41c4e38fda0fe511b656fe7b93bf25f678bd02205ca8da9d662663531dce037e0bd9f1f2f9a13e62f6ed4009110b4f35372d6796412102ef1d30bdb7b2b79040630ac9fe4de21146750ac5dd912543c687b4e0d11c7257", + "asm": "3045022100f72bda96cc5f807f8c1a16f5d90e41c4e38fda0fe511b656fe7b93bf25f678bd02205ca8da9d662663531dce037e0bd9f1f2f9a13e62f6ed4009110b4f35372d6796[ALL|FORKID] 02ef1d30bdb7b2b79040630ac9fe4de21146750ac5dd912543c687b4e0d11c7257" + }, + "value": 9512, + "legacyAddress": "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ", + "cashAddress": "bitcoincash:qqt4kqkhgrr9vwr6hff93ju9fvtssjsz9cd9dqk50f" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010453454e4420775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e0800000000000000640800000000000185d8", + "asm": "OP_RETURN 5262419 1 1145980243 775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e 0000000000000064 00000000000185d8" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a9148b76343309322633cd910fda874c59806f09439888ac", + "asm": "OP_DUP OP_HASH160 8b76343309322633cd910fda874c59806f094398 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1DiQXci2QKPMuDPoZDa8YTenKzYszVcv1B" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00008626", + "n": 2, + "scriptPubKey": { + "hex": "76a914175b02d740c656387aba5258cb854b17084a022e88ac", + "asm": "OP_DUP OP_HASH160 175b02d740c656387aba5258cb854b17084a022e OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ" + ], + "type": "pubkeyhash" + }, + "spentTxId": "4760b765ed7325f5a2ac5e36eea810078c12d1a6a4adced45a5171232c980cde", + "spentIndex": 1, + "spentHeight": 574525 + } + ], + "blockhash": "0000000000000000005e854f36d738280c80485677137e301f273a627c26d2ba", + "blockheight": 574437, + "confirmations": 101, + "time": 1552978671, + "blocktime": 1552978671, + "valueOut": 0.00009172, + "size": 299, + "valueIn": 0.00009512, + "fees": 0.0000034 + }, + { + "txid": "4e9a367ef6a1692a6a76e670d131ee13c3e5810b2373bc20a0e91d6710479a18", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "84fc39aa3396795d3211bf9239c2179e170585e0771ab2594aa50ed4c7023240", + "vout": 0, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "47304402207b5b3474da63d258bbec923d74425d1cc75f697cbf1cb5fbb70a4fed243cc5ae02204bcc60f290927d44ece6107ea7c090992af2fb920cc16549dc33457c1abfe7144121035b469eb0e26b14011f13815fbcb80701580002fb4de05b24d328b3f4a5df398a", + "asm": "304402207b5b3474da63d258bbec923d74425d1cc75f697cbf1cb5fbb70a4fed243cc5ae02204bcc60f290927d44ece6107ea7c090992af2fb920cc16549dc33457c1abfe714[ALL|FORKID] 035b469eb0e26b14011f13815fbcb80701580002fb4de05b24d328b3f4a5df398a" + }, + "value": 2807548, + "legacyAddress": "1JMeeqkTGt8vPkDzG7BkwH6ktjhd3K4F1x", + "cashAddress": "bitcoincash:qzlxrcmztxqfa25cfwfjsq02tkz6jz0uvs36vpuquv" + } + ], + "vout": [ + { + "value": "0.02799733", + "n": 0, + "scriptPubKey": { + "hex": "76a914395711f17ae3f10387cfda04e032e54d7190e16788ac", + "asm": "OP_DUP OP_HASH160 395711f17ae3f10387cfda04e032e54d7190e167 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "16EBpfsvqwPNbhx9gWQcg88m59SHxgYHTc" + ], + "type": "pubkeyhash" + }, + "spentTxId": "0ef758b2db2b170c69a5fb0c4bf542fc6ecf7a98f7ea239025fe8de932a07894", + "spentIndex": 0, + "spentHeight": 573840 + }, + { + "value": "0.00007572", + "n": 1, + "scriptPubKey": { + "hex": "76a914175b02d740c656387aba5258cb854b17084a022e88ac", + "asm": "OP_DUP OP_HASH160 175b02d740c656387aba5258cb854b17084a022e OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ" + ], + "type": "pubkeyhash" + }, + "spentTxId": "5e164260321fd29c10d86677b793b375a0e4149538a0be8599f055f909feff40", + "spentIndex": 1, + "spentHeight": 573839 + } + ], + "blockhash": "000000000000000001254559c31de5044ce6067bce5b4a5fe5a397c0bf65f870", + "blockheight": 573822, + "confirmations": 698, + "time": 1552616894, + "blocktime": 1552616894, + "valueOut": 0.02807305, + "size": 225, + "valueIn": 0.02807548, + "fees": 0.00000243 + }, + { + "txid": "263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e", + "version": 1, + "locktime": 573285, + "vin": [ + { + "txid": "a8ce662c6aea8190b650943fc2d3aeeab31b1bc9e93591ab440b65035a38d678", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540412102829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6", + "asm": "30440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540[ALL|FORKID] 02829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6" + }, + "value": 2111406, + "legacyAddress": "1QCwbchDrw7xt2U83YJSzF7aPSxxsLAPUx", + "cashAddress": "bitcoincash:qrlg6zej97zuywj7ea37dn86ps27z4n6cvj7sjft6r" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530358525006526970706c654c004c0001064c0008016345785d8a0000", + "asm": "OP_RETURN 5262419 1 47454e45534953 5263960 526970706c65 0 0 6 0 016345785d8a0000" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a703166d96e62635722e272211539330705247fa88ac", + "asm": "OP_DUP OP_HASH160 a703166d96e62635722e272211539330705247fa OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GE5YZrvGtJmdQgCbVwg9Jrb5ztsie4SNU" + ], + "type": "pubkeyhash" + }, + "spentTxId": "d283ed88973257c7440aad3446390a0518818d9e0ad050a6dc918a01752e3d96", + "spentIndex": 1, + "spentHeight": 573309 + }, + { + "value": "0.02110581", + "n": 2, + "scriptPubKey": { + "hex": "76a91495461dc0f0c5512ae6e2703fdb2852b0b42c476588ac", + "asm": "OP_DUP OP_HASH160 95461dc0f0c5512ae6e2703fdb2852b0b42c4765 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EcHgLvSkyMPEYQRJxZNt4oAGE5EUWkMew" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "000000000000000001c00f354bc4121c27b21ed9d371368c9f57a7db66a674d0", + "blockheight": 573286, + "confirmations": 1992, + "time": 1552295947, + "blocktime": 1552295947, + "valueOut": 0.02111127, + "size": 278, + "valueIn": 0.02111406, + "fees": 0.00000279 + }, + { + "txid": "b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772", + "version": 1, + "locktime": 573802, + "vin": [ + { + "txid": "05b70a830f9b8e3842c21d2ee2ad022047b646a06deae7a6d88da8da1eaa0872", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "47304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb412102b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605", + "asm": "304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb[ALL|FORKID] 02b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605" + }, + "value": 43568, + "legacyAddress": "1MabofMTV9yJ6CJTYEst5WdoiLAaqm2Zqb", + "cashAddress": "bitcoincash:qrsm6jakv24ghpqyqta0frm7rfkehz4pasayejgjek" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530341415208416172647661726b4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 5390657 416172647661726b 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a85a11b9c81c5761e262d7ab76cc0656fd2f0f4588ac", + "asm": "OP_DUP OP_HASH160 a85a11b9c81c5761e262d7ab76cc0656fd2f0f45 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GMARMBykmPjXm83KmKJckqY4FfKmkx429" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 0, + "spentHeight": 573803 + }, + { + "value": "0.00042741", + "n": 2, + "scriptPubKey": { + "hex": "76a914854bab72529070b256e1bd519d2f63090d94947988ac", + "asm": "OP_DUP OP_HASH160 854bab72529070b256e1bd519d2f63090d949479 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1D9oX8xqpBM9YGxG5tgjstnJXiAj4g7gKC" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 1, + "spentHeight": 573803 + } + ], + "blockhash": "000000000000000001e5209c39b3ab1ec7863efd72f95fb78fa7f468ffdf310e", + "blockheight": 573803, + "confirmations": 1479, + "time": 1552610189, + "blocktime": 1552610189, + "valueOut": 0.00043287, + "size": 280, + "valueIn": 0.00043568, + "fees": 0.00000281 + }, + { + "txid": "775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e", + "version": 1, + "locktime": 573835, + "vin": [ + { + "txid": "abc3fbf2e3c8459cb134a6a44d57c5c99322addba67cd2f099c9571773203309", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a024121030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183", + "asm": "3045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a02[ALL|FORKID] 030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183" + }, + "value": 38672, + "legacyAddress": "185MEfnZKCbVghEXdPR1sdWSKJ55vEZuni", + "cashAddress": "bitcoincash:qpxekmatley3u9ggshfgh8p4stpc6rzwxuluahnush" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e4553495303414e4705416e67656c4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 4673089 416e67656c 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914ab80783acd048d99703eedcbcc895b6bf987c16488ac", + "asm": "OP_DUP OP_HASH160 ab80783acd048d99703eedcbcc895b6bf987c164 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GdpT2msFCQVsMsKNm4MAkB4URR1xV5fYX" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 0, + "spentHeight": 573837 + }, + { + "value": "0.00037848", + "n": 2, + "scriptPubKey": { + "hex": "76a91469fa8f94e2245695f3f593901b9093b69e353bd488ac", + "asm": "OP_DUP OP_HASH160 69fa8f94e2245695f3f593901b9093b69e353bd4 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1AfN7R9ZBSqfstKvaBroxMtBpAbtPh2VjK" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 1, + "spentHeight": 573837 + } + ], + "blockhash": "00000000000000000210dbca65947d6330f8ae05cebd3454738eeabc31973521", + "blockheight": 573837, + "confirmations": 1445, + "time": 1552620880, + "blocktime": 1552620880, + "valueOut": 0.00038394, + "size": 278, + "valueIn": 0.00038672, + "fees": 0.00000278 + }, + { + "txid": "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d", + "version": 1, + "locktime": 571486, + "vin": [ + { + "txid": "583c8fd73a7aea4db3bab433c1410bb2577bc58340fe938a81fd2f1941c5d11d", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b334792321e7217a66fb69621960c2d8d2b64f042919d245b0b8ffaf3b39b9ff02202f95d816f177279e69f69c557154370fa7f8a1d6f75ff76d9f56a77f231d68874121034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450", + "asm": "3045022100b334792321e7217a66fb69621960c2d8d2b64f042919d245b0b8ffaf3b39b9ff02202f95d816f177279e69f69c557154370fa7f8a1d6f75ff76d9f56a77f231d6887[ALL|FORKID] 034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450" + }, + "value": 1111, + "legacyAddress": "1BsLbZEGLNWxFYeY5VKpTDo9w2YbkapRKx", + "cashAddress": "bitcoincash:qpmnv60xzz08weaqurc45g8q4548plpgsqkkcsf205" + }, + { + "txid": "956095f538fe857f86cef79a3e7f5564e9774ae2a9878ba8e9b998eef9d419fe", + "vout": 0, + "sequence": 4294967294, + "n": 1, + "scriptSig": { + "hex": "473044022045c758f5e3147542f13271c8aa67b630c99dd7c320048d23b6c7ae6dd88da11e02203dc7f97bb4caaf8a2e8ef122e2f9f58856bf3cca025ae5c0c0fc6a90a37b7f3e4121034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450", + "asm": "3044022045c758f5e3147542f13271c8aa67b630c99dd7c320048d23b6c7ae6dd88da11e02203dc7f97bb4caaf8a2e8ef122e2f9f58856bf3cca025ae5c0c0fc6a90a37b7f3e[ALL|FORKID] 034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450" + }, + "value": 52373, + "legacyAddress": "1BsLbZEGLNWxFYeY5VKpTDo9w2YbkapRKx", + "cashAddress": "bitcoincash:qpmnv60xzz08weaqurc45g8q4548plpgsqkkcsf205" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530455494f50085468652055494f50136272656e646f6e40626974636f696e2e636f6d4c0001004c000800000000075bcd15", + "asm": "OP_RETURN 5262419 1 47454e45534953 1347373397 5468652055494f50 6272656e646f6e40626974636f696e2e636f6d 0 0 0 00000000075bcd15" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a91494b7e08912767f44d90284941aee3df3f8ec34c688ac", + "asm": "OP_DUP OP_HASH160 94b7e08912767f44d90284941aee3df3f8ec34c6 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EZMHPvTbfsTdE9x3XTcadAXoBuR53qb15" + ], + "type": "pubkeyhash" + }, + "spentTxId": "f23e7a2a718cacc5e2a74339ab3648ef6f7174eaad1183cd2436ae8ffcd612a9", + "spentIndex": 0, + "spentHeight": 571670 + }, + { + "value": "0.00052490", + "n": 2, + "scriptPubKey": { + "hex": "76a9141a57f5dee20963fd45bc4cfe85fd65d2852e155888ac", + "asm": "OP_DUP OP_HASH160 1a57f5dee20963fd45bc4cfe85fd65d2852e1558 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "13QHwaxq9Ze6QhC7rHBCSwdHYptUtVouV7" + ], + "type": "pubkeyhash" + }, + "spentTxId": "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503", + "spentIndex": 0, + "spentHeight": 571487 + } + ], + "blockhash": "000000000000000003c6b560b41276653b46be51a290e76691153c35a0405830", + "blockheight": 571487, + "confirmations": 3795, + "time": 1551215049, + "blocktime": 1551215049, + "valueOut": 0.00053036, + "size": 447, + "valueIn": 0.00053484, + "fees": 0.00000448 + }, + { + "txid": "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503", + "version": 1, + "locktime": 571486, + "vin": [ + { + "txid": "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d", + "vout": 2, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220178c9c0047bb3232c5f1888e9a00bd8ea5ab854e981ec29595017363f4ce070502203d8ba8441530bb153f7b6f36cdca9346180b37992c49a57b0a50123791cff4eb412103cdf15d63def344d01199654c1973ccd25295e9734ee4cc80d605cb8fefc2e85c", + "asm": "30440220178c9c0047bb3232c5f1888e9a00bd8ea5ab854e981ec29595017363f4ce070502203d8ba8441530bb153f7b6f36cdca9346180b37992c49a57b0a50123791cff4eb[ALL|FORKID] 03cdf15d63def344d01199654c1973ccd25295e9734ee4cc80d605cb8fefc2e85c" + }, + "value": 52490, + "legacyAddress": "13QHwaxq9Ze6QhC7rHBCSwdHYptUtVouV7", + "cashAddress": "bitcoincash:qqd90aw7ugyk8l29h3x0ap0avhfg2ts4tq0ejfpvm3" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530555494f50320b5468652055494f50205632136272656e646f6e40626974636f696e2e636f6d4c0001044c00080000011f71fb0450", + "asm": "OP_RETURN 5262419 1 47454e45534953 55494f5032 5468652055494f50205632 6272656e646f6e40626974636f696e2e636f6d 0 4 0 0000011f71fb0450" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a9144a3d99924e2fe34084d152002de16a6ebeea3df188ac", + "asm": "OP_DUP OP_HASH160 4a3d99924e2fe34084d152002de16a6ebeea3df1 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "17mYoEPFPrSgS41w3XdZXbQu4o9MwBB1Gk" + ], + "type": "pubkeyhash" + }, + "spentTxId": "52126b7cb58da053a88ddafacdb6fb97223c0661331a5099a1a04153aace265f", + "spentIndex": 1, + "spentHeight": 571531 + }, + { + "value": "0.00051640", + "n": 2, + "scriptPubKey": { + "hex": "76a9144350d53fbdedc5fc9e7d5eb555130241df997e3988ac", + "asm": "OP_DUP OP_HASH160 4350d53fbdedc5fc9e7d5eb555130241df997e39 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "178w7EEhK3exYmiiuqc4MAWM5meULobznd" + ], + "type": "pubkeyhash" + }, + "spentTxId": "52126b7cb58da053a88ddafacdb6fb97223c0661331a5099a1a04153aace265f", + "spentIndex": 0, + "spentHeight": 571531 + } + ], + "blockhash": "000000000000000003c6b560b41276653b46be51a290e76691153c35a0405830", + "blockheight": 571487, + "confirmations": 3795, + "time": 1551215049, + "blocktime": 1551215049, + "valueOut": 0.00052186, + "size": 303, + "valueIn": 0.0005249, + "fees": 0.00000304 + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_txdetails_aar_bch.json b/slpwallet/src/test/resources/rest_txdetails_aar_bch.json new file mode 100644 index 0000000..793fa02 --- /dev/null +++ b/slpwallet/src/test/resources/rest_txdetails_aar_bch.json @@ -0,0 +1,508 @@ +[ + { + "txid": "71c81c6904d517d9057ec76b5b8188401df479ff7673a3c3ea7128480f54ca44", + "version": 2, + "locktime": 0, + "vin": [ + { + "txid": "c0c272aa515c797a7f99f1286591427010427985e5176aa3602bcb1541e41982", + "vout": 2, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "483045022100cee10792e33920ac5c33fd3c3bd615d0304dc1fb740ccd99e24d2d69392bcda702202d4335ceb33208c68e0ada04bd10645c5bcf7d09f351f9550e877f70167a395f412102ef1d30bdb7b2b79040630ac9fe4de21146750ac5dd912543c687b4e0d11c7257", + "asm": "3045022100cee10792e33920ac5c33fd3c3bd615d0304dc1fb740ccd99e24d2d69392bcda702202d4335ceb33208c68e0ada04bd10645c5bcf7d09f351f9550e877f70167a395f[ALL|FORKID] 02ef1d30bdb7b2b79040630ac9fe4de21146750ac5dd912543c687b4e0d11c7257" + }, + "value": 2166, + "legacyAddress": "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ", + "cashAddress": "bitcoincash:qqt4kqkhgrr9vwr6hff93ju9fvtssjsz9cd9dqk50f" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010453454e4420b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a98937720800000000000000640800000000000002aa", + "asm": "OP_RETURN 5262419 1 1145980243 b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772 0000000000000064 00000000000002aa" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a9148b76343309322633cd910fda874c59806f09439888ac", + "asm": "OP_DUP OP_HASH160 8b76343309322633cd910fda874c59806f094398 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1DiQXci2QKPMuDPoZDa8YTenKzYszVcv1B" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00001280", + "n": 2, + "scriptPubKey": { + "hex": "76a914175b02d740c656387aba5258cb854b17084a022e88ac", + "asm": "OP_DUP OP_HASH160 175b02d740c656387aba5258cb854b17084a022e OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ" + ], + "type": "pubkeyhash" + }, + "spentTxId": "629c2253a6da69d636579767546cc23407d488e573c39a0be193a38f8cddf8de", + "spentIndex": 0, + "spentHeight": 574433 + } + ], + "blockhash": "000000000000000004bae7930bb23f262fb5032b58d67635f894592bcab1f00b", + "blockheight": 574428, + "confirmations": 88, + "time": 1552974990, + "blocktime": 1552974990, + "valueOut": 0.00001826, + "size": 299, + "valueIn": 0.00002166, + "fees": 0.0000034 + }, + { + "txid": "4e9a367ef6a1692a6a76e670d131ee13c3e5810b2373bc20a0e91d6710479a18", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "84fc39aa3396795d3211bf9239c2179e170585e0771ab2594aa50ed4c7023240", + "vout": 0, + "sequence": 4294967295, + "n": 0, + "scriptSig": { + "hex": "47304402207b5b3474da63d258bbec923d74425d1cc75f697cbf1cb5fbb70a4fed243cc5ae02204bcc60f290927d44ece6107ea7c090992af2fb920cc16549dc33457c1abfe7144121035b469eb0e26b14011f13815fbcb80701580002fb4de05b24d328b3f4a5df398a", + "asm": "304402207b5b3474da63d258bbec923d74425d1cc75f697cbf1cb5fbb70a4fed243cc5ae02204bcc60f290927d44ece6107ea7c090992af2fb920cc16549dc33457c1abfe714[ALL|FORKID] 035b469eb0e26b14011f13815fbcb80701580002fb4de05b24d328b3f4a5df398a" + }, + "value": 2807548, + "legacyAddress": "1JMeeqkTGt8vPkDzG7BkwH6ktjhd3K4F1x", + "cashAddress": "bitcoincash:qzlxrcmztxqfa25cfwfjsq02tkz6jz0uvs36vpuquv" + } + ], + "vout": [ + { + "value": "0.02799733", + "n": 0, + "scriptPubKey": { + "hex": "76a914395711f17ae3f10387cfda04e032e54d7190e16788ac", + "asm": "OP_DUP OP_HASH160 395711f17ae3f10387cfda04e032e54d7190e167 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "16EBpfsvqwPNbhx9gWQcg88m59SHxgYHTc" + ], + "type": "pubkeyhash" + }, + "spentTxId": "0ef758b2db2b170c69a5fb0c4bf542fc6ecf7a98f7ea239025fe8de932a07894", + "spentIndex": 0, + "spentHeight": 573840 + }, + { + "value": "0.00007572", + "n": 1, + "scriptPubKey": { + "hex": "76a914175b02d740c656387aba5258cb854b17084a022e88ac", + "asm": "OP_DUP OP_HASH160 175b02d740c656387aba5258cb854b17084a022e OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ" + ], + "type": "pubkeyhash" + }, + "spentTxId": "5e164260321fd29c10d86677b793b375a0e4149538a0be8599f055f909feff40", + "spentIndex": 1, + "spentHeight": 573839 + } + ], + "blockhash": "000000000000000001254559c31de5044ce6067bce5b4a5fe5a397c0bf65f870", + "blockheight": 573822, + "confirmations": 698, + "time": 1552616894, + "blocktime": 1552616894, + "valueOut": 0.02807305, + "size": 225, + "valueIn": 0.02807548, + "fees": 0.00000243 + }, + { + "txid": "263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e", + "version": 1, + "locktime": 573285, + "vin": [ + { + "txid": "a8ce662c6aea8190b650943fc2d3aeeab31b1bc9e93591ab440b65035a38d678", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540412102829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6", + "asm": "30440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540[ALL|FORKID] 02829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6" + }, + "value": 2111406, + "legacyAddress": "1QCwbchDrw7xt2U83YJSzF7aPSxxsLAPUx", + "cashAddress": "bitcoincash:qrlg6zej97zuywj7ea37dn86ps27z4n6cvj7sjft6r" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530358525006526970706c654c004c0001064c0008016345785d8a0000", + "asm": "OP_RETURN 5262419 1 47454e45534953 5263960 526970706c65 0 0 6 0 016345785d8a0000" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a703166d96e62635722e272211539330705247fa88ac", + "asm": "OP_DUP OP_HASH160 a703166d96e62635722e272211539330705247fa OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GE5YZrvGtJmdQgCbVwg9Jrb5ztsie4SNU" + ], + "type": "pubkeyhash" + }, + "spentTxId": "d283ed88973257c7440aad3446390a0518818d9e0ad050a6dc918a01752e3d96", + "spentIndex": 1, + "spentHeight": 573309 + }, + { + "value": "0.02110581", + "n": 2, + "scriptPubKey": { + "hex": "76a91495461dc0f0c5512ae6e2703fdb2852b0b42c476588ac", + "asm": "OP_DUP OP_HASH160 95461dc0f0c5512ae6e2703fdb2852b0b42c4765 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EcHgLvSkyMPEYQRJxZNt4oAGE5EUWkMew" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "000000000000000001c00f354bc4121c27b21ed9d371368c9f57a7db66a674d0", + "blockheight": 573286, + "confirmations": 1992, + "time": 1552295947, + "blocktime": 1552295947, + "valueOut": 0.02111127, + "size": 278, + "valueIn": 0.02111406, + "fees": 0.00000279 + }, + { + "txid": "b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772", + "version": 1, + "locktime": 573802, + "vin": [ + { + "txid": "05b70a830f9b8e3842c21d2ee2ad022047b646a06deae7a6d88da8da1eaa0872", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "47304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb412102b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605", + "asm": "304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb[ALL|FORKID] 02b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605" + }, + "value": 43568, + "legacyAddress": "1MabofMTV9yJ6CJTYEst5WdoiLAaqm2Zqb", + "cashAddress": "bitcoincash:qrsm6jakv24ghpqyqta0frm7rfkehz4pasayejgjek" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530341415208416172647661726b4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 5390657 416172647661726b 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a85a11b9c81c5761e262d7ab76cc0656fd2f0f4588ac", + "asm": "OP_DUP OP_HASH160 a85a11b9c81c5761e262d7ab76cc0656fd2f0f45 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GMARMBykmPjXm83KmKJckqY4FfKmkx429" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 0, + "spentHeight": 573803 + }, + { + "value": "0.00042741", + "n": 2, + "scriptPubKey": { + "hex": "76a914854bab72529070b256e1bd519d2f63090d94947988ac", + "asm": "OP_DUP OP_HASH160 854bab72529070b256e1bd519d2f63090d949479 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1D9oX8xqpBM9YGxG5tgjstnJXiAj4g7gKC" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 1, + "spentHeight": 573803 + } + ], + "blockhash": "000000000000000001e5209c39b3ab1ec7863efd72f95fb78fa7f468ffdf310e", + "blockheight": 573803, + "confirmations": 1479, + "time": 1552610189, + "blocktime": 1552610189, + "valueOut": 0.00043287, + "size": 280, + "valueIn": 0.00043568, + "fees": 0.00000281 + }, + { + "txid": "775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e", + "version": 1, + "locktime": 573835, + "vin": [ + { + "txid": "abc3fbf2e3c8459cb134a6a44d57c5c99322addba67cd2f099c9571773203309", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a024121030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183", + "asm": "3045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a02[ALL|FORKID] 030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183" + }, + "value": 38672, + "legacyAddress": "185MEfnZKCbVghEXdPR1sdWSKJ55vEZuni", + "cashAddress": "bitcoincash:qpxekmatley3u9ggshfgh8p4stpc6rzwxuluahnush" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e4553495303414e4705416e67656c4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 4673089 416e67656c 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914ab80783acd048d99703eedcbcc895b6bf987c16488ac", + "asm": "OP_DUP OP_HASH160 ab80783acd048d99703eedcbcc895b6bf987c164 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GdpT2msFCQVsMsKNm4MAkB4URR1xV5fYX" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 0, + "spentHeight": 573837 + }, + { + "value": "0.00037848", + "n": 2, + "scriptPubKey": { + "hex": "76a91469fa8f94e2245695f3f593901b9093b69e353bd488ac", + "asm": "OP_DUP OP_HASH160 69fa8f94e2245695f3f593901b9093b69e353bd4 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1AfN7R9ZBSqfstKvaBroxMtBpAbtPh2VjK" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 1, + "spentHeight": 573837 + } + ], + "blockhash": "00000000000000000210dbca65947d6330f8ae05cebd3454738eeabc31973521", + "blockheight": 573837, + "confirmations": 1445, + "time": 1552620880, + "blocktime": 1552620880, + "valueOut": 0.00038394, + "size": 278, + "valueIn": 0.00038672, + "fees": 0.00000278 + }, + { + "txid": "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d", + "version": 1, + "locktime": 571486, + "vin": [ + { + "txid": "583c8fd73a7aea4db3bab433c1410bb2577bc58340fe938a81fd2f1941c5d11d", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b334792321e7217a66fb69621960c2d8d2b64f042919d245b0b8ffaf3b39b9ff02202f95d816f177279e69f69c557154370fa7f8a1d6f75ff76d9f56a77f231d68874121034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450", + "asm": "3045022100b334792321e7217a66fb69621960c2d8d2b64f042919d245b0b8ffaf3b39b9ff02202f95d816f177279e69f69c557154370fa7f8a1d6f75ff76d9f56a77f231d6887[ALL|FORKID] 034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450" + }, + "value": 1111, + "legacyAddress": "1BsLbZEGLNWxFYeY5VKpTDo9w2YbkapRKx", + "cashAddress": "bitcoincash:qpmnv60xzz08weaqurc45g8q4548plpgsqkkcsf205" + }, + { + "txid": "956095f538fe857f86cef79a3e7f5564e9774ae2a9878ba8e9b998eef9d419fe", + "vout": 0, + "sequence": 4294967294, + "n": 1, + "scriptSig": { + "hex": "473044022045c758f5e3147542f13271c8aa67b630c99dd7c320048d23b6c7ae6dd88da11e02203dc7f97bb4caaf8a2e8ef122e2f9f58856bf3cca025ae5c0c0fc6a90a37b7f3e4121034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450", + "asm": "3044022045c758f5e3147542f13271c8aa67b630c99dd7c320048d23b6c7ae6dd88da11e02203dc7f97bb4caaf8a2e8ef122e2f9f58856bf3cca025ae5c0c0fc6a90a37b7f3e[ALL|FORKID] 034b473c833174d272d932f1cbb768d0df8be1057cb12100a99547f0155ed1a450" + }, + "value": 52373, + "legacyAddress": "1BsLbZEGLNWxFYeY5VKpTDo9w2YbkapRKx", + "cashAddress": "bitcoincash:qpmnv60xzz08weaqurc45g8q4548plpgsqkkcsf205" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530455494f50085468652055494f50136272656e646f6e40626974636f696e2e636f6d4c0001004c000800000000075bcd15", + "asm": "OP_RETURN 5262419 1 47454e45534953 1347373397 5468652055494f50 6272656e646f6e40626974636f696e2e636f6d 0 0 0 00000000075bcd15" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a91494b7e08912767f44d90284941aee3df3f8ec34c688ac", + "asm": "OP_DUP OP_HASH160 94b7e08912767f44d90284941aee3df3f8ec34c6 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EZMHPvTbfsTdE9x3XTcadAXoBuR53qb15" + ], + "type": "pubkeyhash" + }, + "spentTxId": "f23e7a2a718cacc5e2a74339ab3648ef6f7174eaad1183cd2436ae8ffcd612a9", + "spentIndex": 0, + "spentHeight": 571670 + }, + { + "value": "0.00052490", + "n": 2, + "scriptPubKey": { + "hex": "76a9141a57f5dee20963fd45bc4cfe85fd65d2852e155888ac", + "asm": "OP_DUP OP_HASH160 1a57f5dee20963fd45bc4cfe85fd65d2852e1558 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "13QHwaxq9Ze6QhC7rHBCSwdHYptUtVouV7" + ], + "type": "pubkeyhash" + }, + "spentTxId": "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503", + "spentIndex": 0, + "spentHeight": 571487 + } + ], + "blockhash": "000000000000000003c6b560b41276653b46be51a290e76691153c35a0405830", + "blockheight": 571487, + "confirmations": 3795, + "time": 1551215049, + "blocktime": 1551215049, + "valueOut": 0.00053036, + "size": 447, + "valueIn": 0.00053484, + "fees": 0.00000448 + }, + { + "txid": "3257135d7c351f8b2f46ab2b5e610620beb7a957f3885ce1787cffa90582f503", + "version": 1, + "locktime": 571486, + "vin": [ + { + "txid": "8fff3e288b53d49f7469159c3f535f500eeeb3d01d6b7474497a7658d6a0193d", + "vout": 2, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220178c9c0047bb3232c5f1888e9a00bd8ea5ab854e981ec29595017363f4ce070502203d8ba8441530bb153f7b6f36cdca9346180b37992c49a57b0a50123791cff4eb412103cdf15d63def344d01199654c1973ccd25295e9734ee4cc80d605cb8fefc2e85c", + "asm": "30440220178c9c0047bb3232c5f1888e9a00bd8ea5ab854e981ec29595017363f4ce070502203d8ba8441530bb153f7b6f36cdca9346180b37992c49a57b0a50123791cff4eb[ALL|FORKID] 03cdf15d63def344d01199654c1973ccd25295e9734ee4cc80d605cb8fefc2e85c" + }, + "value": 52490, + "legacyAddress": "13QHwaxq9Ze6QhC7rHBCSwdHYptUtVouV7", + "cashAddress": "bitcoincash:qqd90aw7ugyk8l29h3x0ap0avhfg2ts4tq0ejfpvm3" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530555494f50320b5468652055494f50205632136272656e646f6e40626974636f696e2e636f6d4c0001044c00080000011f71fb0450", + "asm": "OP_RETURN 5262419 1 47454e45534953 55494f5032 5468652055494f50205632 6272656e646f6e40626974636f696e2e636f6d 0 4 0 0000011f71fb0450" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a9144a3d99924e2fe34084d152002de16a6ebeea3df188ac", + "asm": "OP_DUP OP_HASH160 4a3d99924e2fe34084d152002de16a6ebeea3df1 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "17mYoEPFPrSgS41w3XdZXbQu4o9MwBB1Gk" + ], + "type": "pubkeyhash" + }, + "spentTxId": "52126b7cb58da053a88ddafacdb6fb97223c0661331a5099a1a04153aace265f", + "spentIndex": 1, + "spentHeight": 571531 + }, + { + "value": "0.00051640", + "n": 2, + "scriptPubKey": { + "hex": "76a9144350d53fbdedc5fc9e7d5eb555130241df997e3988ac", + "asm": "OP_DUP OP_HASH160 4350d53fbdedc5fc9e7d5eb555130241df997e39 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "178w7EEhK3exYmiiuqc4MAWM5meULobznd" + ], + "type": "pubkeyhash" + }, + "spentTxId": "52126b7cb58da053a88ddafacdb6fb97223c0661331a5099a1a04153aace265f", + "spentIndex": 0, + "spentHeight": 571531 + } + ], + "blockhash": "000000000000000003c6b560b41276653b46be51a290e76691153c35a0405830", + "blockheight": 571487, + "confirmations": 3795, + "time": 1551215049, + "blocktime": 1551215049, + "valueOut": 0.00052186, + "size": 303, + "valueIn": 0.0005249, + "fees": 0.00000304 + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_txdetails_aar_genesis.json b/slpwallet/src/test/resources/rest_txdetails_aar_genesis.json new file mode 100644 index 0000000..2cda552 --- /dev/null +++ b/slpwallet/src/test/resources/rest_txdetails_aar_genesis.json @@ -0,0 +1,87 @@ +[ + { + "debugComment": "AAR Genesis", + "txid": "b75d9a2f2251deea547f80358158817e791671b865a3f1a80da840e4a9893772", + "version": 1, + "locktime": 573802, + "vin": [ + { + "txid": "05b70a830f9b8e3842c21d2ee2ad022047b646a06deae7a6d88da8da1eaa0872", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "47304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb412102b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605", + "asm": "304402204ca4d6a5f9add6f3b4b2e49f56f44067b3abcf73da999dde4a1b2ca3553d6156022032616d45702a3eac59c50388227d3157194f88a1f9ff1daf70a6d3beb484f7eb[ALL|FORKID] 02b431abf5d47cd1bc9768c3ce9be5b91504fd0a1b39ea0535fe1fca03908fa605" + }, + "value": 43568, + "legacyAddress": "1MabofMTV9yJ6CJTYEst5WdoiLAaqm2Zqb", + "cashAddress": "bitcoincash:qrsm6jakv24ghpqyqta0frm7rfkehz4pasayejgjek" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530341415208416172647661726b4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 5390657 416172647661726b 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a85a11b9c81c5761e262d7ab76cc0656fd2f0f4588ac", + "asm": "OP_DUP OP_HASH160 a85a11b9c81c5761e262d7ab76cc0656fd2f0f45 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GMARMBykmPjXm83KmKJckqY4FfKmkx429" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 0, + "spentHeight": 573803 + }, + { + "value": "0.00042741", + "n": 2, + "scriptPubKey": { + "hex": "76a914854bab72529070b256e1bd519d2f63090d94947988ac", + "asm": "OP_DUP OP_HASH160 854bab72529070b256e1bd519d2f63090d949479 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1D9oX8xqpBM9YGxG5tgjstnJXiAj4g7gKC" + ], + "type": "pubkeyhash" + }, + "spentTxId": "870fd05ee0f0fcea249a1c7cca62ddfb9824b8972c04b04ec6e79a52240f2db9", + "spentIndex": 1, + "spentHeight": 573803 + } + ], + "blockhash": "000000000000000001e5209c39b3ab1ec7863efd72f95fb78fa7f468ffdf310e", + "blockheight": 573803, + "confirmations": 1482, + "time": 1552610189, + "blocktime": 1552610189, + "valueOut": 0.00043287, + "size": 280, + "valueIn": 0.00043568, + "fees": 0.00000281, + "tokenInfo": { + "versionType": 1, + "transactionType": "GENESIS", + "symbol": "AAR", + "name": "Aardvark", + "documentUri": "", + "documentSha256": null, + "decimals": 2, + "batonVout": null, + "genesisOrMintQuantity": "100000000" + }, + "tokenIsValid": true + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_txdetails_ang_genesis.json b/slpwallet/src/test/resources/rest_txdetails_ang_genesis.json new file mode 100644 index 0000000..6522669 --- /dev/null +++ b/slpwallet/src/test/resources/rest_txdetails_ang_genesis.json @@ -0,0 +1,87 @@ +[ + { + "debugComment": "ANG Genesis", + "txid": "775a3902829c48c56acb62d5493946c025aa80f43959fdfd6aa3c5fced07366e", + "version": 1, + "locktime": 573835, + "vin": [ + { + "txid": "abc3fbf2e3c8459cb134a6a44d57c5c99322addba67cd2f099c9571773203309", + "vout": 3, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "483045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a024121030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183", + "asm": "3045022100b7a3577aeaa3dc946175c91f4fd588730c98f415b25c9843f542fff99ab5f2f2022074ccce7703e72cc383139e9341a8625bef7eeeb53975d0a30554b30ff8784a02[ALL|FORKID] 030da7d293daba46dfe6af7a41bcc1fdaf7fc1e0b0da60d6753dd631de8bf8c183" + }, + "value": 38672, + "legacyAddress": "185MEfnZKCbVghEXdPR1sdWSKJ55vEZuni", + "cashAddress": "bitcoincash:qpxekmatley3u9ggshfgh8p4stpc6rzwxuluahnush" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e4553495303414e4705416e67656c4c004c0001024c00080000000005f5e100", + "asm": "OP_RETURN 5262419 1 47454e45534953 4673089 416e67656c 0 0 2 0 0000000005f5e100" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914ab80783acd048d99703eedcbcc895b6bf987c16488ac", + "asm": "OP_DUP OP_HASH160 ab80783acd048d99703eedcbcc895b6bf987c164 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GdpT2msFCQVsMsKNm4MAkB4URR1xV5fYX" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 0, + "spentHeight": 573837 + }, + { + "value": "0.00037848", + "n": 2, + "scriptPubKey": { + "hex": "76a91469fa8f94e2245695f3f593901b9093b69e353bd488ac", + "asm": "OP_DUP OP_HASH160 69fa8f94e2245695f3f593901b9093b69e353bd4 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1AfN7R9ZBSqfstKvaBroxMtBpAbtPh2VjK" + ], + "type": "pubkeyhash" + }, + "spentTxId": "449e468fdfc9e8991cadadc83aa2b1a6399528c5c99818def2414682304f25bd", + "spentIndex": 1, + "spentHeight": 573837 + } + ], + "blockhash": "00000000000000000210dbca65947d6330f8ae05cebd3454738eeabc31973521", + "blockheight": 573837, + "confirmations": 1448, + "time": 1552620880, + "blocktime": 1552620880, + "valueOut": 0.00038394, + "size": 278, + "valueIn": 0.00038672, + "fees": 0.00000278, + "tokenInfo": { + "versionType": 1, + "transactionType": "GENESIS", + "symbol": "ANG", + "name": "Angel", + "documentUri": "", + "documentSha256": null, + "decimals": 2, + "batonVout": null, + "genesisOrMintQuantity": "100000000" + }, + "tokenIsValid": true + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_txdetails_xrp_genesis.json b/slpwallet/src/test/resources/rest_txdetails_xrp_genesis.json new file mode 100644 index 0000000..24427bc --- /dev/null +++ b/slpwallet/src/test/resources/rest_txdetails_xrp_genesis.json @@ -0,0 +1,74 @@ +[ + { + "txid": "263ca75dd8ab35e699808896255212b374f2fb185fb0389297a11f63d8d41f7e", + "version": 1, + "locktime": 573285, + "vin": [ + { + "txid": "a8ce662c6aea8190b650943fc2d3aeeab31b1bc9e93591ab440b65035a38d678", + "vout": 0, + "sequence": 4294967294, + "n": 0, + "scriptSig": { + "hex": "4730440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540412102829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6", + "asm": "30440220510bc14ff4918dbba93fd396966cd1d4f23d4b88aafea756feee589d85cb4d8f022052b5b941f45b039e0b677c5bc6485f544e3f3c46990452f7bf125f4fa5ed4540[ALL|FORKID] 02829084a51cda5c0abfe362c07daf02479b8c042f17b1e7072ae763a0f560c8c6" + }, + "value": 2111406, + "legacyAddress": "1QCwbchDrw7xt2U83YJSzF7aPSxxsLAPUx", + "cashAddress": "bitcoincash:qrlg6zej97zuywj7ea37dn86ps27z4n6cvj7sjft6r" + } + ], + "vout": [ + { + "value": "0.00000000", + "n": 0, + "scriptPubKey": { + "hex": "6a04534c500001010747454e455349530358525006526970706c654c004c0001064c0008016345785d8a0000", + "asm": "OP_RETURN 5262419 1 47454e45534953 5263960 526970706c65 0 0 6 0 016345785d8a0000" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + }, + { + "value": "0.00000546", + "n": 1, + "scriptPubKey": { + "hex": "76a914a703166d96e62635722e272211539330705247fa88ac", + "asm": "OP_DUP OP_HASH160 a703166d96e62635722e272211539330705247fa OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1GE5YZrvGtJmdQgCbVwg9Jrb5ztsie4SNU" + ], + "type": "pubkeyhash" + }, + "spentTxId": "d283ed88973257c7440aad3446390a0518818d9e0ad050a6dc918a01752e3d96", + "spentIndex": 1, + "spentHeight": 573309 + }, + { + "value": "0.02110581", + "n": 2, + "scriptPubKey": { + "hex": "76a91495461dc0f0c5512ae6e2703fdb2852b0b42c476588ac", + "asm": "OP_DUP OP_HASH160 95461dc0f0c5512ae6e2703fdb2852b0b42c4765 OP_EQUALVERIFY OP_CHECKSIG", + "addresses": [ + "1EcHgLvSkyMPEYQRJxZNt4oAGE5EUWkMew" + ], + "type": "pubkeyhash" + }, + "spentTxId": null, + "spentIndex": null, + "spentHeight": null + } + ], + "blockhash": "000000000000000001c00f354bc4121c27b21ed9d371368c9f57a7db66a674d0", + "blockheight": 573286, + "confirmations": 1992, + "time": 1552295947, + "blocktime": 1552295947, + "valueOut": 0.02111127, + "size": 278, + "valueIn": 0.02111406, + "fees": 0.00000279 + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_utxos.json b/slpwallet/src/test/resources/rest_utxos.json new file mode 100644 index 0000000..cf865d4 --- /dev/null +++ b/slpwallet/src/test/resources/rest_utxos.json @@ -0,0 +1,41 @@ +[ + { + "utxos": [ + { + "txid": "5da23019e80a9a5a83ead96d426070483fed854c92e71637b42daec766c066fe", + "vout": 0, + "amount": 0.00077346, + "satoshis": 77346, + "height": 573722, + "confirmations": 1 + }, + { + "txid": "962aa9115c5170becb6051c79692217eea1877906ced3a69552e76ef95cb7f0b", + "vout": 2, + "amount": 0.00005461, + "satoshis": 5461, + "height": 573722, + "confirmations": 1 + }, + { + "txid": "3b63d0f1d67ea52f7c57cd130c3118a3a8a30286bf3b7a6bf4b8ad8b885fca69", + "vout": 1, + "amount": 0.00000546, + "satoshis": 546, + "height": 573622, + "confirmations": 101 + }, + { + "txid": "e28a36df4d56fd04a715c969e874e8bafefa1ac29772d75ce7de762f8a8cf06f", + "vout": 2, + "amount": 0.00984573, + "satoshis": 984573, + "height": 573621, + "confirmations": 102 + } + ], + "legacyAddress": "18ZzbSgQanNFssJrrLCKNfkfSwt38KL1t5", + "cashAddress": "bitcoincash:qpfsv39800lr75qzhwqjzqtqlqp9r4pk4spq58nxdk", + "scriptPubKey": "76a914530644a77bfe3f5002bb81210160f80251d436ac88ac" + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_utxos_aar_ang_bch.json b/slpwallet/src/test/resources/rest_utxos_aar_ang_bch.json new file mode 100644 index 0000000..6c7ace2 --- /dev/null +++ b/slpwallet/src/test/resources/rest_utxos_aar_ang_bch.json @@ -0,0 +1,33 @@ +[ + { + "utxos": [ + { + "txid": "71c81c6904d517d9057ec76b5b8188401df479ff7673a3c3ea7128480f54ca44", + "vout": 2, + "amount": 0.00001280, + "satoshis": 1280, + "height": 574428, + "confirmations": 83 + }, + { + "txid": "2460c85e7f782bd3b7c9816687c6c2736900367fd1c6efee60953c3a23f010ac", + "vout": 2, + "amount": 0.00000546, + "satoshis": 546, + "height": 574437, + "confirmations": 63 + }, + { + "txid": "4e9a367ef6a1692a6a76e670d131ee13c3e5810b2373bc20a0e91d6710479a18", + "vout": 1, + "amount": 0.00007572, + "satoshis": 7572, + "height": 573822, + "confirmations": 183 + } + ], + "legacyAddress": "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ", + "cashAddress": "bitcoincash:qqt4kqkhgrr9vwr6hff93ju9fvtssjsz9cd9dqk50f", + "scriptPubKey": "76a914175b02d740c656387aba5258cb854b17084a022e88ac" + } +] \ No newline at end of file diff --git a/slpwallet/src/test/resources/rest_utxos_aar_bch.json b/slpwallet/src/test/resources/rest_utxos_aar_bch.json new file mode 100644 index 0000000..34b83bf --- /dev/null +++ b/slpwallet/src/test/resources/rest_utxos_aar_bch.json @@ -0,0 +1,25 @@ +[ + { + "utxos": [ + { + "txid": "71c81c6904d517d9057ec76b5b8188401df479ff7673a3c3ea7128480f54ca44", + "vout": 2, + "amount": 0.00001280, + "satoshis": 1280, + "height": 574428, + "confirmations": 83 + }, + { + "txid": "4e9a367ef6a1692a6a76e670d131ee13c3e5810b2373bc20a0e91d6710479a18", + "vout": 1, + "amount": 0.00007572, + "satoshis": 7572, + "height": 573822, + "confirmations": 183 + } + ], + "legacyAddress": "138VZyVTwTPUucy9CMCUKiYsHsaYWunCDZ", + "cashAddress": "bitcoincash:qqt4kqkhgrr9vwr6hff93ju9fvtssjsz9cd9dqk50f", + "scriptPubKey": "76a914175b02d740c656387aba5258cb854b17084a022e88ac" + } +] \ No newline at end of file