diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index db02e9ea9..790ff4276 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -8,7 +8,7 @@ runs: run: | echo "home=${HOME}" >> "$GITHUB_ENV" - name: Set up Java - uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 + uses: actions/setup-java@2dfa2011c5b2a0f1489bf9e433881c92c1631f88 with: distribution: 'temurin' java-version: 17 @@ -18,7 +18,7 @@ runs: ANDROID_ROOT=/usr/local/lib/android ANDROID_SDK_ROOT=${ANDROID_ROOT}/sdk SDKMANAGER=${ANDROID_SDK_ROOT}/cmdline-tools/latest/bin/sdkmanager - echo "y" | $SDKMANAGER "ndk;26.1.10909125" + echo "y" | $SDKMANAGER "ndk;27.0.12077973" - name: Setup Rust shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e5fad59bd..38c5a93f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `TransactionOverview.isShielding` has been added to indicate the shielding transaction type + +### Changed +- NDK version has been updated to `27.0.12077973` +- Android `compileSdkVersion` and `targetSdkVersion` has been updated to 35 +- `CompackBlockProcessor.calculatePollInterval` now uses a randomized poll interval to avoid exposing computation time + +### Fixed +- Android 15 (SDK level 35) support added for 16 KB memory page size + +## [2.2.3] - 2024-09-09 + ### Changed - Several functions have been updated to accept `cash.z.ecc.android.sdk.model.Locale` instead of `cash.z.ecc.android.sdk.model.MonetarySeparators` as an argument. MonetarySeparators are derived from Locale now. @@ -18,7 +31,12 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `Double?.convertUsdToZec` has been added as we are moving away from `BigDecimal` in favor of primitive types - `Locale.getDefault()` has been added -- `TransactionOverview.isShielding` has been added to indicate a shielding transaction +- Transaction resubmission feature has been added to the CompactBlockProcessor's regular actions. This new action + periodically checks unmined sent transactions that are still within their expiry window and resubmits them if + there are any. + +### Fixed +- Fastest Server calculation changed for estimated height ## [2.2.2] - 2024-09-03 diff --git a/backend-lib/build.gradle.kts b/backend-lib/build.gradle.kts index 9a1005c14..ba6f67111 100644 --- a/backend-lib/build.gradle.kts +++ b/backend-lib/build.gradle.kts @@ -84,6 +84,12 @@ cargo { ) profile = "release" prebuiltToolchains = true + // To force the compiler to use the given page size + // See the new Android 16 KB page size requirement for more details: + // https://developer.android.com/about/versions/15/behavior-changes-all#16-kb + exec = { spec, _ -> + spec.environment["RUST_ANDROID_GRADLE_CC_LINK_ARG"] = "-Wl,-z,max-page-size=16384" + } } // As a workaround to the Gradle (starting from v7.4.1) and Rust Android Gradle plugin (starting from v0.9.3) diff --git a/build-conventions/src/main/kotlin/zcash-sdk.android-conventions.gradle.kts b/build-conventions/src/main/kotlin/zcash-sdk.android-conventions.gradle.kts index 715bc6c43..950dfdb9c 100644 --- a/build-conventions/src/main/kotlin/zcash-sdk.android-conventions.gradle.kts +++ b/build-conventions/src/main/kotlin/zcash-sdk.android-conventions.gradle.kts @@ -35,7 +35,8 @@ pluginManager.withPlugin("com.android.library") { defaultConfig { minSdk = project.property("ANDROID_MIN_SDK_VERSION").toString().toInt() // This is deprecated but there isn't a replacement for it yet with instrumentation tests. - targetSdk = project.property("ANDROID_TARGET_SDK_VERSION").toString().toInt() + lint.targetSdk = project.property("ANDROID_TARGET_SDK_VERSION").toString().toInt() + testOptions.targetSdk = project.property("ANDROID_TARGET_SDK_VERSION").toString().toInt() multiDexEnabled = true diff --git a/gradle.properties b/gradle.properties index 80bae0fec..1081b9771 100644 --- a/gradle.properties +++ b/gradle.properties @@ -74,13 +74,13 @@ IS_DEBUGGABLE_WHILE_BENCHMARKING=false # Versions ANDROID_MIN_SDK_VERSION=27 -ANDROID_TARGET_SDK_VERSION=34 -ANDROID_COMPILE_SDK_VERSION=34 +ANDROID_TARGET_SDK_VERSION=35 +ANDROID_COMPILE_SDK_VERSION=35 # When changing this, be sure to update the following places: # - .github/actions/setup/action.yml # - Clang major version in backend-lib/build.rs -ANDROID_NDK_VERSION=26.1.10909125 +ANDROID_NDK_VERSION=27.0.12077973 ANDROID_GRADLE_PLUGIN_VERSION=8.5.0 DETEKT_VERSION=1.23.6 @@ -95,7 +95,7 @@ GRADLE_VERSIONS_PLUGIN_VERSION=0.51.0 KSP_VERSION=1.9.23-1.0.20 KTLINT_VERSION=1.2.1 PROTOBUF_GRADLE_PLUGIN_VERSION=0.9.4 -RUST_GRADLE_PLUGIN_VERSION=0.9.3 +RUST_GRADLE_PLUGIN_VERSION=0.9.4 ANDROIDX_ACTIVITY_VERSION=1.8.2 ANDROIDX_ANNOTATION_VERSION=1.7.1 diff --git a/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2635000.json b/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2635000.json new file mode 100644 index 000000000..8ea1bceb6 --- /dev/null +++ b/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2635000.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "2635000", + "hash": "0000000000936510469feedc6c09814afb64bed2dfc6f2bf84b9585fc995a638", + "time": 1725472507, + "saplingTree": "014aec4ee318120584a5d63b3446ad73b30db681d7b6c51befadb86ba4dcd48b2b001f00000001efcdeb61d7c5cc71ceb16999553257ab67ac2f8c29b74f91367eeb4580356d7101df197a749b62e07ee373d523c8795d7ff067a3baa4deec22fdbee0d7d3875920012b0bebd3e58bf72b7c2923181f13637ad7a28bd125944757fcf9981b2afe113c00000001443df95e3fe9835e24edc7f8534a8aaf0505c418385647904caea5acadd0ce3c013e1f5c6c01be4395da13e7ff3db37edc618840a22201bdf6cb448f25b046a948016360f687872720377203b62741754d620ba92fa73359ac6caa0854db38d7a045015f57b607ee9f63c786a49df5f034279b6d7645f26ce1e9d0cbcad31660c15229010fdff8660d84e9dbb3979b612a8c64535da6d9c5820ed495c395857252d3863b000001b254e3cfba937bcd40621e1d623a943440b083892546f081aab37058fd1b763f000000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "016c29db17907191e912d000d4929fa5630a177e9910a954b176ab28b34e628b2b001f01687b54d1a8d8df472208ab4f50e4665a39106aee59069287e1711ff8cb2c0028017ef92629025fd9c81a3e0cd7c9b3e395da8907da4937020351594bd9a429a90b0001f3377c01ae9e142f6277d1edf4cfe6723f52a324ab9d3d554bef1cc0abd4221801b838055b03e108d8f3dfb6ec2a06045ed45caca6774011dd970fe5ed01a7ed05000113d22aef389acdbde9205ac1554ed76976a38686b3e992a80754c25dff774d340001348473c84ab47f1daed7cfe1980c0eb56f3c4bb61565657217752256ab2ad224016acb8166c54a52c0169502fcd761a28ade7d4e9ad43d5d04e79d0fcd3d08a1350000012e924c7b6b2a77680118ee5fc70914b953fb33b7673ab2ddceb873c26632562901f427516d6600178ef99cc049193c413103980c407ea3489a687b3c3479d2300c00000112278dfeae9949f887b70ae81e084f8897a5054627acef3efd01c8b29793d522000160040850b766b126a2b4843fcdfdffa5d5cab3f53bc860a3bef68958b5f066170001cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2637500.json b/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2637500.json new file mode 100644 index 000000000..a6f542e44 --- /dev/null +++ b/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2637500.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "2637500", + "hash": "00000000017e912451775307f70f3dc45f3c26472abcd57821cefb5397ec095d", + "time": 1725660690, + "saplingTree": "0143545ee77369853d02f5d40779ad2bde786e00ced989ac44b4e9f8b4ee028b1f01199533df7fc1e1a972ac4eb7be134a66234f9c286fbdd92596fa7399705939281f01d64856d0f6ed5cde7f5b72585d530c1e6c5155267883a36b8c189426bf30d80f014dbf82b6b2a751d69a5691fc484c1ba806f508985fc3282fdb810b87b63f385c01f49bd1f62a1cff6b37c8c513f182cee518f6eb2cb2d94a7428eba10937e1bb1d010e745ade9349a1ee37a82b740d662bb737858b6a58fc42834f9061ea496f546b000000000105a8e948e3587e3af250a809487925cab99603c74028889103dd10ecc286f85e000001985764ab0e65ac40225e22fa01dc6be6b9b5ebf14efe8f49f94bec5722a35e690000018a015f44048c81b4d523a810de3ce0f216cde33ade70ad760501bd5e08d727000001b254e3cfba937bcd40621e1d623a943440b083892546f081aab37058fd1b763f000000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "01e77d7e93c2e20ea50479189ec8223eff06fa7377e10f6ff4daf738ad3941bb2b01f387d33cba0bbcef08de97bb90f88fe2bd206b4ee9bf273ceefde3798a5f2c311f00010e2bc4a095c262944aaf4ce47b00c2e3401d4ec2d7a021945b0db01d2e2bb83101c4e89af79b5473bf471782dd97a28ac68ab1bf6da9df9582c14e63d7a166120200000001f41460af612ec2a0141597d48af9e8ed6939ec6f183cf930c64462efa34c532b015b9c9e09001ba583e70a7cac2ee145d2a8903bc87bc11e8dc169ab1b184944090134467a17a9eec4b57e54f4706dc5dae8519ed13a338c807276764be12dfced0c00017456e5ac88cf348914dc885a5b520facd0e31985bcbee5490ed74bd2d777f50100012e924c7b6b2a77680118ee5fc70914b953fb33b7673ab2ddceb873c26632562901f427516d6600178ef99cc049193c413103980c407ea3489a687b3c3479d2300c00000112278dfeae9949f887b70ae81e084f8897a5054627acef3efd01c8b29793d522000160040850b766b126a2b4843fcdfdffa5d5cab3f53bc860a3bef68958b5f066170001cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2640000.json b/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2640000.json new file mode 100644 index 000000000..11379045f --- /dev/null +++ b/sdk-lib/src/main/assets/co.electriccoin.zcash/checkpoint/mainnet/2640000.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "2640000", + "hash": "00000000005573f750bf887ba84eb6cdbbf68e9725a26edd0953ad1f9c2afa6f", + "time": 1725849281, + "saplingTree": "0101f5ea6dca26458eb9f8ce9f6d81839079378f4c1b4bcf8f2db65d8aa1ab03390182c1d5f455fa608b46af734a062fe578df31a19523d5153fe23a9d60b594e6161f01aeff8a227febe1f1980d2907ef1fec5239b658b20f983cae19efcc19d4921b4f0001f33e7e5e89c1ca2a4b9afd1241d22ff1b8778139e853c342f6ea7c199c0ed87200000000013ec7b9e6943afb674ae185f7832a27236d0f37a2da4f755692beb77673390e4101a84a6e9d268ebdb72a8594b935a6a4dd72525a33e44d6196b0ac305985b8b12f017b5c740cafbc34fef26447b0484376c17b4a3bb81ef7520c5e457bdc23c529550000019ae0c6c0a25f41f55524fd9f4f91d7cab457594e88472b579451b3d24597a66b00018a015f44048c81b4d523a810de3ce0f216cde33ade70ad760501bd5e08d727000001b254e3cfba937bcd40621e1d623a943440b083892546f081aab37058fd1b763f000000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "019116b1be34f136c7d6fc385c03468ea65df33f0676018aa4435243f86f668d2b001f013bdde1e91eb92a9ad82ee2760f7890a9e14f4882e6fdbbec3f3296805ce6eb11011a2a14dedaac9456392bf0bcef8f8472be3f263bce6d39371a8650a7144e3f1f01fc4b1e304598998a96ca40f8950f337c102e56329d76551112689b7acc32341e000000000160ad94cc69f55ec843f854dcd51873fa4111cfe95f5b9fc4ac84295c0ab77d17010581b14b31f3cb4b6bc54b767e8fa2eff3ac4f7bfd6c83ab9786af014779bf3d01b54a634726cb3a0eab16d78437fd923bee6a3fc4eb537d4f68a2cefdab03cb18017456e5ac88cf348914dc885a5b520facd0e31985bcbee5490ed74bd2d777f50100012e924c7b6b2a77680118ee5fc70914b953fb33b7673ab2ddceb873c26632562901f427516d6600178ef99cc049193c413103980c407ea3489a687b3c3479d2300c00000112278dfeae9949f887b70ae81e084f8897a5054627acef3efd01c8b29793d522000160040850b766b126a2b4843fcdfdffa5d5cab3f53bc860a3bef68958b5f066170001cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt index a2806b0c2..dedad2d5a 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/SdkSynchronizer.kt @@ -13,8 +13,8 @@ import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Synced import cash.z.ecc.android.sdk.block.processor.CompactBlockProcessor.State.Syncing import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException import cash.z.ecc.android.sdk.exception.InitializeException +import cash.z.ecc.android.sdk.exception.LightWalletException import cash.z.ecc.android.sdk.exception.TransactionEncoderException -import cash.z.ecc.android.sdk.exception.TransactionSubmitException import cash.z.ecc.android.sdk.ext.ConsensusBranchId import cash.z.ecc.android.sdk.ext.ZcashSdk import cash.z.ecc.android.sdk.internal.FastestServerFetcher @@ -722,7 +722,7 @@ class SdkSynchronizer private constructor( "createProposedTransactions(proposeTransfer(usk.account, toAddress, amount, memo), usk)" ) ) - @Throws(TransactionEncoderException::class, TransactionSubmitException::class) + @Throws(TransactionEncoderException::class, LightWalletException.TransactionSubmitException::class) override suspend fun sendToAddress( usk: UnifiedSpendingKey, amount: Zatoshi, @@ -742,9 +742,8 @@ class SdkSynchronizer private constructor( is TransactionSubmitResult.Success -> { return storage.findMatchingTransactionId(encodedTx.txId.byteArray)!! } - else -> { - throw TransactionSubmitException() + throw LightWalletException.TransactionSubmitException() } } } @@ -756,7 +755,7 @@ class SdkSynchronizer private constructor( "proposeShielding(usk.account, shieldingThreshold, memo)?.let { createProposedTransactions(it, usk) }" ) ) - @Throws(TransactionEncoderException::class, TransactionSubmitException::class) + @Throws(TransactionEncoderException::class, LightWalletException.TransactionSubmitException::class) override suspend fun shieldFunds( usk: UnifiedSpendingKey, memo: String @@ -778,9 +777,8 @@ class SdkSynchronizer private constructor( is TransactionSubmitResult.Success -> { return storage.findMatchingTransactionId(encodedTx.txId.byteArray)!! } - else -> { - throw TransactionSubmitException() + throw LightWalletException.TransactionSubmitException() } } } @@ -1046,13 +1044,15 @@ internal object DefaultSynchronizerFactory { backend: TypesafeBackend, downloader: CompactBlockDownloader, repository: DerivedDataRepository, - birthdayHeight: BlockHeight + birthdayHeight: BlockHeight, + txManager: OutboundTransactionManager ): CompactBlockProcessor = CompactBlockProcessor( + backend = backend, downloader = downloader, + minimumHeight = birthdayHeight, repository = repository, - backend = backend, - minimumHeight = birthdayHeight + txManager = txManager, ) } diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt index d28345e4f..6dd41c0db 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/Synchronizer.kt @@ -686,9 +686,10 @@ interface Synchronizer { val processor = DefaultSynchronizerFactory.defaultProcessor( backend = backend, + birthdayHeight = birthday ?: zcashNetwork.saplingActivationHeight, downloader = downloader, repository = repository, - birthdayHeight = birthday ?: zcashNetwork.saplingActivationHeight + txManager = txManager, ) return SdkSynchronizer.new( diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt index 53efca057..a355df2c0 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/block/processor/CompactBlockProcessor.kt @@ -23,6 +23,7 @@ import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.Mismatche import cash.z.ecc.android.sdk.exception.CompactBlockProcessorException.MismatchedNetwork import cash.z.ecc.android.sdk.exception.InitializeException import cash.z.ecc.android.sdk.exception.LightWalletException +import cash.z.ecc.android.sdk.exception.TransactionEncoderException import cash.z.ecc.android.sdk.ext.ZcashSdk import cash.z.ecc.android.sdk.ext.ZcashSdk.MAX_BACKOFF_INTERVAL import cash.z.ecc.android.sdk.ext.ZcashSdk.POLL_INTERVAL @@ -52,10 +53,12 @@ import cash.z.ecc.android.sdk.internal.model.ext.from import cash.z.ecc.android.sdk.internal.model.ext.toBlockHeight import cash.z.ecc.android.sdk.internal.model.ext.toTransactionStatus import cash.z.ecc.android.sdk.internal.repository.DerivedDataRepository +import cash.z.ecc.android.sdk.internal.transaction.OutboundTransactionManager import cash.z.ecc.android.sdk.model.Account import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.PercentDecimal import cash.z.ecc.android.sdk.model.RawTransaction +import cash.z.ecc.android.sdk.model.TransactionSubmitResult import cash.z.ecc.android.sdk.model.WalletBalance import cash.z.ecc.android.sdk.model.Zatoshi import cash.z.ecc.android.sdk.model.ZcashNetwork @@ -85,6 +88,7 @@ import java.util.Locale import java.util.concurrent.atomic.AtomicInteger import kotlin.math.max import kotlin.math.min +import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes @@ -108,10 +112,11 @@ import kotlin.time.toDuration @OpenForTesting @Suppress("TooManyFunctions", "LargeClass") class CompactBlockProcessor internal constructor( + private val backend: TypesafeBackend, val downloader: CompactBlockDownloader, + minimumHeight: BlockHeight, private val repository: DerivedDataRepository, - private val backend: TypesafeBackend, - minimumHeight: BlockHeight + private val txManager: OutboundTransactionManager ) { /** * Callback for any non-trivial errors that occur while processing compact blocks. @@ -457,6 +462,10 @@ class CompactBlockProcessor internal constructor( network = network, lastValidHeight = lastValidHeight ) + + // Running the unsubmitted transactions check action at the beginning of every sync loop + resubmitUnminedTransactions(networkHeight.value) + when (preparationResult) { is SbSPreparationResult.ProcessFailure -> { return preparationResult.toBlockProcessingResult() @@ -493,6 +502,9 @@ class CompactBlockProcessor internal constructor( // Update sync progress and wallet balance refreshWalletSummary() + // Running the unsubmitted transactions check action at the end of processing every block batch + resubmitUnminedTransactions(networkHeight.value) + when (batchSyncProgress.resultState) { SyncingResult.UpdateBirthday -> { updateBirthdayHeight() @@ -599,6 +611,9 @@ class CompactBlockProcessor internal constructor( // Update sync progress and wallet balances refreshWalletSummary() + // Running the unsubmitted transactions check action at the end of processing every block batch + resubmitUnminedTransactions(networkHeight.value) + when (batchSyncProgress.resultState) { SyncingResult.UpdateBirthday -> { updateBirthdayHeight() @@ -1022,6 +1037,11 @@ class CompactBlockProcessor internal constructor( */ internal const val TRANSACTION_FETCH_RETRIES = 1 + /** + * Transaction resubmit retry attempts + */ + internal const val TRANSACTION_RESUBMIT_RETRIES = 1 + /** * UTXOs fetching default attempts at retrying. */ @@ -2199,16 +2219,18 @@ class CompactBlockProcessor internal constructor( * or repository is empty */ @VisibleForTesting - @Suppress("MaxLineLength") - internal suspend fun getFirstUnenhancedHeight(repository: DerivedDataRepository) = repository.firstUnenhancedHeight() + internal suspend fun getFirstUnenhancedHeight(repository: DerivedDataRepository): BlockHeight? { + return repository.firstUnenhancedHeight() + } /** * Get the height of the last block that was downloaded by this processor. * * @return the last downloaded height reported by the downloader. */ - @Suppress("MaxLineLength") - internal suspend fun getLastDownloadedHeight(downloader: CompactBlockDownloader) = downloader.getLastDownloadedHeight() + internal suspend fun getLastDownloadedHeight(downloader: CompactBlockDownloader): BlockHeight? { + return downloader.getLastDownloadedHeight() + } /** * Get the current unified address for the given wallet account. @@ -2437,9 +2459,9 @@ class CompactBlockProcessor internal constructor( } /** - * Poll on time boundaries. Per Issue #95, we want to avoid exposing computation time to a - * network observer. Instead, we poll at regular time intervals that are large enough for all - * computation to complete so no intervals are skipped. See 95 for more details. + * Poll on time boundaries. In order to avoid exposing computation time to a network observer this function uses + * randomized poll intervals that are large enough for all computation to complete so no intervals are skipped. + * See 95 for more details. * * @param fastIntervalDesired set if the short poll interval should be used * @@ -2452,8 +2474,11 @@ class CompactBlockProcessor internal constructor( } else { POLL_INTERVAL } + + @Suppress("MagicNumber") + val randomMultiplier = Random.nextDouble(0.75, 1.25) val now = System.currentTimeMillis() - val deltaToNextInterval = interval - (now + interval).rem(interval) + val deltaToNextInterval = (interval - (now + interval).rem(interval)) * randomMultiplier return deltaToNextInterval.toDuration(DurationUnit.MILLISECONDS) } @@ -2483,6 +2508,56 @@ class CompactBlockProcessor internal constructor( } ?: lowerBoundHeight } + /** + * This function resubmits the unmined sent transactions that are still within the expiry window. It can produce + * [TransactionEncoderException.TransactionNotFoundException] in case the transaction in not found in the database, + * but networking issues are not reported, it is retried in the next sync cycle instead. + * + * @param blockHeight The block height to which transactions should be compared (usually the current chain tip) + * + * @throws TransactionEncoderException.TransactionNotFoundException in case the encoded transaction is not found + */ + @Throws(TransactionEncoderException.TransactionNotFoundException::class) + private suspend fun resubmitUnminedTransactions(blockHeight: BlockHeight?) { + // Run the check only in case we have already obtained the current chain tip + if (blockHeight == null) { + return + } + val list = repository.findUnminedTransactionsWithinExpiry(blockHeight) + + Twig.debug { "Trx resubmission: ${list.size}, ${list.joinToString(separator = ", ") { it.txIdString() }}" } + + if (list.isNotEmpty()) { + list.forEach { + val trxForResubmission = + repository.findEncodedTransactionByTxId(it.rawId) + ?: throw TransactionEncoderException.TransactionNotFoundException(it.rawId) + + Twig.debug { "Trx resubmission: Found: ${trxForResubmission.txIdString()}" } + + retryUpToAndContinue(TRANSACTION_RESUBMIT_RETRIES) { + when (val response = txManager.submit(trxForResubmission)) { + is TransactionSubmitResult.Success -> { + Twig.info { "Trx resubmission success: ${response.txIdString()}" } + } + is TransactionSubmitResult.Failure -> { + Twig.error { "Trx resubmission failure: ${response.description}" } + throw LightWalletException.TransactionSubmitException( + response.code, + response.description, + ) + } + is TransactionSubmitResult.NotAttempted -> { + Twig.warn { "Trx resubmission not attempted: ${response.txIdString()}" } + } + } + } + } + } else { + Twig.debug { "Trx resubmission: No trx for resubmission found" } + } + } + suspend fun getUtxoCacheBalance(address: String): Zatoshi = backend.getDownloadedUtxoBalance(address) /** diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt index 3d3115df1..104b30bf3 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/exception/Exceptions.kt @@ -271,30 +271,38 @@ sealed class InitializeException(message: String, cause: Throwable? = null) : Sd * Exceptions thrown while interacting with lightwalletd. */ sealed class LightWalletException(message: String, cause: Throwable? = null) : SdkException(message, cause) { - class DownloadBlockException(code: Int, description: String?, cause: Throwable) : SdkException( - "Failed to download block with code: $code due to: ${description ?: "-"}", - cause + class DownloadBlockException(code: Int, description: String?, cause: Throwable) : LightWalletException( + message = "Failed to download block with code: $code due to: ${description ?: "-"}", + cause = cause ) - class GetSubtreeRootsException(code: Int, description: String?, cause: Throwable) : SdkException( - "Failed to get subtree roots with code: $code due to: ${description ?: "-"}", - cause + class GetSubtreeRootsException(code: Int, description: String?, cause: Throwable) : LightWalletException( + message = "Failed to get subtree roots with code: $code due to: ${description ?: "-"}", + cause = cause ) - class FetchUtxosException(code: Int, description: String?, cause: Throwable) : SdkException( - "Failed to fetch UTXOs with code: $code due to: ${description ?: "-"}", - cause + class FetchUtxosException(code: Int, description: String?, cause: Throwable) : LightWalletException( + message = "Failed to fetch UTXOs with code: $code due to: ${description ?: "-"}", + cause = cause ) - class GetLatestBlockHeightException(code: Int, description: String?, cause: Throwable) : SdkException( - "Failed to fetch latest block height with code: $code due to: ${description ?: "-"}", - cause + class GetLatestBlockHeightException(code: Int, description: String?, cause: Throwable) : LightWalletException( + message = "Failed to fetch latest block height with code: $code due to: ${description ?: "-"}", + cause = cause ) - class GetTAddressTransactionsException(code: Int, description: String?, cause: Throwable) : SdkException( - "Failed to get transactions belonging to the given transparent address with code: $code due" + - " to: ${description ?: "-"}", - cause + class GetTAddressTransactionsException(code: Int, description: String?, cause: Throwable) : LightWalletException( + message = + "Failed to get transactions belonging to the given transparent address with code: $code due" + + " to: ${description ?: "-"}", + cause = cause + ) + + class TransactionSubmitException(code: Int? = null, description: String? = null) : LightWalletException( + message = + "Failed to submit transaction to the lightwalletd server with code: ${code ?: "-"} due" + + " to: ${description ?: "-"}", + cause = null ) } @@ -340,5 +348,3 @@ sealed class TransactionEncoderException( " height was $lastScannedHeight." ) } - -class TransactionSubmitException : Exception() diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/FastestServerFetcher.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/FastestServerFetcher.kt index 7b05408e6..449b5e545 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/FastestServerFetcher.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/FastestServerFetcher.kt @@ -189,7 +189,7 @@ internal class FastestServerFetcher( return null } - if (remoteInfo.estimatedHeight >= remoteInfo.blockHeightUnsafe.value + N) { + if (remoteInfo.estimatedHeight >= remoteInfo.blockHeightUnsafe.value + SYNCED_THRESHOLD_BLOCKS) { logRuledOut("estimatedHeight does not match") return null } @@ -240,3 +240,5 @@ private val LATENCY_THRESHOLD = 300.milliseconds * Threshold for getBlockRange RPC call latency of latest [N] blocks. */ private val FETCH_THRESHOLD = 60.seconds + +private const val SYNCED_THRESHOLD_BLOCKS = 288 diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt index 79dbdb555..c1de388bc 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/AllTransactionView.kt @@ -25,6 +25,8 @@ internal class AllTransactionView( private const val QUERY_LIMIT = "1" // $NON-NLS + private const val SENT_TRANSACTION_RECOGNITION_VALUE = "0" // $NON-NLS + private val COLUMNS = arrayOf( // $NON-NLS @@ -58,6 +60,23 @@ internal class AllTransactionView( AllTransactionViewDefinition.COLUMN_INTEGER_MINED_HEIGHT ) + /** + * Get all sent, unmined transactions that are still within the expiry window + * + * Requested selection should look like this: + * mined_height IS NULL AND expiry_height > ? AND account_balance_delta < 0 + */ + private val SELECTION_TRX_RESUBMISSION = + String.format( + Locale.ROOT, + // $NON-NLS + "%s IS NULL AND %s > ? AND %s < %s", + AllTransactionViewDefinition.COLUMN_INTEGER_MINED_HEIGHT, + AllTransactionViewDefinition.COLUMN_INTEGER_EXPIRY_HEIGHT, + AllTransactionViewDefinition.COLUMN_LONG_ACCOUNT_BALANCE_DELTA, + SENT_TRANSACTION_RECOGNITION_VALUE + ) + private val SELECTION_RAW_IS_NULL = String.format( Locale.ROOT, @@ -142,6 +161,16 @@ internal class AllTransactionView( cursorParser = cursorParser ) + fun getUnminedUnexpiredTransactions(blockHeight: BlockHeight) = + sqliteDatabase.queryAndMap( + table = AllTransactionViewDefinition.VIEW_NAME, + columns = COLUMNS, + orderBy = ORDER_BY, + selection = SELECTION_TRX_RESUBMISSION, + selectionArgs = arrayOf(blockHeight.value), + cursorParser = cursorParser + ) + suspend fun getOldestTransaction() = sqliteDatabase.queryAndMap( table = AllTransactionViewDefinition.VIEW_NAME, diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt index de8152665..bfeb6d0a3 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/db/derived/DbDerivedDataRepository.kt @@ -26,6 +26,9 @@ internal class DbDerivedDataRepository( return derivedDataDb.transactionTable.findEncodedTransactionByTxId(txId) } + override suspend fun findUnminedTransactionsWithinExpiry(blockHeight: BlockHeight): List = + derivedDataDb.allTransactionView.getUnminedUnexpiredTransactions(blockHeight).toList() + override suspend fun getOldestTransaction() = derivedDataDb.allTransactionView.getOldestTransaction() override suspend fun findMinedHeight(rawTransactionId: ByteArray) = diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/EncodedTransaction.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/EncodedTransaction.kt index d5e082986..9c5dd5fd6 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/EncodedTransaction.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/model/EncodedTransaction.kt @@ -1,5 +1,6 @@ package cash.z.ecc.android.sdk.internal.model +import cash.z.ecc.android.sdk.internal.ext.toHexReversed import cash.z.ecc.android.sdk.model.BlockHeight import cash.z.ecc.android.sdk.model.FirstClassByteArray @@ -7,4 +8,10 @@ internal data class EncodedTransaction( val txId: FirstClassByteArray, val raw: FirstClassByteArray, val expiryHeight: BlockHeight? -) +) { + override fun toString() = "EncodedTransaction" + + fun txIdString(): String { + return txId.byteArray.toHexReversed() + } +} diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt index ec0496446..1c0660e29 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/repository/DerivedDataRepository.kt @@ -30,6 +30,18 @@ internal interface DerivedDataRepository { */ suspend fun findEncodedTransactionByTxId(txId: FirstClassByteArray): EncodedTransaction? + /** + * Find all unmined and unexpired transactions in the given height inclusively. This method + * is intended for getting a transaction list for the transaction re-submitting. It returns a list to signal that + * the intention is not to add them to a recyclerview or otherwise show in the UI. + * + * @param blockHeight the height of the block to which the query should be done + * + * @return a list of transactions that were unmined and are still in the expiry window till the give height + * inclusively. + */ + suspend fun findUnminedTransactionsWithinExpiry(blockHeight: BlockHeight): List + suspend fun getOldestTransaction(): DbTransactionOverview? /** diff --git a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt index 0566d428a..3b7893e4e 100644 --- a/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt +++ b/sdk-lib/src/main/java/cash/z/ecc/android/sdk/internal/transaction/TransactionEncoderImpl.kt @@ -42,6 +42,8 @@ internal class TransactionEncoderImpl( * @param memo the optional memo to include as part of the transaction. * * @return the successfully encoded transaction or an exception + * + * @throws TransactionEncoderException.TransactionNotFoundException in case the encoded transaction not found */ override suspend fun createTransaction( usk: UnifiedSpendingKey,