diff --git a/.github/scripts/run_balances_test.sh b/.github/scripts/run_balances_test.sh index a3d3e7ef..feeb7fc4 100644 --- a/.github/scripts/run_balances_test.sh +++ b/.github/scripts/run_balances_test.sh @@ -35,7 +35,7 @@ t.start() def run(): os.system('adb wait-for-device') - p = sp.Popen('adb shell am instrument -w -m -e debug false -e class "io.novafoundation.nova.balances.BalancesIntegrationTest" io.pezkuwichain.wallet.debug.test/io.qameta.allure.android.runners.AllureAndroidJUnitRunner', + p = sp.Popen('adb shell am instrument -w -m -e debug false -e package "io.novafoundation.nova.balances" io.pezkuwichain.wallet.debug.test/io.qameta.allure.android.runners.AllureAndroidJUnitRunner', shell=True, stdout=sp.PIPE, stderr=sp.PIPE, stdin=sp.PIPE) return p.communicate() success = re.compile(r'OK \(\d+ tests\)') diff --git a/.github/workflows/balances_test.yml b/.github/workflows/balances_test.yml index 6c74a8fa..9b5f57d4 100644 --- a/.github/workflows/balances_test.yml +++ b/.github/workflows/balances_test.yml @@ -1,6 +1,7 @@ name: Run balances tests on: + pull_request: workflow_dispatch: schedule: - cron: '0 */8 * * *' @@ -43,13 +44,50 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm + - name: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-29-nexus6-x86_64-v1 + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + disable-animations: false + profile: Nexus 6 + api-level: 29 + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: echo "Generated AVD snapshot for caching." + - name: Run tests + id: run-tests-attempt-1 + continue-on-error: true uses: reactivecircus/android-emulator-runner@v2 with: disable-animations: true profile: Nexus 6 api-level: 29 arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim + script: .github/scripts/run_balances_test.sh + + - name: Run tests (retry - reactivecircus/android-emulator-runner flakes on SDK download/emulator boot) + if: steps.run-tests-attempt-1.outcome == 'failure' + uses: reactivecircus/android-emulator-runner@v2 + with: + disable-animations: true + profile: Nexus 6 + api-level: 29 + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot-save -noaudio -no-boot-anim script: .github/scripts/run_balances_test.sh - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/install/action.yml b/.github/workflows/install/action.yml index 1d6c723d..d674561e 100644 --- a/.github/workflows/install/action.yml +++ b/.github/workflows/install/action.yml @@ -18,7 +18,27 @@ runs: - name: Install NDK run: | SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1) - echo "y" | sudo ${SDKMANAGER} --install "ndk;26.1.10909125" --sdk_root=${ANDROID_SDK_ROOT} + NDK_PACKAGE="ndk;26.1.10909125" + + # sdkmanager's download of the ~1GB NDK zip from Google's CDN occasionally comes back truncated/corrupted + # ("Error on ZipFile unknown archive") with no built-in retry, taking down the whole build for a purely + # transient network blip. Retry with cleanup between attempts: sdkmanager can otherwise resume from the + # same corrupted partial file instead of re-fetching, making a naive retry fail identically every time. + for attempt in 1 2 3; do + echo "NDK install attempt $attempt/3" + if echo "y" | sudo ${SDKMANAGER} --install "$NDK_PACKAGE" --sdk_root=${ANDROID_SDK_ROOT}; then + echo "NDK installed successfully" + exit 0 + fi + + echo "Attempt $attempt failed - clearing any partial/corrupted download before retrying" + sudo rm -rf "${ANDROID_SDK_ROOT}/ndk/26.1.10909125" + sudo find "${ANDROID_SDK_ROOT}" -maxdepth 1 -name "tmp*" -exec rm -rf {} + + sleep 10 + done + + echo "NDK install failed after 3 attempts" + exit 1 shell: bash - name: Set ndk.dir in local.properties diff --git a/app/build.gradle b/app/build.gradle index f0eaa8b3..fe5f4538 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -324,6 +324,7 @@ dependencies { androidTestImplementation androidTestRunnerDep androidTestImplementation androidTestRulesDep androidTestImplementation androidJunitDep + androidTestImplementation scalarsConverterDep androidTestImplementation allureKotlinModel androidTestImplementation allureKotlinCommons diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt new file mode 100644 index 00000000..9ffcb697 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/PezkuwiFullArchitectureBalancesTest.kt @@ -0,0 +1,154 @@ +package io.novafoundation.nova.balances + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.google.gson.Gson +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.fromJson +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.AssetDao +import io.novafoundation.nova.core_db.dao.ChainAssetDao +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.di.DbApi +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi +import io.novafoundation.nova.runtime.BuildConfig.TEST_ASSETS_URL +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.net.URL +import kotlin.time.Duration.Companion.seconds + +private data class AssetFixture(val chainId: String, val chainName: String, val assetId: Int, val symbol: String) +private data class AssetsFixtureFile(val account: String, val assets: List) + +/** + * Exercises the ACTUAL production balance-sync pipeline (BalancesUpdateSystem -> AssetCache/AssetDao) end to + * end, unlike [BalancesIntegrationTest] which bypasses it entirely via a direct low-level storage query. This + * is meant to answer one question with hard evidence, not speculation: for a real, well-funded mainnet Founder + * account, does the app's real, running background sync ever write an `assets` cache row for every asset in + * wallet-utils' pezkuwi_assets_for_testBalance.json (HEZ/PEZ/USDT/DOT/ETH/BTC across Pezkuwi's chains) - not + * just the native balance on one chain, which is all the older [BalancesIntegrationTest] fixture covers. + * + * A single watch-only account is created and selected once, so a single BalancesUpdateSystem run has to + * successfully sync every asset in the fixture - this is what actually caught the 2026-07-09 HEZ-on-Asset-Hub + * silent sync failure (5 of 6 assets on that chain synced fine; only HEZ silently never did). + * + * BalancesUpdateSystem.start() is a cold flow - in production it's only ever collected by RootInteractor, + * which is wired to the root Activity/ViewModel lifecycle. A bare instrumented test never launches that + * Activity, so we collect it ourselves here via the same AssetsFeatureApi.updateSystem instance the real app + * uses, instead of relying on app UI lifecycle to start it. + * + * If this test fails, the standard per-updater error logs already wired into BalancesUpdateSystem/ + * FullSyncPaymentUpdater/NativeAssetBalance/StatemineAssetBalance show exactly which decision branch or + * exception is responsible - not another layer of inference from silence. + */ +class PezkuwiFullArchitectureBalancesTest { + + // Standard BIP44 Ethereum derivation (m/44'/60'/0'/0/0) from the same already-verified Founder mnemonic + // used for the substrate address below - this is exactly what Nova/Pezkuwi Wallet's own unified account + // creation derives when a single seed produces both a substrate and an Ethereum account. No dedicated + // "founder EVM wallet" record exists anywhere else, so this is the correct, non-fabricated way to get a + // real EVM address to test USDT-on-Ethereum sync with - it doesn't need to hold any balance, since this + // test only asserts a row gets written (see below), not that the balance is non-zero. + private val founderEthereumAddress = "0x1aa2EA1292c62BdC6E49E0C12134263efc73713A" + private val ethereumChainId = "eip155:1" + + private val context = ApplicationProvider.getApplicationContext() + + private val dbApi = FeatureUtils.getFeature(context, DbApi::class.java) + private val metaAccountDao = dbApi.metaAccountDao() + private val assetDao: AssetDao = dbApi.provideAssetDao() + private val chainAssetDao: ChainAssetDao = dbApi.chainAssetDao() + + private val assetsFeatureApi = FeatureUtils.getFeature(context, AssetsFeatureApi::class.java) + + @Test + fun testPezkuwiEcosystemAssetsActuallySync() = runBlocking { + val fixture: AssetsFixtureFile = Gson().fromJson(URL(TEST_ASSETS_URL).readText()) + + val updateSystemJob = launch { assetsFeatureApi.updateSystem.start().collect {} } + + try { + val metaId = insertAndSelectWatchAccount(metaAccountDao, fixture.account, founderEthereumAddress) + + // Ethereum's USDT isn't in the JSON fixture because its assetId isn't a fixed, known integer like + // Substrate assets - EvmAssetsSyncService computes it as a hash of the ERC20 contract address at + // sync time (see chainAssetIdOfErc20Token()), so it has to be resolved dynamically here instead of + // hardcoded. This closes out the third of the three originally-reported symptoms (Tron disabled, + // Pezkuwi tokens missing, USDT on Polkadot AH/Ethereum missing) - the first two are already covered + // by the fixture-driven assets above. + val ethereumUsdtAssetId = withTimeoutOrNull(30.seconds) { + var assetId: Int? = null + while (assetId == null) { + assetId = chainAssetDao.getEnabledAssets() + .firstOrNull { it.chainId == ethereumChainId && it.symbol == "USDT" } + ?.id + if (assetId == null) delay(2.seconds) + } + assetId + } + assertTrue( + "USDT was never registered as an enabled asset on Ethereum (chainId=$ethereumChainId) within 30s - " + + "EvmAssetsSyncService may have failed to sync from EVM_ASSETS_URL.", + ethereumUsdtAssetId != null + ) + + val stillMissing = ( + fixture.assets + + AssetFixture(ethereumChainId, "Ethereum", ethereumUsdtAssetId!!, "USDT") + ).toMutableList() + + withTimeoutOrNull(120.seconds) { + while (stillMissing.isNotEmpty()) { + val found = stillMissing.filter { asset -> + assetDao.getAsset(metaId, asset.chainId, asset.assetId) != null + } + stillMissing.removeAll(found) + if (stillMissing.isNotEmpty()) delay(2.seconds) + } + } + + assertTrue( + "No `assets` row was ever written for: ${stillMissing.joinToString { "${it.symbol} on ${it.chainName}" }} " + + "(metaId=$metaId) within 120s, out of ${fixture.assets.size + 1} total. The real BalancesUpdateSystem " + + "pipeline never completed a sync for these - check the standard FullSyncPaymentUpdater/" + + "NativeAssetBalance/StatemineAssetBalance/EvmErc20AssetBalance error logs in logcat.", + stillMissing.isEmpty() + ) + } finally { + updateSystemJob.cancel() + } + } + + private suspend fun insertAndSelectWatchAccount(dao: MetaAccountDao, substrateAddress: String, ethereumAddress: String): Long { + val accountId = substrateAddress.toAccountId() + val evmAddress = ethereumAddress.removePrefix("0x").fromHex() + + val metaAccount = MetaAccountLocal( + substratePublicKey = accountId, + substrateCryptoType = CryptoType.SR25519, + substrateAccountId = accountId, + ethereumPublicKey = null, + ethereumAddress = evmAddress, + name = "PezkuwiFullArchitectureBalancesTest", + parentMetaId = null, + isSelected = false, + position = 0, + type = MetaAccountLocal.Type.WATCH_ONLY, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), + typeExtras = null + ) + + val metaId = dao.insertMetaAccount(metaAccount) + dao.selectMetaAccount(metaId) + + return metaId + } +} diff --git a/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt new file mode 100644 index 00000000..395a3065 --- /dev/null +++ b/app/src/androidTest/java/io/novafoundation/nova/balances/TronBalancesIntegrationTest.kt @@ -0,0 +1,97 @@ +package io.novafoundation.nova.balances + +import io.novafoundation.nova.common.utils.tronAddressToAccountId +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.math.BigInteger + +/** + * Tron is a REST API (TronGrid), not a Substrate runtime - it has no ChainConnection/RuntimeProvider and isn't + * reachable through [BalancesIntegrationTest]'s chainRegistry-based mechanism at all. This exercises the exact + * same [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi] the production app uses for + * balance reads (see TronNativeAssetBalance/Trc20AssetBalance), just via a standalone Retrofit client instead of + * the full DI graph, since TronGridApi isn't exposed through a public feature API for tests to reach. + * + * Test account is the mainnet Founder's Tron address, verified live via TronGrid's public API on 2026-07-09 to + * hold a substantial non-zero balance of both native TRX and TRC-20 USDT - not a guessed or empty account. + */ +class TronBalancesIntegrationTest { + + private val testAddress = "TDGZ4GfvCRe1d8oksj8fBD77ZHw4bkCPBA" + private val usdtContractAddress = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" + private val baseUrl = "https://api.trongrid.io" + + // A real wallet that received exactly 5 USDT-TRC20 (2026-07-11) and has NEVER had any native TRX/other + // on-chain activity - confirmed live to have no Account object at all (`/v1/accounts` returns `data: []`) + // despite genuinely holding the token (`balanceOf` correctly returns 5000000). Regression coverage for the + // exact bug this uncovered: fetchTrc20Balance used to read through `/v1/accounts` and silently returned 0 + // for any address in this state, well after it was live and had already deceived a real user mid-transfer. + private val unactivatedHolderAddress = "TUdvwdGeqcag51XkhgRK21KmhH2qw37LZG" + private val unactivatedHolderExpectedUsdtBalance = BigInteger.valueOf(5_000_000L) + + private val maxAmount = BigInteger.valueOf(10).pow(30) + + private val tronGridApi = run { + val retrofit = Retrofit.Builder() + .client(OkHttpClient.Builder().build()) + .baseUrl(baseUrl) + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + RealTronGridApi(retrofit.create(RetrofitTronGridApi::class.java)) + } + + // TronGrid's public (no API key) endpoint rate-limits aggressively, and this test now runs on every PR + // (see balances_test.yml) in addition to its own 2 calls back-to-back - a bare 429 previously failed the + // whole run for a transient, infrastructure-level reason unrelated to whether the wallet's code is correct. + // Retry with backoff instead of just tolerating the flake. + private suspend fun retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T { + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: HttpException) { + if (e.code() != 429) throw e + delay(2_000L * (attempt + 1)) + } + } + return block() + } + + @Test + fun testNativeTrxBalanceLoading() = runBlocking { + val freeBalance = retryOn429 { tronGridApi.fetchNativeBalance(baseUrl, testAddress) } + + assertTrue("TRX balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) + assertTrue("TRX balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) + } + + @Test + fun testTrc20UsdtBalanceLoading() = runBlocking { + val freeBalance = retryOn429 { tronGridApi.fetchTrc20Balance(baseUrl, testAddress.tronAddressToAccountId(), usdtContractAddress) } + + assertTrue("USDT-TRC20 balance: $freeBalance is less than $maxAmount", maxAmount > freeBalance) + assertTrue("USDT-TRC20 balance: $freeBalance is greater than 0", BigInteger.ZERO < freeBalance) + } + + @Test + fun testTrc20BalanceLoadingForNeverActivatedHolder() = runBlocking { + val freeBalance = retryOn429 { + tronGridApi.fetchTrc20Balance(baseUrl, unactivatedHolderAddress.tronAddressToAccountId(), usdtContractAddress) + } + + assertTrue( + "USDT-TRC20 balance for a never-activated holder: expected $unactivatedHolderExpectedUsdtBalance, got $freeBalance", + freeBalance == unactivatedHolderExpectedUsdtBalance + ) + } +} diff --git a/build.gradle b/build.gradle index e0319600..9e68957a 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { // App version - versionName = '1.1.1' + versionName = '1.1.2' versionCode = 1 applicationId = "io.pezkuwichain.wallet" diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt index c0dd818e..6bc801db 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/MetaAccountSecrets.kt @@ -28,6 +28,9 @@ object MetaAccountSecrets : Schema() { val TronKeypair by schema(KeyPairSchema).optional() val TronDerivationPath by string().optional() + + val BitcoinKeypair by schema(KeyPairSchema).optional() + val BitcoinDerivationPath by string().optional() } object ChainAccountSecrets : Schema() { @@ -47,6 +50,8 @@ fun MetaAccountSecrets( ethereumDerivationPath: String? = null, tronKeypair: Keypair? = null, tronDerivationPath: String? = null, + bitcoinKeypair: Keypair? = null, + bitcoinDerivationPath: String? = null, ): EncodableStruct = MetaAccountSecrets { secrets -> secrets[Entropy] = entropy secrets[SubstrateSeed] = substrateSeed @@ -75,6 +80,15 @@ fun MetaAccountSecrets( } } secrets[TronDerivationPath] = tronDerivationPath + + secrets[BitcoinKeypair] = bitcoinKeypair?.let { + KeyPairSchema { keypair -> + keypair[PublicKey] = it.publicKey + keypair[PrivateKey] = it.privateKey + keypair[Nonce] = null // bitcoin uses secp256k1 (like ethereum/tron), so nonce is always null + } + } + secrets[BitcoinDerivationPath] = bitcoinDerivationPath } fun ChainAccountSecrets( @@ -103,6 +117,9 @@ val EncodableStruct.ethereumDerivationPath val EncodableStruct.tronDerivationPath get() = get(MetaAccountSecrets.TronDerivationPath) +val EncodableStruct.bitcoinDerivationPath + get() = get(MetaAccountSecrets.BitcoinDerivationPath) + val EncodableStruct.entropy get() = get(MetaAccountSecrets.Entropy) @@ -118,6 +135,9 @@ val EncodableStruct.ethereumKeypair val EncodableStruct.tronKeypair get() = get(MetaAccountSecrets.TronKeypair) +val EncodableStruct.bitcoinKeypair + get() = get(MetaAccountSecrets.BitcoinKeypair) + val EncodableStruct.derivationPath get() = get(ChainAccountSecrets.DerivationPath) diff --git a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt index b62f364a..1b44c7d0 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/secrets/v2/SecretStoreV2.kt @@ -156,20 +156,28 @@ val AccountSecrets.isChainAccountSecrets suspend fun SecretStoreV2.getMetaAccountKeypair( metaId: Long, isEthereum: Boolean, + isTron: Boolean = false, + isBitcoin: Boolean = false, ): Keypair = withContext(Dispatchers.Default) { val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId) - mapMetaAccountSecretsToKeypair(secrets, isEthereum) + mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin) } fun mapMetaAccountSecretsToKeypair( secrets: EncodableStruct, ethereum: Boolean, + tron: Boolean = false, + bitcoin: Boolean = false, ): Keypair { - val keypairStruct = if (ethereum) { - secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() - } else { - secrets[MetaAccountSecrets.SubstrateKeypair] + // Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different + // SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get + // signed with the wrong (Ethereum) private key. + val keypairStruct = when { + tron -> secrets[MetaAccountSecrets.TronKeypair] ?: noTronSecret() + bitcoin -> secrets[MetaAccountSecrets.BitcoinKeypair] ?: noBitcoinSecret() + ethereum -> secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret() + else -> secrets[MetaAccountSecrets.SubstrateKeypair] } return mapKeypairStructToKeypair(keypairStruct) @@ -178,8 +186,11 @@ fun mapMetaAccountSecretsToKeypair( fun mapMetaAccountSecretsToDerivationPath( secrets: EncodableStruct, ethereum: Boolean, + bitcoin: Boolean = false, ): String? { - return if (ethereum) { + return if (bitcoin) { + secrets[MetaAccountSecrets.BitcoinDerivationPath] + } else if (ethereum) { secrets[MetaAccountSecrets.EthereumDerivationPath] } else { secrets[MetaAccountSecrets.SubstrateDerivationPath] @@ -198,6 +209,10 @@ private fun noChainSecrets(metaId: Long, accountId: ByteArray): Nothing { private fun noEthereumSecret(): Nothing = error("No ethereum keypair found") +private fun noBitcoinSecret(): Nothing = error("No bitcoin keypair found") + +private fun noTronSecret(): Nothing = error("No tron keypair found") + fun mapKeypairStructToKeypair(struct: EncodableStruct): Keypair { return Keypair( publicKey = struct[KeyPairSchema.PublicKey], diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/Bech32.kt b/common/src/main/java/io/novafoundation/nova/common/utils/Bech32.kt new file mode 100644 index 00000000..307a78f4 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/Bech32.kt @@ -0,0 +1,164 @@ +package io.novafoundation.nova.common.utils + +/** + * Bech32 (BIP173) / Bech32m (BIP350) codec, hand-implemented since no such library is currently on this + * project's classpath (same rationale as [Base58]/[Base58Check] for Tron - this is a deterministic text + * encoding, not a secret-dependent cryptographic primitive). + * + * Direct port of the reference algorithm in BIP173/BIP350's `segwit_addr.py`. Cross-checked against BIP173's + * and BIP350's official test vectors - see [Bech32Test]. + */ +object Bech32 { + + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private const val BECH32_CONST = 1L + private const val BECH32M_CONST = 0x2bc830a3L + + enum class Encoding(val const: Long) { + BECH32(BECH32_CONST), + BECH32M(BECH32M_CONST) + } + + data class Decoded(val hrp: String, val values: IntArray, val encoding: Encoding) + + private fun polymod(values: IntArray): Long { + val gen = longArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + var chk = 1L + for (v in values) { + val b = (chk ushr 25) + chk = (chk and 0x1ffffff) shl 5 xor v.toLong() + for (i in 0 until 5) { + if ((b ushr i) and 1L == 1L) { + chk = chk xor gen[i] + } + } + } + return chk + } + + private fun hrpExpand(hrp: String): IntArray { + val lower = hrp.map { (it.code ushr 5) } + val upper = hrp.map { (it.code and 31) } + return (lower + listOf(0) + upper).toIntArray() + } + + private fun createChecksum(hrp: String, data: IntArray, encoding: Encoding): IntArray { + val values = hrpExpand(hrp) + data + IntArray(6) + val mod = polymod(values) xor encoding.const + return IntArray(6) { i -> ((mod ushr (5 * (5 - i))) and 31).toInt() } + } + + fun encode(hrp: String, data: IntArray, encoding: Encoding): String { + val checksum = createChecksum(hrp, data, encoding) + val combined = data + checksum + return hrp + "1" + combined.map { CHARSET[it] }.joinToString("") + } + + fun decode(input: String): Decoded { + require(input.length in 8..90) { "Bech32 string has invalid length: ${input.length}" } + require(input == input.lowercase() || input == input.uppercase()) { "Bech32 string is mixed case: $input" } + + val lower = input.lowercase() + val separatorIndex = lower.lastIndexOf('1') + require(separatorIndex >= 1) { "Bech32 string is missing separator '1': $input" } + require(separatorIndex + 7 <= lower.length) { "Bech32 data part too short: $input" } + + val hrp = lower.substring(0, separatorIndex) + val dataPart = lower.substring(separatorIndex + 1) + + val values = IntArray(dataPart.length) + for ((i, c) in dataPart.withIndex()) { + val v = CHARSET.indexOf(c) + require(v >= 0) { "Invalid Bech32 character: '$c' in $input" } + values[i] = v + } + + val checksumValue = polymod(hrpExpand(hrp) + values) + val encoding = when (checksumValue) { + BECH32_CONST -> Encoding.BECH32 + BECH32M_CONST -> Encoding.BECH32M + else -> throw IllegalArgumentException("Invalid Bech32/Bech32m checksum: $input") + } + + return Decoded(hrp, values.copyOfRange(0, values.size - 6), encoding) + } + + /** + * Regroups bits between arbitrary group sizes (e.g. 8-bit bytes <-> 5-bit Bech32 words). Direct port of + * BIP173's `convertbits`. + */ + fun convertBits(data: IntArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray? { + var acc = 0 + var bits = 0 + val ret = mutableListOf() + val maxV = (1 shl toBits) - 1 + val maxAcc = (1 shl (fromBits + toBits - 1)) - 1 + + for (value in data) { + if (value < 0 || (value ushr fromBits) != 0) return null + + acc = ((acc shl fromBits) or value) and maxAcc + bits += fromBits + while (bits >= toBits) { + bits -= toBits + ret.add((acc ushr bits) and maxV) + } + } + + if (pad) { + if (bits > 0) ret.add((acc shl (toBits - bits)) and maxV) + } else if (bits >= fromBits || ((acc shl (toBits - bits)) and maxV) != 0) { + return null + } + + return ret.toIntArray() + } +} + +/** + * Segwit address encoding/decoding (BIP173 witness v0, BIP350 witness v1+/Bech32m) on top of the raw [Bech32] + * codec above. Only witness version 0 (P2WPKH/P2WSH) is actually used by this app today (native SegWit only - + * Taproot/witness v1 is explicitly out of scope for now), but decode handles both since a user could paste any + * valid segwit address. + */ +object SegwitAddress { + + fun encode(hrp: String, witnessVersion: Int, witnessProgram: ByteArray): String { + require(witnessVersion in 0..16) { "Invalid witness version: $witnessVersion" } + require(witnessProgram.size in 2..40) { "Invalid witness program length: ${witnessProgram.size}" } + + val programWords = Bech32.convertBits(witnessProgram.map { it.toInt() and 0xff }.toIntArray(), 8, 5, true) + ?: throw IllegalArgumentException("Failed to convert witness program to 5-bit words") + + val encoding = if (witnessVersion == 0) Bech32.Encoding.BECH32 else Bech32.Encoding.BECH32M + + return Bech32.encode(hrp, intArrayOf(witnessVersion) + programWords, encoding) + } + + data class Decoded(val witnessVersion: Int, val witnessProgram: ByteArray) + + fun decode(expectedHrp: String, address: String): Decoded { + val (hrp, values, encoding) = Bech32.decode(address) + require(hrp == expectedHrp) { "Unexpected HRP: expected $expectedHrp, got $hrp" } + require(values.isNotEmpty()) { "Empty Bech32 data part: $address" } + + val witnessVersion = values[0] + val expectedEncoding = if (witnessVersion == 0) Bech32.Encoding.BECH32 else Bech32.Encoding.BECH32M + require(encoding == expectedEncoding) { + "Witness version $witnessVersion requires ${expectedEncoding.name} but address used ${encoding.name}: $address" + } + + val programWords = values.copyOfRange(1, values.size) + val programBytes = Bech32.convertBits(programWords, 5, 8, false) + ?: throw IllegalArgumentException("Invalid witness program padding: $address") + + require(programBytes.size in 2..40) { "Invalid witness program length: $address" } + if (witnessVersion == 0) { + require(programBytes.size == 20 || programBytes.size == 32) { + "Witness v0 program must be 20 (P2WPKH) or 32 (P2WSH) bytes: $address" + } + } + + return Decoded(witnessVersion, ByteArray(programBytes.size) { programBytes[it].toByte() }) + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinAddress.kt new file mode 100644 index 00000000..a4855ec2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinAddress.kt @@ -0,0 +1,56 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.runtime.AccountId +import org.bouncycastle.jcajce.provider.digest.RIPEMD160 + +/** + * Native SegWit (P2WPKH) Bitcoin address support. Only witness v0 P2WPKH (`bc1q...`) is implemented - Taproot + * and legacy/P2SH-SegWit are explicitly out of scope for this phase (see the BTC integration plan). + * + * Bitcoin's "account id" here is the 20-byte HASH160 of the compressed secp256k1 public key - unlike + * Tron/Ethereum (which both derive their account id via keccak256 of the *uncompressed* pubkey), so this is + * NOT interchangeable with [tronPublicKeyToAccountId]/`asEthereumPublicKey().toAccountId()` despite all three + * using the same underlying secp256k1 keypair machinery. + */ +private const val BITCOIN_MAINNET_HRP = "bc" + +/** RIPEMD160(SHA256(x)) - the "HASH160" function used throughout Bitcoin for pubkey hashes and script hashes. */ +fun ByteArray.hash160(): ByteArray { + val ripemd160 = RIPEMD160.Digest() + return ripemd160.digest(this.sha256()) +} + +/** + * @param compressedPublicKey a 33-byte compressed secp256k1 public key (0x02/0x03 prefix + 32-byte x-coordinate). + */ +fun ByteArray.bitcoinPublicKeyToAccountId(): AccountId { + require(size == 33) { "Bitcoin native SegWit requires a compressed (33-byte) public key, got $size bytes" } + + return hash160() +} + +/** P2WPKH scriptPubKey: `OP_0 <20-byte-push> `, i.e. `0x00 0x14 <20 bytes>`. */ +fun AccountId.toP2wpkhScriptPubKey(): ByteArray { + require(size == 20) { "Bitcoin account id (HASH160) must be 20 bytes, got $size" } + + return byteArrayOf(0x00, 0x14) + this +} + +fun AccountId.toBitcoinAddress(): String { + require(size == 20) { "Bitcoin account id (HASH160) must be 20 bytes, got $size" } + + return SegwitAddress.encode(BITCOIN_MAINNET_HRP, witnessVersion = 0, witnessProgram = this) +} + +fun String.bitcoinAddressToAccountId(): AccountId { + val decoded = SegwitAddress.decode(BITCOIN_MAINNET_HRP, this) + require(decoded.witnessVersion == 0 && decoded.witnessProgram.size == 20) { + "Not a native SegWit P2WPKH address: $this" + } + + return decoded.witnessProgram +} + +fun String.isValidBitcoinAddress(): Boolean = runCatching { bitcoinAddressToAccountId() }.isSuccess + +fun emptyBitcoinAccountId() = ByteArray(20) { 1 } diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinDestinationAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinDestinationAddress.kt new file mode 100644 index 00000000..3f43cc11 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinDestinationAddress.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.common.utils + +private const val P2PKH_VERSION = 0x00 +private const val P2SH_VERSION = 0x05 + +/** + * Any Bitcoin address this wallet can SEND to - a strict superset of what it can derive/receive as its own + * address (native SegWit only, see `BitcoinAddress.kt`). Needed because a real exchange withdrawal address was + * confirmed live to be P2SH (`3...`), which this app's original native-SegWit-only `isValidBitcoinAddress()` + * rejected outright, blocking the send with a misleading "QR can't be decoded" error (the QR was fine - a bare + * P2SH address - the address TYPE just wasn't recognized as a valid destination at all). + * + * Deliberately kept separate from [bitcoinAddressToAccountId]/[AccountId] - that function's 20-byte output feeds + * generic multi-chain code (`Chain.accountIdOf`) that assumes every account id is THIS wallet's own P2WPKH + * shape. Silently reusing it for a P2SH/P2PKH recipient would produce a 20-byte hash with no type tag, and + * downstream code would wrap it in the wrong (P2WPKH) scriptPubKey - sending funds to an address that doesn't + * match what was actually asked for. [BitcoinDestination] carries its type through to [toScriptPubKey] instead. + */ +sealed class BitcoinDestination { + + data class NativeSegwit(val witnessProgram: ByteArray) : BitcoinDestination() + + data class P2sh(val scriptHash: ByteArray) : BitcoinDestination() + + data class P2pkh(val pubKeyHash: ByteArray) : BitcoinDestination() +} + +fun String.decodeBitcoinDestination(): BitcoinDestination { + runCatching { + val decoded = SegwitAddress.decode("bc", this) + if (decoded.witnessVersion == 0 && decoded.witnessProgram.size == 20) { + return BitcoinDestination.NativeSegwit(decoded.witnessProgram) + } + } + + // Base58Check itself is chain-agnostic (see TronAddress.kt) - a Bitcoin legacy address is 1 version byte + + // 20-byte hash, same shape as Tron's own address, just a different version byte and no fixed prefix meaning. + val decoded = Base58Check.decode(this) + require(decoded.size == 21) { "Not a valid Bitcoin legacy address: $this" } + + val version = decoded[0].toInt() and 0xff + val hash = decoded.copyOfRange(1, decoded.size) + + return when (version) { + P2PKH_VERSION -> BitcoinDestination.P2pkh(hash) + P2SH_VERSION -> BitcoinDestination.P2sh(hash) + else -> error("Unsupported Bitcoin address version byte: $version") + } +} + +fun String.isValidBitcoinDestinationAddress(): Boolean = runCatching { decodeBitcoinDestination() }.isSuccess + +/** + * The raw 20-byte hash underlying any destination type, with its type tag dropped - safe ONLY for consumers + * that treat it as an opaque identicon/display seed (e.g. `Chain.accountIdOf` and, downstream, address icon + * generation) and never feed it back into [toScriptPubKey] or re-encode it as an address. Real transaction + * construction must keep using [decodeBitcoinDestination]/[toScriptPubKey] directly, which keep the type. + */ +val BitcoinDestination.hash: ByteArray + get() = when (this) { + is BitcoinDestination.NativeSegwit -> witnessProgram + is BitcoinDestination.P2sh -> scriptHash + is BitcoinDestination.P2pkh -> pubKeyHash + } + +/** + * @return the scriptPubKey a transaction output must use to actually pay this destination - P2SH/P2PKH have + * different script shapes from this wallet's own P2WPKH (see [toP2wpkhScriptPubKey]), despite all three being a + * "20-byte hash wrapped in a short script." + */ +fun BitcoinDestination.toScriptPubKey(): ByteArray = when (this) { + is BitcoinDestination.NativeSegwit -> byteArrayOf(0x00, 0x14) + witnessProgram + + // OP_HASH160 <20-byte-push> OP_EQUAL + is BitcoinDestination.P2sh -> byteArrayOf(0xa9.toByte(), 0x14) + scriptHash + byteArrayOf(0x87.toByte()) + + // OP_DUP OP_HASH160 <20-byte-push> OP_EQUALVERIFY OP_CHECKSIG + is BitcoinDestination.P2pkh -> byteArrayOf(0x76, 0xa9.toByte(), 0x14) + pubKeyHash + byteArrayOf(0x88.toByte(), 0xac.toByte()) +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinTransaction.kt b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinTransaction.kt new file mode 100644 index 00000000..96c97d72 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/BitcoinTransaction.kt @@ -0,0 +1,170 @@ +package io.novafoundation.nova.common.utils + +import java.io.ByteArrayOutputStream + +/** SHA256(SHA256(x)) - Bitcoin's standard "double SHA256", used for both txids and the BIP143 sighash. */ +fun ByteArray.sha256d(): ByteArray = sha256().sha256() + +/** Bitcoin's variable-length integer ("CompactSize") encoding, used throughout raw transaction serialization. */ +fun Long.toBitcoinVarInt(): ByteArray { + require(this >= 0) { "VarInt cannot encode a negative value: $this" } + val out = ByteArrayOutputStream() + when { + this < 0xfd -> out.write(toInt()) + this <= 0xffff -> { + out.write(0xfd) + out.write(toInt() and 0xff) + out.write((toInt() ushr 8) and 0xff) + } + this <= 0xffffffffL -> { + out.write(0xfe) + for (i in 0..3) out.write(((this ushr (8 * i)) and 0xff).toInt()) + } + else -> { + out.write(0xff) + for (i in 0..7) out.write(((this ushr (8 * i)) and 0xff).toInt()) + } + } + return out.toByteArray() +} + +private fun Int.toLeBytes(byteCount: Int): ByteArray = ByteArray(byteCount) { i -> ((this ushr (8 * i)) and 0xff).toByte() } + +private fun Long.toLeBytes(byteCount: Int): ByteArray = ByteArray(byteCount) { i -> ((this ushr (8 * i)) and 0xff).toByte() } + +/** + * A single UTXO being spent, in the form needed to build and sign a transaction. + * + * @param txid the previous transaction's id in standard (RPC/explorer-display) byte order - this class reverses + * it internally to the on-wire/internal order raw transactions actually use (see [reversedTxid]). + */ +data class BitcoinInput( + val txid: ByteArray, + val vout: Int, + val valueSat: Long, + val sequence: Long = 0xfffffffdL, // RBF-signaling (BIP125), matching the exchange's proven, already-live choice +) { + init { + require(txid.size == 32) { "txid must be 32 bytes, got ${txid.size}" } + } + + fun reversedTxid(): ByteArray = txid.reversedArray() +} + +data class BitcoinOutput( + val valueSat: Long, + val scriptPubKey: ByteArray, +) + +/** + * Builds and signs native SegWit (P2WPKH-only) Bitcoin transactions using BIP143 sighashes - hand-implemented + * since no Bitcoin transaction library (bitcoinj/PSBT/etc.) is on this project's classpath (same rationale as + * [Bech32]/[DerSignature]). Verified byte-for-byte against BIP143's official "Native P2WPKH" worked example, + * including the fully serialized signed transaction - see [BitcoinTransactionTest]. + */ +object BitcoinTransaction { + + private const val SIGHASH_ALL = 1 + + /** P2PKH-shaped "scriptCode" BIP143 requires for a P2WPKH input - see BIP143's "Specification" section. */ + private fun p2wpkhScriptCode(accountId: ByteArray): ByteArray { + require(accountId.size == 20) + val script = byteArrayOf(0x76.toByte(), 0xa9.toByte(), 0x14) + accountId + byteArrayOf(0x88.toByte(), 0xac.toByte()) + return 25L.toBitcoinVarInt() + script + } + + private fun serializeOutpoint(input: BitcoinInput): ByteArray = input.reversedTxid() + input.vout.toLeBytes(4) + + private fun hashPrevouts(inputs: List): ByteArray = + inputs.fold(ByteArray(0)) { acc, input -> acc + serializeOutpoint(input) }.sha256d() + + private fun hashSequence(inputs: List): ByteArray = + inputs.fold(ByteArray(0)) { acc, input -> acc + input.sequence.toLeBytes(4) }.sha256d() + + private fun serializeOutput(output: BitcoinOutput): ByteArray = + output.valueSat.toLeBytes(8) + output.scriptPubKey.size.toLong().toBitcoinVarInt() + output.scriptPubKey + + private fun hashOutputs(outputs: List): ByteArray = + outputs.fold(ByteArray(0)) { acc, output -> acc + serializeOutput(output) }.sha256d() + + /** + * BIP143 sighash preimage + double-SHA256 for signing [inputIndex], which must be a P2WPKH input whose + * pubkey hashes to [signingAccountId]. Always uses SIGHASH_ALL, no ANYONECANPAY/NONE/SINGLE - this app never + * constructs those. + */ + fun bip143Sighash( + version: Int, + inputs: List, + outputs: List, + inputIndex: Int, + signingAccountId: ByteArray, + locktime: Int, + ): ByteArray { + val input = inputs[inputIndex] + + val preimage = version.toLeBytes(4) + + hashPrevouts(inputs) + + hashSequence(inputs) + + serializeOutpoint(input) + + p2wpkhScriptCode(signingAccountId) + + input.valueSat.toLeBytes(8) + + input.sequence.toLeBytes(4) + + hashOutputs(outputs) + + locktime.toLeBytes(4) + + SIGHASH_ALL.toLeBytes(4) + + return preimage.sha256d() + } + + /** + * @param witnesses one (derSignatureWithoutSighashByte, compressedPublicKey) pair per input, in input order - + * every input in this app's transactions is a P2WPKH input from this wallet's own single address, so every + * witness has exactly 2 items (signature, pubkey), never a bare key-path/script-path Taproot witness or a + * multisig-style stack. + */ + fun serializeSigned( + version: Int, + inputs: List, + outputs: List, + witnesses: List>, + locktime: Int, + ): ByteArray { + require(witnesses.size == inputs.size) { "Need exactly one witness per input" } + + val out = ByteArrayOutputStream() + out.write(version.toLeBytes(4)) + out.write(0x00) // segwit marker + out.write(0x01) // segwit flag + out.write(inputs.size.toLong().toBitcoinVarInt()) + for (input in inputs) { + out.write(input.reversedTxid()) + out.write(input.vout.toLeBytes(4)) + out.write(0L.toBitcoinVarInt()) // scriptSig: empty for a native SegWit input + out.write(input.sequence.toLeBytes(4)) + } + out.write(outputs.size.toLong().toBitcoinVarInt()) + for (output in outputs) { + out.write(serializeOutput(output)) + } + for ((derSignature, publicKey) in witnesses) { + out.write(2L.toBitcoinVarInt()) // 2 witness items: signature, pubkey + val sigWithHashType = derSignature + byteArrayOf(SIGHASH_ALL.toByte()) + out.write(sigWithHashType.size.toLong().toBitcoinVarInt()) + out.write(sigWithHashType) + out.write(publicKey.size.toLong().toBitcoinVarInt()) + out.write(publicKey) + } + out.write(locktime.toLeBytes(4)) + + return out.toByteArray() + } + + /** + * Estimated virtual size in vbytes for fee purposes - the same hardcoded heuristic already proven in + * production by `pezkuwi-exchange/wallet-service` (`inputs*68 + outputs*31 + 11`), reused here rather than + * computing an exact post-signing weight (which would require knowing final DER signature lengths ahead of + * time - low-S-normalized DER signatures are 70-72 bytes almost always, making this heuristic accurate to + * within a few vbytes in practice). + */ + fun estimateVsize(inputCount: Int, outputCount: Int): Long = inputCount * 68L + outputCount * 31L + 11L +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/DerSignature.kt b/common/src/main/java/io/novafoundation/nova/common/utils/DerSignature.kt new file mode 100644 index 00000000..643ae5d2 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/utils/DerSignature.kt @@ -0,0 +1,62 @@ +package io.novafoundation.nova.common.utils + +import java.math.BigInteger + +/** + * DER-encodes a raw secp256k1 ECDSA (r, s) signature the way Bitcoin's script/witness format requires it - + * unlike Tron/Ethereum, which both use a fixed-size compact r(32)+s(32)+v(1) format (see + * [io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.RealTronTransactionService]'s + * doc-comment for that format), Bitcoin signatures are a variable-length ASN.1 DER `SEQUENCE(INTEGER r, INTEGER + * s)`. + * + * `r`/`s` are taken as raw big-endian unsigned 32-byte values - exactly what + * [io.novasama.substrate_sdk_android]'s `SignatureWrapper.Ecdsa` (reached via `SignedRaw.toEcdsaSignatureData()`) + * already exposes for Ethereum-style signing, which this app already uses. No new signing call path is needed + * for Bitcoin: only this pure, standalone encoding step is new. + */ +object DerSignature { + + // secp256k1 curve order n, and n/2 - Bitcoin Core's standardness rules (BIP62) reject a signature whose `s` + // is greater than n/2 ("high-S"); wallets are expected to always produce the "low-S" of the two equally + // valid (r, s) and (r, n-s) signatures for a given message, or relay nodes/miners may refuse the transaction. + private val CURVE_ORDER = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + private val HALF_CURVE_ORDER = CURVE_ORDER.shiftRight(1) + + /** + * @param r raw big-endian unsigned 32-byte value + * @param s raw big-endian unsigned 32-byte value (will be normalized to low-S if not already) + * @return DER-encoded `SEQUENCE(INTEGER r, INTEGER s)`, WITHOUT the trailing sighash-type byte (the caller + * appends that when assembling the witness/scriptSig, since it is not part of the DER signature itself). + */ + fun encode(r: ByteArray, s: ByteArray): ByteArray { + val rInt = BigInteger(1, r) + var sInt = BigInteger(1, s) + + if (sInt > HALF_CURVE_ORDER) { + sInt = CURVE_ORDER.subtract(sInt) + } + + val rEncoded = encodeInteger(rInt) + val sEncoded = encodeInteger(sInt) + + val sequenceBody = rEncoded + sEncoded + + return byteArrayOf(0x30, sequenceBody.size.toDerLength()) + sequenceBody + } + + /** + * ASN.1 DER INTEGER: tag(0x02) + length + minimal big-endian two's-complement bytes. [BigInteger.toByteArray] + * already produces minimal big-endian two's-complement (including the leading 0x00 disambiguation byte when + * the high bit of the first byte would otherwise be set, which would make it read as negative) - since + * `rInt`/`sInt` are always non-negative here, its output is exactly the DER INTEGER content we need. + */ + private fun encodeInteger(value: BigInteger): ByteArray { + val bytes = value.toByteArray() + return byteArrayOf(0x02, bytes.size.toDerLength()) + bytes + } + + private fun Int.toDerLength(): Byte { + require(this in 0..127) { "DER length $this requires long-form encoding, not expected for a 32-byte ECDSA signature" } + return toByte() + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt index 3de6e08e..5a299f6a 100644 --- a/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt +++ b/common/src/main/java/io/novafoundation/nova/common/utils/TronAddress.kt @@ -2,6 +2,7 @@ package io.novafoundation.nova.common.utils import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey import io.novasama.substrate_sdk_android.extensions.toAccountId +import io.novasama.substrate_sdk_android.extensions.toHexString import io.novasama.substrate_sdk_android.runtime.AccountId import java.math.BigInteger @@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId { fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess fun emptyTronAccountId() = ByteArray(20) { 1 } + +/** + * Hex form of a Tron address (`0x41` prefix byte ++ accountId, hex-encoded, no `0x` prefix), e.g. + * `41a614f803b6fd780986a42c78ec9c7f77e6ded13c`. This is the format TronGrid's `/wallet/` transaction + * construction/broadcast endpoints expect when called with `"visible": false` (as opposed to the human-facing + * Base58Check form used by the `/v1/accounts/{address}` balance endpoint and by [toTronAddress]). + */ +fun AccountId.toTronHexAddress(): String { + require(size == 20) { "Tron account id must be 20 bytes, got $size" } + + return byteArrayOf(TRON_ADDRESS_PREFIX_BYTE).toHexString(withPrefix = false) + toHexString(withPrefix = false) +} + +/** + * Converts a human-facing Base58Check Tron address (e.g. a TRC20 `contractAddress` from chain config) directly + * into the hex form described in [toTronHexAddress]. + */ +fun String.tronAddressToHexAddress(): String = tronAddressToAccountId().toTronHexAddress() diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index d9baff5e..f7a6c6c2 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -355,6 +355,8 @@ DOT ↔ HEZ Bridge + HEZ ⇄ DOT + USDT ⇄ USDT.p You send You receive (estimated) Exchange rate diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/Bech32Test.kt b/common/src/test/java/io/novafoundation/nova/common/utils/Bech32Test.kt new file mode 100644 index 00000000..a5b46eb2 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/Bech32Test.kt @@ -0,0 +1,179 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * All test vectors below are quoted verbatim from the official BIPs (fetched directly from + * https://github.com/bitcoin/bips at implementation time), not invented for this test: + * - BIP173 (https://github.com/bitcoin/bips/blob/master/bip-0173.mediawiki) for Bech32 checksum vectors. + * - BIP350 (https://github.com/bitcoin/bips/blob/master/bip-0350.mediawiki) for Bech32m checksum vectors + * and the current (BIP350-superseding-BIP173) segwit address <-> scriptPubKey vectors - BIP173's own + * witness-v1+ vectors used plain Bech32 (since Bech32m didn't exist yet) and are now considered INVALID; + * only BIP173's witness-v0 vectors still apply unchanged under BIP350. + */ +class Bech32Test { + + @Test + fun `valid Bech32 checksums should decode without throwing`() { + val validBech32 = listOf( + "A12UEL5L", + "a12uel5l", + "an83characterlonghumanreadablepartthatcontainsthenumber1andtheexcludedcharactersbio1tt5tgs", + "abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw", + "11qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqc8247j", + "split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w", + "?1ezyfcl" + ) + + for (address in validBech32) { + val decoded = Bech32.decode(address) + assertEquals("$address should decode as Bech32 (not Bech32m)", Bech32.Encoding.BECH32, decoded.encoding) + } + } + + @Test + fun `valid Bech32m checksums should decode without throwing`() { + val validBech32m = listOf( + "A1LQFN3A", + "a1lqfn3a", + "an83characterlonghumanreadablepartthatcontainsthetheexcludedcharactersbioandnumber11sg7hg6", + "abcdef1l7aum6echk45nj3s0wdvt2fg8x9yrzpqzd3ryx", + "11llllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllludsr8", + "split1checkupstagehandshakeupstreamerranterredcaperredlc445v", + "?1v759aa" + ) + + for (address in validBech32m) { + val decoded = Bech32.decode(address) + assertEquals("$address should decode as Bech32m (not Bech32)", Bech32.Encoding.BECH32M, decoded.encoding) + } + } + + @Test + fun `mixed case Bech32 string should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + Bech32.decode("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sL5k7") + } + } + + // --- Segwit address <-> scriptPubKey (BIP350's updated table) --- + + private fun expectedWitnessVersionAndProgram(scriptPubKeyHex: String): Pair { + val script = scriptPubKeyHex.fromHex() + val versionByte = script[0].toInt() and 0xff + val witnessVersion = if (versionByte == 0) 0 else versionByte - 0x50 + val programLength = script[1].toInt() and 0xff + val program = script.copyOfRange(2, 2 + programLength) + return witnessVersion to program + } + + @Test + fun `known mainnet P2WPKH address should decode to the documented scriptPubKey`() { + val address = "BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4" + val (expectedVersion, expectedProgram) = expectedWitnessVersionAndProgram("0014751e76e8199196d454941c45d1b3a323f1433bd6") + + val decoded = SegwitAddress.decode("bc", address) + + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `known testnet P2WSH address should decode to the documented scriptPubKey`() { + val address = "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7" + val (expectedVersion, expectedProgram) = + expectedWitnessVersionAndProgram("00201863143c14c5166804bd19203356da136c985678cd4d27a1b8c6329604903262") + + val decoded = SegwitAddress.decode("tb", address) + + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `known testnet P2WPKH address (all-zero-ish program) should decode correctly`() { + val address = "tb1qqqqqp399et2xygdj5xreqhjjvcmzhxw4aywxecjdzew6hylgvsesrxh6hy" + val (expectedVersion, expectedProgram) = + expectedWitnessVersionAndProgram("0020000000c4a5cad46221b2a187905e5266362b99d5e91c6ce24d165dab93e86433") + + val decoded = SegwitAddress.decode("tb", address) + + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `known witness v1 taproot-style address (Bech32m) should decode correctly`() { + val address = "bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y" + val (expectedVersion, expectedProgram) = expectedWitnessVersionAndProgram( + "5128751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45d1b3a323f1433bd6" + ) + + val decoded = SegwitAddress.decode("bc", address) + + assertEquals(1, expectedVersion) + assertEquals(expectedVersion, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(expectedProgram)) + } + + @Test + fun `P2WPKH encode should reproduce the exact known mainnet address (lowercase)`() { + val (_, program) = expectedWitnessVersionAndProgram("0014751e76e8199196d454941c45d1b3a323f1433bd6") + + val encoded = SegwitAddress.encode("bc", witnessVersion = 0, witnessProgram = program) + + assertEquals("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4".lowercase(), encoded) + } + + @Test + fun `encode-decode should round trip for a fresh 20-byte P2WPKH program`() { + val program = "0011223344556677889900112233445566778899".fromHex() + assertEquals(20, program.size) + + val address = SegwitAddress.encode("bc", witnessVersion = 0, witnessProgram = program) + val decoded = SegwitAddress.decode("bc", address) + + assertEquals(0, decoded.witnessVersion) + assertTrue(decoded.witnessProgram.contentEquals(program)) + } + + @Test + fun `invalid checksum should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5") + } + } + + @Test + fun `wrong hrp should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7") + } + } + + @Test + fun `invalid program length should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "bc1rw5uspcuh") + } + } + + @Test + fun `witness v0 with wrong program length per BIP141 should be rejected`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "BC1QR508D6QEJXTDG4Y5R3ZARVARYV98GJ9P") + } + } + + @Test + fun `witness v0 encoded with Bech32m instead of Bech32 should be rejected (BIP350)`() { + assertThrows(IllegalArgumentException::class.java) { + SegwitAddress.decode("bc", "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh") + } + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinAddressTest.kt new file mode 100644 index 00000000..ce52a97a --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinAddressTest.kt @@ -0,0 +1,98 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [knownAccountId]/[knownAddress] is BIP173/BIP350's official segwit address test vector + * (BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4 <-> scriptPubKey 0014751e76e8199196d454941c45d1b3a323f1433bd6, + * fetched directly from https://github.com/bitcoin/bips at implementation time) - an independently-verifiable, + * real-world test vector, not invented for this test. [knownPublicKey] is BIP143's official Native P2WPKH + * example pubkey, whose HASH160 is independently confirmed (via Bech32AddressTest and BitcoinTransactionTest) + * to equal a *different* known account id - used here only to test [hash160]/[bitcoinPublicKeyToAccountId] in + * isolation from address encoding. + */ +class BitcoinAddressTest { + + private val knownAccountId = "751e76e8199196d454941c45d1b3a323f1433bd6".fromHex() + private val knownAddress = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + + private val knownPublicKey = "025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357".fromHex() + private val knownPublicKeyAccountId = "1d0f172a0ecb48aee1be1f2687d2963ae33f71a1".fromHex() + + @Test + fun `accountId to address should produce the known BIP173 address`() { + assertEquals(knownAddress, knownAccountId.toBitcoinAddress()) + } + + @Test + fun `address to accountId should decode the known BIP173 address back to the known bytes`() { + assertTrue(knownAddress.bitcoinAddressToAccountId().contentEquals(knownAccountId)) + } + + @Test + fun `accountId to address and back should round trip`() { + val decodedBack = knownAccountId.toBitcoinAddress().bitcoinAddressToAccountId() + assertTrue(decodedBack.contentEquals(knownAccountId)) + } + + @Test + fun `compressed public key to accountId should match the known BIP143 hash160`() { + assertTrue(knownPublicKey.bitcoinPublicKeyToAccountId().contentEquals(knownPublicKeyAccountId)) + } + + @Test + fun `uncompressed (65-byte) public key should be rejected`() { + val uncompressed = ByteArray(65) + try { + uncompressed.bitcoinPublicKeyToAccountId() + org.junit.Assert.fail("Expected an IllegalArgumentException for a non-33-byte public key") + } catch (e: IllegalArgumentException) { + // expected + } + } + + @Test + fun `toP2wpkhScriptCode should produce OP_0 push-20 the account id`() { + val scriptPubKey = knownAccountId.toP2wpkhScriptPubKey() + + assertEquals("0014751e76e8199196d454941c45d1b3a323f1433bd6", scriptPubKey.toHexString(withPrefix = false)) + } + + @Test + fun `isValidBitcoinAddress should accept the known good address`() { + assertTrue(knownAddress.isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a corrupted checksum`() { + val corrupted = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5" + assertFalse(corrupted.isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a testnet address`() { + assertFalse("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7".isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a Tron address`() { + assertFalse("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".isValidBitcoinAddress()) + } + + @Test + fun `isValidBitcoinAddress should reject a P2WSH (32-byte program) address as not P2WPKH`() { + assertFalse("bc1pw508d6qejxtdg4y5r3zarvary0c5xw7kw508d6qejxtdg4y5r3zarvary0c5xw7kt5nd6y".isValidBitcoinAddress()) + } + + @Test + fun `hash160 known test vector should match independently-verified value`() { + // hash160("hello") independently cross-checked via Python's hashlib (ripemd160(sha256(b"hello"))) at + // implementation time - a different library from this project's BouncyCastle, not just self-consistency. + assertEquals("b6a9c8c230722b7c748331a8b450f05566dc7d0f", "hello".toByteArray().hash160().toHexString(withPrefix = false)) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinDestinationAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinDestinationAddressTest.kt new file mode 100644 index 00000000..f7f683cd --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinDestinationAddressTest.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Test vectors: + * - [genesisP2pkhAddress] is Satoshi's genesis coinbase address - real, independently-verifiable, its hash160 + * cross-checked via Python's hashlib/base58 at implementation time. + * - [okxWithdrawalAddress] is a real address confirmed live: an OKX BTC withdrawal QR, whose bare (non-BIP21) + * content this app's original native-SegWit-only address validation rejected outright, producing a misleading + * "QR can't be decoded" error - the actual bug this file's code fixes. + */ +class BitcoinDestinationAddressTest { + + private val genesisP2pkhAddress = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa" + private val genesisP2pkhHash160 = "62e907b15cbf27d5425399ebf6f0fb50ebb88f18" + + private val piVanityP2shAddress = "3P14159f73E4gFr7JterCCQh9QjiTjiZrG" + + private val okxWithdrawalAddress = "3QBsCZAv5hsZSrDpTcQYEqd82TdA8Qr3g9" + private val okxWithdrawalHash160 = "f6c78c3a049bfa34ed05b22ca9515414cdcb46d1" + + private val nativeSegwitAddress = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4" + private val nativeSegwitAccountId = "751e76e8199196d454941c45d1b3a323f1433bd6" + + @Test + fun `should decode a known P2PKH address to the correct hash160`() { + val destination = genesisP2pkhAddress.decodeBitcoinDestination() + + assertTrue(destination is BitcoinDestination.P2pkh) + assertEquals(genesisP2pkhHash160, (destination as BitcoinDestination.P2pkh).pubKeyHash.toHexString(withPrefix = false)) + } + + @Test + fun `should decode a known P2SH address to a P2sh destination`() { + val destination = piVanityP2shAddress.decodeBitcoinDestination() + + assertTrue(destination is BitcoinDestination.P2sh) + } + + @Test + fun `should decode the real OKX withdrawal address to the correct P2SH hash160`() { + val destination = okxWithdrawalAddress.decodeBitcoinDestination() + + assertTrue(destination is BitcoinDestination.P2sh) + assertEquals(okxWithdrawalHash160, (destination as BitcoinDestination.P2sh).scriptHash.toHexString(withPrefix = false)) + } + + @Test + fun `should still decode a native segwit address as NativeSegwit`() { + val destination = nativeSegwitAddress.decodeBitcoinDestination() + + assertTrue(destination is BitcoinDestination.NativeSegwit) + assertEquals(nativeSegwitAccountId, (destination as BitcoinDestination.NativeSegwit).witnessProgram.toHexString(withPrefix = false)) + } + + @Test + fun `isValidBitcoinDestinationAddress should accept P2PKH, P2SH and native segwit`() { + assertTrue(genesisP2pkhAddress.isValidBitcoinDestinationAddress()) + assertTrue(piVanityP2shAddress.isValidBitcoinDestinationAddress()) + assertTrue(okxWithdrawalAddress.isValidBitcoinDestinationAddress()) + assertTrue(nativeSegwitAddress.isValidBitcoinDestinationAddress()) + } + + @Test + fun `isValidBitcoinDestinationAddress should reject a corrupted checksum`() { + assertFalse("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNb".isValidBitcoinDestinationAddress()) + } + + @Test + fun `isValidBitcoinDestinationAddress should reject a Tron address`() { + assertFalse("TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t".isValidBitcoinDestinationAddress()) + } + + @Test + fun `P2SH destination should produce OP_HASH160 push-20 OP_EQUAL scriptPubKey`() { + val destination = okxWithdrawalAddress.decodeBitcoinDestination() + + assertEquals("a914${okxWithdrawalHash160}87", destination.toScriptPubKey().toHexString(withPrefix = false)) + } + + @Test + fun `P2PKH destination should produce OP_DUP OP_HASH160 push-20 OP_EQUALVERIFY OP_CHECKSIG scriptPubKey`() { + val destination = genesisP2pkhAddress.decodeBitcoinDestination() + + assertEquals("76a914${genesisP2pkhHash160}88ac", destination.toScriptPubKey().toHexString(withPrefix = false)) + } + + @Test + fun `NativeSegwit destination should produce OP_0 push-20 scriptPubKey`() { + val destination = nativeSegwitAddress.decodeBitcoinDestination() + + assertEquals("0014$nativeSegwitAccountId", destination.toScriptPubKey().toHexString(withPrefix = false)) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinTransactionTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinTransactionTest.kt new file mode 100644 index 00000000..2ff58486 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/BitcoinTransactionTest.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Verified byte-for-byte against BIP143's official "Native P2WPKH" worked example + * (https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki, fetched directly at implementation time) - + * every intermediate value below (hashPrevouts/hashSequence/hashOutputs/preimage/sighash/signed tx) is quoted + * verbatim from that document, not invented for this test. + * + * The two inputs' txids are given here in the conventional *display* order (reversed from on-wire order) - the + * same order a REST API like mempool.space returns - to exercise [BitcoinInput.reversedTxid]'s reversal for + * real, rather than pre-reversing them and bypassing that logic. + */ +class BitcoinTransactionTest { + + // BIP143 doc's wire-order txids, reversed here once (by hand, offline) to get the display-order string a + // real API would hand back - see this test class's doc comment. + private val input0Txid = "9f96ade4b41d5433f4eda31e1738ec2b36f6e7d1420d94a6af99801a88f7f7ff".fromHex() + private val input1Txid = "8ac60eb9575db5b2d987e29f301b5b819ea83a5c6579d282d189cc04b8e151ef".fromHex() + + // sequence is the *value* that gets LE-encoded, NOT the wire bytes - the doc shows input 0's on-wire + // sequence bytes as "eeffffff", which as a little-endian integer is 0xffffffee, not 0xeeffffff (a + // transcription of this exact off-by-reversal was caught by a failing CI run before this fix). + private val input0 = BitcoinInput(txid = input0Txid, vout = 0, valueSat = 625_000_000L, sequence = 0xffffffeeL) + private val input1 = BitcoinInput(txid = input1Txid, vout = 1, valueSat = 600_000_000L, sequence = 0xffffffffL) + + private val output0 = BitcoinOutput( + valueSat = 112_340_000L, + scriptPubKey = "76a9148280b37df378db99f66f85c95a783a76ac7a6d5988ac".fromHex() + ) + private val output1 = BitcoinOutput( + valueSat = 223_450_000L, + scriptPubKey = "76a9143bde42dbee7e4dbe6a21b2d50ce2f0167faa815988ac".fromHex() + ) + + private val signingAccountId = "1d0f172a0ecb48aee1be1f2687d2963ae33f71a1".fromHex() + private val publicKey = "025476c2e83188368da1ff3e292e7acafcdb3566bb0ad253f62fc70f07aeee6357".fromHex() + + @Test + fun `hash160 of the known public key should match the known account id`() { + assertEquals(signingAccountId.toHexString(withPrefix = false), publicKey.hash160().toHexString(withPrefix = false)) + } + + @Test + fun `bip143Sighash should match the known sighash for signing input 1`() { + val sighash = BitcoinTransaction.bip143Sighash( + version = 1, + inputs = listOf(input0, input1), + outputs = listOf(output0, output1), + inputIndex = 1, + signingAccountId = signingAccountId, + locktime = 0x11, + ) + + assertEquals("c37af31116d1b27caf68aae9e3ac82f1477929014d5b917657d0eb49478cb670", sighash.toHexString(withPrefix = false)) + } + + @Test + fun `serializeSigned should produce the exact expected bytes for a single-input all-P2WPKH transaction`() { + // Hand-verified (not from a BIP143 vector, since BIP143's own worked example mixes a legacy P2PK input + // with the P2WPKH one - this app only ever builds all-P2WPKH transactions since it only ever spends + // from its own single P2WPKH address). Expected hex was independently computed byte-by-byte from this + // function's own documented format (version/marker/flag/varints/witness) rather than copied from here. + val txidWire = "0a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9".fromHex() + val txidDisplay = txidWire.reversedArray() // what a mempool.space-style API would actually return + + val input = BitcoinInput(txid = txidDisplay, vout = 3, valueSat = 100_000L, sequence = 0xfffffffdL) + val output = BitcoinOutput(valueSat = 90_000L, scriptPubKey = "0014".fromHex() + "2222222222222222222222222222222222222222".fromHex()) + val derSignature = "3006020101020101".fromHex() + val dummyPubKey = "0246e14bb0d93c0d64c265dd6b0eeeba6b9bd94aa88ce74aa302cf1cb8fdff9b6a".fromHex() + + val signed = BitcoinTransaction.serializeSigned( + version = 2, + inputs = listOf(input), + outputs = listOf(output), + witnesses = listOf(derSignature to dummyPubKey), + locktime = 0, + ) + + val expected = "020000000001010a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90300000000fdffffff01905f01000000000016001422222222222222222222222222222222222222220209300602010102010101210246e14bb0d93c0d64c265dd6b0eeeba6b9bd94aa88ce74aa302cf1cb8fdff9b6a00000000" + assertEquals(expected, signed.toHexString(withPrefix = false)) + } + + @Test + fun `estimateVsize should match the exchange's proven heuristic formula`() { + assertEquals(1 * 68L + 2 * 31L + 11L, BitcoinTransaction.estimateVsize(inputCount = 1, outputCount = 2)) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/DerSignatureTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/DerSignatureTest.kt new file mode 100644 index 00000000..d502f3e0 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/utils/DerSignatureTest.kt @@ -0,0 +1,72 @@ +package io.novafoundation.nova.common.utils + +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +class DerSignatureTest { + + private val curveOrder = BigInteger("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) + + private fun ByteArray.pad32() = ByteArray(32 - size) + this + + @Test + fun `small r and high-bit s should each get a leading zero byte per DER minimal-integer rule`() { + // r = 1 (0x01, high bit clear -> no padding needed), s = 128 (0x80, high bit set -> needs 0x00 prefix + // so it isn't misread as a negative two's-complement integer). Manually verified expected DER bytes. + val r = BigInteger.valueOf(1).toByteArray().pad32() + val s = BigInteger.valueOf(128).toByteArray().pad32() // 128 < half-curve-order, so no low-S flip happens + + val der = DerSignature.encode(r, s) + + assertEquals("30070201010202" + "0080", der.toHexString(withPrefix = false)) + } + + @Test + fun `high-S signature should be normalized to low-S per BIP62`() { + val r = BigInteger.valueOf(42).toByteArray().pad32() + val highS = curveOrder.subtract(BigInteger.ONE) // curveOrder - 1: definitely > halfCurveOrder + val s = highS.toByteArray().let { if (it.size > 32) it.copyOfRange(it.size - 32, it.size) else it }.pad32() + + val der = DerSignature.encode(r, s) + + // Expect the DER-encoded s to equal curveOrder - highS == 1, not the original high-S value. + val expectedNormalizedS = curveOrder.subtract(highS) + assertEquals(BigInteger.ONE, expectedNormalizedS) + + // Extract the s component back out of the DER bytes to check it against the expected normalized value. + val rLen = der[3].toInt() + val sTagIndex = 4 + rLen + val sLen = der[sTagIndex + 1].toInt() + val sBytes = der.copyOfRange(sTagIndex + 2, sTagIndex + 2 + sLen) + assertEquals(expectedNormalizedS, BigInteger(1, sBytes)) + } + + @Test + fun `already-low-S signature should be left unchanged`() { + val r = BigInteger.valueOf(7).toByteArray().pad32() + val lowS = BigInteger.valueOf(12345) + val s = lowS.toByteArray().pad32() + + val der = DerSignature.encode(r, s) + + val rLen = der[3].toInt() + val sTagIndex = 4 + rLen + val sLen = der[sTagIndex + 1].toInt() + val sBytes = der.copyOfRange(sTagIndex + 2, sTagIndex + 2 + sLen) + assertEquals(lowS, BigInteger(1, sBytes)) + } + + @Test + fun `DER output should start with SEQUENCE tag and correct overall length`() { + val r = "0011223344556677889900112233445566778899aabbccddeeff0011223344".fromHex() + val s = "1122334455667788990011223344556677889900112233445566778899aabb".fromHex() + + val der = DerSignature.encode(r, s) + + assertEquals(0x30.toByte(), der[0]) + assertEquals(der.size - 2, der[1].toInt()) + } +} diff --git a/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt b/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt index d036c57f..2bec4a3c 100644 --- a/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt +++ b/common/src/test/java/io/novafoundation/nova/common/utils/TronAddressTest.kt @@ -44,6 +44,18 @@ class TronAddressTest { assertTrue(decodedBack.contentEquals(accountId)) } + @Test + fun `toTronHexAddress should produce the known hex form`() { + val accountId = knownTronAddressHex.fromHex().copyOfRange(1, 21) + + assertEquals(knownTronAddressHex, accountId.toTronHexAddress()) + } + + @Test + fun `tronAddressToHexAddress should produce the known hex form directly from a Base58 address`() { + assertEquals(knownTronAddressHex, knownTronAddress.tronAddressToHexAddress()) + } + @Test fun `isValidTronAddress should accept known good address`() { assertTrue(knownTronAddress.isValidTronAddress()) diff --git a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt index 5ea76180..1cd5f359 100644 --- a/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt +++ b/core-db/src/androidTest/java/io/novafoundation/nova/core_db/dao/Helpers.kt @@ -58,6 +58,7 @@ fun chainOf( isTestNet = false, isEthereumBased = false, isTronBased = false, + isBitcoinBased = false, hasCrowdloans = false, additional = "", governance = "governance", diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt index 014f6b02..d14c9542 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/AppDatabase.kt @@ -94,6 +94,7 @@ import io.novafoundation.nova.core_db.migrations.AddStakingTypeToTotalRewards_44 import io.novafoundation.nova.core_db.migrations.AddSwapOption_48_49 import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51 import io.novafoundation.nova.core_db.migrations.AddTransferApisTable_29_30 +import io.novafoundation.nova.core_db.migrations.AddBitcoinSupport_74_75 import io.novafoundation.nova.core_db.migrations.AddTronSupport_73_74 import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69 import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33 @@ -167,7 +168,7 @@ import io.novafoundation.nova.core_db.model.operation.SwapTypeLocal import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal @Database( - version = 74, + version = 75, entities = [ AccountLocal::class, NodeLocal::class, @@ -273,6 +274,7 @@ abstract class AppDatabase : RoomDatabase() { .addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71) .addMigrations(AddGifts_71_72, AddFieldsToContributions) .addMigrations(AddTronSupport_73_74) + .addMigrations(AddBitcoinSupport_74_75) .build() } return instance!! diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt index e72205a4..99017fb5 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/dao/ChainDao.kt @@ -164,7 +164,12 @@ abstract class ChainDao { // ------- Queries ------ - @Query("SELECT * FROM chains") + // ORDER BY rowid: without an explicit order, SQLite gives no guarantee about row order for `SELECT *`. + // The merged chains.json lists Pezkuwi's own chains first (see wallet-utils' merge_chains()), and that + // order is what gets inserted first on initial sync - rowid tracks insertion order, so ordering by it + // means Pezkuwi's chains reliably register/connect first instead of being interleaved unpredictably + // among the ~90+ Nova-inherited chains, where they could otherwise end up processed last. + @Query("SELECT * FROM chains ORDER BY rowid") @Transaction abstract suspend fun getJoinChainInfo(): List @@ -172,7 +177,7 @@ abstract class ChainDao { @Transaction abstract suspend fun getAllChainIds(): List - @Query("SELECT * FROM chains") + @Query("SELECT * FROM chains ORDER BY rowid") @Transaction abstract fun joinChainInfoFlow(): Flow> diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/74_75_AddBitcoinSupport.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/74_75_AddBitcoinSupport.kt new file mode 100644 index 00000000..24fd633c --- /dev/null +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/migrations/74_75_AddBitcoinSupport.kt @@ -0,0 +1,14 @@ +package io.novafoundation.nova.core_db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val AddBitcoinSupport_74_75 = object : Migration(74, 75) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE chains ADD COLUMN isBitcoinBased INTEGER NOT NULL DEFAULT 0") + + db.execSQL("ALTER TABLE meta_accounts ADD COLUMN bitcoinPublicKey BLOB") + db.execSQL("ALTER TABLE meta_accounts ADD COLUMN bitcoinAddress BLOB") + } +} diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt index 77216596..faf32f39 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/ChainLocal.kt @@ -23,6 +23,8 @@ data class ChainLocal( val isEthereumBased: Boolean, @ColumnInfo(defaultValue = "0") val isTronBased: Boolean, + @ColumnInfo(defaultValue = "0") + val isBitcoinBased: Boolean, val isTestNet: Boolean, @ColumnInfo(defaultValue = "1") val hasSubstrateRuntime: Boolean, diff --git a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt index 4886b762..ed001602 100644 --- a/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt +++ b/core-db/src/main/java/io/novafoundation/nova/core_db/model/chain/account/MetaAccountLocal.kt @@ -39,6 +39,8 @@ class MetaAccountLocal( val typeExtras: SerializedJson?, val tronPublicKey: ByteArray? = null, val tronAddress: ByteArray? = null, + val bitcoinPublicKey: ByteArray? = null, + val bitcoinAddress: ByteArray? = null, ) { enum class Status { @@ -59,6 +61,9 @@ class MetaAccountLocal( const val TRON_PUBKEY = "tronPublicKey" const val TRON_ADDRESS = "tronAddress" + const val BITCOIN_PUBKEY = "bitcoinPublicKey" + const val BITCOIN_ADDRESS = "bitcoinAddress" + const val NAME = "name" const val IS_SELECTED = "isSelected" const val POSITION = "position" @@ -74,6 +79,62 @@ class MetaAccountLocal( fun addEvmAccount( ethereumPublicKey: ByteArray, ethereumAddress: ByteArray, + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumPublicKey = ethereumPublicKey, + ethereumAddress = ethereumAddress, + name = name, + parentMetaId = parentMetaId, + isSelected = isSelected, + position = position, + type = type, + status = status, + globallyUniqueId = globallyUniqueId, + typeExtras = typeExtras, + tronPublicKey = tronPublicKey, + tronAddress = tronAddress, + bitcoinPublicKey = bitcoinPublicKey, + bitcoinAddress = bitcoinAddress, + ).also { + it.id = id + } + } + + // We do not use copy as we need explicitly set id + fun addBitcoinAccount( + bitcoinPublicKey: ByteArray, + bitcoinAddress: ByteArray, + ): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = substratePublicKey, + substrateCryptoType = substrateCryptoType, + substrateAccountId = substrateAccountId, + ethereumPublicKey = ethereumPublicKey, + ethereumAddress = ethereumAddress, + name = name, + parentMetaId = parentMetaId, + isSelected = isSelected, + position = position, + type = type, + status = status, + globallyUniqueId = globallyUniqueId, + typeExtras = typeExtras, + tronPublicKey = tronPublicKey, + tronAddress = tronAddress, + bitcoinPublicKey = bitcoinPublicKey, + bitcoinAddress = bitcoinAddress, + ).also { + it.id = id + } + } + + // We do not use copy as we need explicitly set id + fun addTronAccount( + tronPublicKey: ByteArray, + tronAddress: ByteArray, ): MetaAccountLocal { return MetaAccountLocal( substratePublicKey = substratePublicKey, @@ -109,6 +170,8 @@ class MetaAccountLocal( if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false if (!tronPublicKey.contentEquals(other.tronPublicKey)) return false if (!tronAddress.contentEquals(other.tronAddress)) return false + if (!bitcoinPublicKey.contentEquals(other.bitcoinPublicKey)) return false + if (!bitcoinAddress.contentEquals(other.bitcoinAddress)) return false if (name != other.name) return false if (parentMetaId != other.parentMetaId) return false if (isSelected != other.isSelected) return false @@ -130,6 +193,8 @@ class MetaAccountLocal( result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0) result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0) result = 31 * result + (tronAddress?.contentHashCode() ?: 0) + result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0) result = 31 * result + name.hashCode() result = 31 * result + (parentMetaId?.hashCode() ?: 0) result = 31 * result + isSelected.hashCode() diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt index d683e7fa..b6be9f54 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/model/Fee.kt @@ -63,6 +63,31 @@ class SubstrateFee( override val asset: Chain.Asset ) : Fee +/** + * Fee for a Bitcoin transaction, denominated in sats: `feeRate (sat/vB) * estimated vsize` - see + * `RealBitcoinTransactionService` for how both are derived. The network only ever collects what miners include + * in a block, which is exactly this amount (unlike account-model chains, a Bitcoin fee is not a cap/estimate + * that gets partially refunded - it is the literal difference between input and output values). + */ +class BitcoinFee( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee + +/** + * Fee for a Tron transaction (native TRX or TRC-20), always denominated in TRX (sun), regardless of which asset + * is being sent - Tron has no separate "gas token" concept, network resources (bandwidth/energy) are always + * burned as TRX. [amount] is this client's own estimate of that burn (see `RealTronTransactionService`); the + * network only ever burns what it actually uses, so the real cost can be lower, but never higher than what this + * client authorized via `fee_limit` when submitting. + */ +class TronFee( + override val amount: BigInteger, + override val submissionOrigin: SubmissionOrigin, + override val asset: Chain.Asset +) : Fee + class SubstrateFeeBase( override val amount: BigInteger, override val asset: Chain.Asset diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt index 5f172b4d..c132e8b0 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/secrets/SecretStoreExt.kt @@ -25,14 +25,14 @@ suspend fun SecretStoreV2.getAccountSecrets( fun AccountSecrets.keypair(chain: Chain): Keypair { return fold( - left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased) }, + left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) }, right = { mapChainAccountSecretsToKeypair(it) } ) } fun AccountSecrets.derivationPath(chain: Chain): String? { return fold( - left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased) }, + left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) }, right = { it[ChainAccountSecrets.DerivationPath] } ) } diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt index 1741cb26..b328a252 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/domain/model/MetaAccount.kt @@ -52,6 +52,10 @@ interface LightMetaAccount { */ val tronAddress: ByteArray? val tronPublicKey: ByteArray? + + /** Bitcoin account id (HASH160 of the compressed pubkey - see [io.novafoundation.nova.common.utils.hash160]). */ + val bitcoinAddress: ByteArray? + val bitcoinPublicKey: ByteArray? val isSelected: Boolean val name: String val type: Type @@ -90,6 +94,8 @@ fun LightMetaAccount( parentMetaId: Long?, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) = object : LightMetaAccount { override val id: Long = id override val globallyUniqueId: String = globallyUniqueId @@ -100,6 +106,8 @@ fun LightMetaAccount( override val ethereumPublicKey: ByteArray? = ethereumPublicKey override val tronAddress: ByteArray? = tronAddress override val tronPublicKey: ByteArray? = tronPublicKey + override val bitcoinAddress: ByteArray? = bitcoinAddress + override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey override val isSelected: Boolean = isSelected override val name: String = name override val type: LightMetaAccount.Type = type diff --git a/feature-account-impl/build.gradle b/feature-account-impl/build.gradle index 1283cf5e..75de034a 100644 --- a/feature-account-impl/build.gradle +++ b/feature-account-impl/build.gradle @@ -13,6 +13,16 @@ android { buildFeatures { viewBinding true } + + testOptions { + unitTests { + // android.util.Log.* throws "Method ... not mocked" by default in plain JVM unit tests (no real + // Android framework, no Robolectric) - TronAddressBackfillMigrationTest is the first test in this + // module to exercise code that calls Log.d/Log.e. This makes those stubbed calls return harmless + // defaults instead of throwing, which is what the framework's own stubs are meant for in this context. + returnDefaultValues = true + } + } } dependencies { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt index 3717e617..2e066d22 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacade.kt @@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.derivationPath +import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair import io.novafoundation.nova.common.data.secrets.v2.entropy import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair @@ -17,6 +19,8 @@ import io.novafoundation.nova.common.data.secrets.v2.publicKey import io.novafoundation.nova.common.data.secrets.v2.seed import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.common.utils.filterNotNull import io.novafoundation.nova.common.utils.findById import io.novafoundation.nova.common.utils.mapToSet @@ -90,6 +94,8 @@ class RealLocalAccountsCloudBackupFacade( substrate = baseSecrets.getSubstrateBackupSecrets(), ethereum = baseSecrets.getEthereumBackupSecrets(), chainAccounts = emptyList(), + bitcoin = baseSecrets.getBitcoinBackupSecrets(), + tron = baseSecrets.getTronBackupSecrets(), ) return CloudBackup( @@ -357,6 +363,8 @@ class RealLocalAccountsCloudBackupFacade( substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo), ethereum = baseSecrets.getEthereumBackupSecrets(), chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets, + bitcoin = baseSecrets.getBitcoinBackupSecrets(), + tron = baseSecrets.getTronBackupSecrets(), ) } @@ -466,7 +474,11 @@ class RealLocalAccountsCloudBackupFacade( substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null, substrateDerivationPath = substrate?.derivationPath, ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(), - ethereumDerivationPath = ethereum?.derivationPath + ethereumDerivationPath = ethereum?.derivationPath, + bitcoinKeypair = bitcoin?.keypair?.toLocalKeyPair(), + bitcoinDerivationPath = bitcoin?.derivationPath, + tronKeypair = tron?.keypair?.toLocalKeyPair(), + tronDerivationPath = tron?.derivationPath ) } @@ -479,6 +491,24 @@ class RealLocalAccountsCloudBackupFacade( ) } + private fun EncodableStruct?.getBitcoinBackupSecrets(): CloudBackup.WalletPrivateInfo.BitcoinSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.BitcoinSecrets( + keypair = bitcoinKeypair?.toBackupKeypairSecrets() ?: return null, + derivationPath = bitcoinDerivationPath + ) + } + + private fun EncodableStruct?.getTronBackupSecrets(): CloudBackup.WalletPrivateInfo.TronSecrets? { + if (this == null) return null + + return CloudBackup.WalletPrivateInfo.TronSecrets( + keypair = tronKeypair?.toBackupKeypairSecrets() ?: return null, + derivationPath = tronDerivationPath + ) + } + private fun EncodableStruct?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? { if (this == null) return null @@ -520,7 +550,11 @@ class RealLocalAccountsCloudBackupFacade( ethereumPublicKey = metaAccount.ethereumPublicKey, name = metaAccount.name, type = metaAccount.type.toBackupWalletType() ?: return null, - chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) } + chainAccounts = chainAccounts.mapToSet { chainAccount -> chainAccount.toBackupPublicChainAccount(chainsById) }, + bitcoinAddress = metaAccount.bitcoinAddress, + bitcoinPublicKey = metaAccount.bitcoinPublicKey, + tronAddress = metaAccount.tronAddress, + tronPublicKey = metaAccount.tronPublicKey, ) } @@ -542,7 +576,11 @@ class RealLocalAccountsCloudBackupFacade( isSelected = isSelected, position = accountPosition, status = MetaAccountLocal.Status.ACTIVE, - typeExtras = null + typeExtras = null, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, + tronAddress = tronAddress, + tronPublicKey = tronPublicKey, ).also { if (localIdOverwrite != null) { it.id = localIdOverwrite diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt index ae8d3b8b..fdff1bcd 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/mappers/AccountMappers.kt @@ -65,6 +65,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, isSelected = isSelected, name = name, status = mapMetaAccountStateFromLocal(status), @@ -82,6 +84,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, isSelected = isSelected, name = name, type = type, @@ -101,6 +105,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, isSelected = isSelected, name = name, type = type, @@ -119,6 +125,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, isSelected = isSelected, name = name, type = type, @@ -138,6 +146,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, isSelected = isSelected, name = name, type = type, @@ -165,6 +175,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, isSelected = isSelected, name = name, status = mapMetaAccountStateFromLocal(status), @@ -183,6 +195,8 @@ class AccountMappers( ethereumPublicKey = ethereumPublicKey, tronAddress = tronAddress, tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey, chainAccounts = chainAccounts, isSelected = isSelected, name = name, diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt index b4021b68..6d997afd 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/AccountDataSourceImpl.kt @@ -30,6 +30,8 @@ import io.novafoundation.nova.feature_account_impl.data.mappers.AccountMappers import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountTypeToLocal import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.ChainAccountInsertionData import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData import io.novafoundation.nova.runtime.ext.accountIdOf @@ -64,15 +66,40 @@ class AccountDataSourceImpl( private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretStoreV1: SecretStoreV1, accountDataMigration: AccountDataMigration, + tronAddressBackfillMigration: TronAddressBackfillMigration, + bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, ) : AccountDataSource, SecretStoreV1 by secretStoreV1 { init { - migrateIfNeeded(accountDataMigration) - } + // Run sequentially in one coroutine, not as independent launches - the Tron/Bitcoin backfills read + // accounts/secrets that the legacy migration may still be in the middle of writing for very old + // (pre-MetaAccount) installs, and separate GlobalScope.launch calls give no ordering guarantee + // relative to each other. + async { + Log.d("AccountDataSourceImpl", "migrations block starting") - private fun migrateIfNeeded(migration: AccountDataMigration) = async { - if (migration.migrationNeeded()) { - migration.migrate(::saveSecuritySource) + if (accountDataMigration.migrationNeeded()) { + accountDataMigration.migrate(::saveSecuritySource) + } + + Log.d("AccountDataSourceImpl", "about to run tronAddressBackfillMigration") + + tronAddressBackfillMigration.migrate() + + Log.d("AccountDataSourceImpl", "about to run bitcoinAddressBackfillMigration") + + bitcoinAddressBackfillMigration.migrate() + + Log.d("AccountDataSourceImpl", "migrations block done") + + metaAccountDao.getMetaAccounts().forEach { + Log.d( + "TronDiag", + "metaId=${it.id} name=${it.name} type=${it.type} isSelected=${it.isSelected} " + + "tronAddress=${it.tronAddress?.joinToString("") { b -> "%02x".format(b) } ?: "NULL"} " + + "tronPublicKey=${if (it.tronPublicKey != null) "present(${it.tronPublicKey!!.size}B)" else "NULL"}" + ) + } } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt index 891d0d17..765558a6 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/SecretsMetaAccountLocalFactory.kt @@ -2,6 +2,7 @@ package io.novafoundation.nova.feature_account_impl.data.repository.datasource import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.utils.bitcoinPublicKeyToAccountId import io.novafoundation.nova.common.utils.substrateAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId import io.novafoundation.nova.core.model.CryptoType @@ -31,6 +32,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory { val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey] val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey) val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey) + val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey) return MetaAccountLocal( substratePublicKey = substratePublicKey, @@ -47,7 +49,13 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory { globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(), typeExtras = null, tronPublicKey = tronPublicKey, - tronAddress = tronPublicKey?.tronPublicKeyToAccountId() + tronAddress = tronPublicKey?.tronPublicKeyToAccountId(), + bitcoinPublicKey = bitcoinPublicKey, + // Bip32EcdsaKeypairFactory already yields a compressed (33-byte) public key (confirmed against + // substrate-sdk-android's own source: ECDSAUtils.derivePublicKey -> compressedPublicKeyFromPrivate), + // which is exactly the format both BIP143 (P2WPKH) and bitcoinPublicKeyToAccountId() require - no + // extra compression/decompression step needed here, unlike Ethereum's uncompressed-key derivation. + bitcoinAddress = bitcoinPublicKey?.bitcoinPublicKeyToAccountId() ) } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt new file mode 100644 index 00000000..8a2e8d3e --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/BitcoinAddressBackfillMigration.kt @@ -0,0 +1,119 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration + +import android.util.Log +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.bitcoinDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.bitcoinKeypair +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair +import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair +import io.novafoundation.nova.common.utils.bitcoinPublicKeyToAccountId +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.updateMetaAccount +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.data.secrets.BITCOIN_DEFAULT_DERIVATION_PATH +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "BitcoinAddressBackfill" + +/** + * Idempotent, per-account backfill for accounts that don't yet have a Bitcoin keypair - mirrors + * [TronAddressBackfillMigration]'s exact design (see that class for the full rationale): any account created + * before Bitcoin support existed has no `MetaAccountSecrets.BitcoinKeypair`/`meta_accounts.bitcoinAddress` yet, + * and this fills it in from the account's own mnemonic without requiring re-import. + * + * Deliberately has NO "have I already run once" flag, for the same reason as the Tron migration: a one-shot + * flag that gets set even when backfill legitimately still didn't produce a key (e.g. a since-fixed bug kept + * re-losing it) would permanently strand that account. This is cheap to call for an account that doesn't need + * it, so it just runs unconditionally on every app start. + * + * Only touches accounts that are `Type.SECRETS` (mnemonic-derived) and still hold their `Entropy` in + * [SecretStoreV2] - same restriction as the Tron migration, for the same reason (watch-only/Ledger/Json/ + * multisig/proxied accounts and raw-seed imports never had a Bitcoin-capable mnemonic to derive from). + */ +class BitcoinAddressBackfillMigration( + private val secretStoreV2: SecretStoreV2, + private val metaAccountDao: MetaAccountDao, + private val accountSecretsFactory: AccountSecretsFactory, +) { + + suspend fun migrate() = withContext(Dispatchers.Default) { + val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS } + Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}") + + secretsAccounts.forEach { account -> + try { + backfillIfNeeded(account) + } catch (e: Throwable) { + Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e) + } + } + + Log.d(TAG, "migrate() done") + } + + private suspend fun backfillIfNeeded(account: MetaAccountLocal) { + val secrets = secretStoreV2.getMetaAccountSecrets(account.id) + if (secrets == null) { + Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping") + return + } + val entropy = secrets.entropy + if (entropy == null) { + Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping") + return + } + if (secrets.bitcoinKeypair != null) { + Log.d(TAG, "metaId=${account.id}: already has a BitcoinKeypair - skipping") + return + } + val substrateCryptoType = account.substrateCryptoType + if (substrateCryptoType == null) { + Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping") + return + } + + Log.d(TAG, "metaId=${account.id}: deriving Bitcoin keypair") + + val mnemonic = MnemonicCreator.fromEntropy(entropy).words + + val bitcoinChainSecrets = accountSecretsFactory.chainAccountSecrets( + derivationPath = BITCOIN_DEFAULT_DERIVATION_PATH, + accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic), + isEthereum = true + ).secrets + + val bitcoinKeypair = mapKeypairStructToKeypair(bitcoinChainSecrets[ChainAccountSecrets.Keypair]) + + val updatedSecrets = MetaAccountSecrets( + substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair), + entropy = secrets.entropy, + substrateSeed = secrets.seed, + substrateDerivationPath = secrets.substrateDerivationPath, + ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair), + ethereumDerivationPath = secrets.ethereumDerivationPath, + tronKeypair = secrets.tronKeypair?.let(::mapKeypairStructToKeypair), + tronDerivationPath = secrets.tronDerivationPath, + bitcoinKeypair = bitcoinKeypair, + bitcoinDerivationPath = BITCOIN_DEFAULT_DERIVATION_PATH + ) + + secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets) + + val bitcoinAccountId = bitcoinKeypair.publicKey.bitcoinPublicKeyToAccountId() + metaAccountDao.updateMetaAccount(account.id) { it.addBitcoinAccount(bitcoinKeypair.publicKey, bitcoinAccountId) } + + Log.d(TAG, "metaId=${account.id}: backfilled successfully, bitcoinAddress set (${bitcoinAccountId.size} bytes)") + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt new file mode 100644 index 00000000..58b3f07a --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigration.kt @@ -0,0 +1,128 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration + +import android.util.Log +import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.ethereumKeypair +import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair +import io.novafoundation.nova.common.data.secrets.v2.seed +import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.substrateKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.dao.updateMetaAccount +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novafoundation.nova.feature_account_impl.data.secrets.TRON_DEFAULT_DERIVATION_PATH +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val TAG = "TronAddressBackfill" + +/** + * Idempotent, per-account backfill for accounts that don't yet have a Tron keypair - both accounts created + * before Tron support existed, AND accounts whose Tron keypair was lost some other way (e.g. the cloud-backup + * schema round trip that used to silently drop it before every wallet had a `tron` field to serialize into - + * see CloudBackup.kt). `73_74_AddTronSupport` (the migration that added `meta_accounts.tronPublicKey`/ + * `tronAddress`) is, like every other migration in this codebase, pure `ALTER TABLE` - it never derives a value + * for pre-existing rows. + * + * Deliberately has NO "have I already run once" flag: an earlier version of this class gated itself behind a + * one-shot SharedPreferences flag, which meant that once it ran and marked itself done - even for an account + * that legitimately still lacked a Tron keypair afterwards (e.g. because a *different*, since-fixed bug kept + * re-losing it) - it would never run again for that account, ever, on that install. [backfillIfNeeded] is cheap + * to call for an account that doesn't need it (a handful of null-checks, no derivation), so this just runs + * unconditionally on every app start instead: self-healing by construction, no stuck-flag failure mode possible. + * + * Only touches accounts that are: + * - `Type.SECRETS` (mnemonic-derived) - watch-only/Ledger/Json/multisig/proxied accounts never had a + * Tron-capable mnemonic and are correctly left with `tronAddress == null` forever, same as they already are + * for Ethereum. + * - still holding their `Entropy` in [SecretStoreV2] - an account imported from a raw seed/keypair rather than + * a mnemonic has no entropy either and is likewise correctly left alone. + * - missing a `TronKeypair` - i.e. not already backfilled and not created after Tron support shipped. + * + * The Tron keypair is derived via [AccountSecretsFactory.chainAccountSecrets] with `isEthereum = true` (Tron + * reuses the exact same secp256k1/BIP32 derivation as Ethereum, just under its own SLIP-44 coin-type-195 path - + * see that class's own doc comment) at [TRON_DEFAULT_DERIVATION_PATH], the same call this codebase's own + * `metaAccountSecrets()` makes for a fresh account - so a backfilled account ends up with byte-for-byte the + * same Tron address it would have gotten had it been created today, not a separately-reimplemented derivation. + */ +class TronAddressBackfillMigration( + private val secretStoreV2: SecretStoreV2, + private val metaAccountDao: MetaAccountDao, + private val accountSecretsFactory: AccountSecretsFactory, +) { + + suspend fun migrate() = withContext(Dispatchers.Default) { + val secretsAccounts = metaAccountDao.getMetaAccounts().filter { it.type == MetaAccountLocal.Type.SECRETS } + Log.d(TAG, "migrate() starting - ${secretsAccounts.size} SECRETS-type account(s): ${secretsAccounts.map { it.id }}") + + secretsAccounts.forEach { account -> + try { + backfillIfNeeded(account) + } catch (e: Throwable) { + Log.e(TAG, "backfill failed for metaId=${account.id}, continuing with remaining accounts", e) + } + } + + Log.d(TAG, "migrate() done") + } + + private suspend fun backfillIfNeeded(account: MetaAccountLocal) { + val secrets = secretStoreV2.getMetaAccountSecrets(account.id) + if (secrets == null) { + Log.d(TAG, "metaId=${account.id}: no stored secrets at all (watch-only/Ledger/etc) - skipping") + return + } + val entropy = secrets.entropy + if (entropy == null) { + Log.d(TAG, "metaId=${account.id}: no entropy (raw-seed import, not a mnemonic) - skipping") + return + } + if (secrets.tronKeypair != null) { + Log.d(TAG, "metaId=${account.id}: already has a TronKeypair - skipping") + return + } + val substrateCryptoType = account.substrateCryptoType + if (substrateCryptoType == null) { + Log.d(TAG, "metaId=${account.id}: substrateCryptoType is null - skipping") + return + } + + Log.d(TAG, "metaId=${account.id}: deriving Tron keypair") + + val mnemonic = MnemonicCreator.fromEntropy(entropy).words + + val tronChainSecrets = accountSecretsFactory.chainAccountSecrets( + derivationPath = TRON_DEFAULT_DERIVATION_PATH, + accountSource = AccountSecretsFactory.AccountSource.Mnemonic(substrateCryptoType, mnemonic), + isEthereum = true + ).secrets + + val tronKeypair = mapKeypairStructToKeypair(tronChainSecrets[ChainAccountSecrets.Keypair]) + + val updatedSecrets = MetaAccountSecrets( + substrateKeyPair = mapKeypairStructToKeypair(secrets.substrateKeypair), + entropy = secrets.entropy, + substrateSeed = secrets.seed, + substrateDerivationPath = secrets.substrateDerivationPath, + ethereumKeypair = secrets.ethereumKeypair?.let(::mapKeypairStructToKeypair), + ethereumDerivationPath = secrets.ethereumDerivationPath, + tronKeypair = tronKeypair, + tronDerivationPath = TRON_DEFAULT_DERIVATION_PATH + ) + + secretStoreV2.putMetaAccountSecrets(account.id, updatedSecrets) + + val tronAccountId = tronKeypair.publicKey.tronPublicKeyToAccountId() + metaAccountDao.updateMetaAccount(account.id) { it.addTronAccount(tronKeypair.publicKey, tronAccountId) } + + Log.d(TAG, "metaId=${account.id}: backfilled successfully, tronAddress set (${tronAccountId.size} bytes)") + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt index 5cdff614..0939771a 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/secrets/AccountSecretsFactory.kt @@ -34,6 +34,14 @@ import kotlinx.coroutines.withContext */ const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0" +/** + * BIP84 purpose (native SegWit), SLIP-44 coin type 0 (Bitcoin). Deliberately `//84//...`, not `//44//...` - + * this app only supports native SegWit (bech32 `bc1q...`) addresses, not legacy/P2SH-SegWit, so the derivation + * path signals that choice the same way real Bitcoin wallets do. Not user-configurable yet - always derived at + * this fixed path (single address, no HD address-index rotation). + */ +const val BITCOIN_DEFAULT_DERIVATION_PATH = "//84//0//0/0/0" + class AccountSecretsFactory( private val JsonDecoder: JsonDecoder ) { @@ -131,6 +139,7 @@ class AccountSecretsFactory( ethereumDerivationPath: String?, accountSource: AccountSource, tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH, + bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH, ): Result = withContext(Dispatchers.Default) { val (substrateSecrets, substrateCryptoType) = chainAccountSecrets( derivationPath = substrateDerivationPath, @@ -157,6 +166,16 @@ class AccountSecretsFactory( Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedTronDerivationPath?.junctions.orEmpty()) } + // Bitcoin (native SegWit) also reuses the exact same secp256k1/BIP32 keypair generation as Ethereum/Tron - + // only the SLIP-44 coin type (0) and BIP84 purpose differ. See BITCOIN_DEFAULT_DERIVATION_PATH's doc. + val bitcoinKeypair = accountSource.castOrNull()?.let { + val decodedBitcoinDerivationPath = decodeDerivationPath(bitcoinDerivationPath, ethereum = true) + + val seed = deriveSeed(it.mnemonic, password = decodedBitcoinDerivationPath?.password, ethereum = true).seed + + Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedBitcoinDerivationPath?.junctions.orEmpty()) + } + val secrets = MetaAccountSecrets( entropy = substrateSecrets[ChainAccountSecrets.Entropy], substrateSeed = substrateSecrets[ChainAccountSecrets.Seed], @@ -165,7 +184,9 @@ class AccountSecretsFactory( ethereumKeypair = ethereumKeypair, ethereumDerivationPath = ethereumDerivationPath, tronKeypair = tronKeypair, - tronDerivationPath = tronDerivationPath + tronDerivationPath = tronDerivationPath, + bitcoinKeypair = bitcoinKeypair, + bitcoinDerivationPath = bitcoinDerivationPath, ) Result(secrets = secrets, cryptoType = substrateCryptoType) diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt index 2336b9ef..1d0cd56f 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/data/signer/secrets/SecretsSigner.kt @@ -142,11 +142,15 @@ class SecretsSigner( private suspend fun getKeypair(accountId: AccountId): Keypair { val chainsById = chainRegistry.chainsById() val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!! + val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true + val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true return secretStoreV2.getKeypair( metaAccount = metaAccount, accountId = accountId, - isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum + isEthereumBased = multiChainEncryption is MultiChainEncryption.Ethereum, + isTronBased = isTronBased, + isBitcoinBased = isBitcoinBased ) } @@ -159,11 +163,13 @@ class SecretsSigner( private suspend fun SecretStoreV2.getKeypair( metaAccount: MetaAccount, accountId: AccountId, - isEthereumBased: Boolean + isEthereumBased: Boolean, + isTronBased: Boolean = false, + isBitcoinBased: Boolean = false, ) = if (hasChainSecrets(metaAccount.id, accountId)) { getChainAccountKeypair(metaAccount.id, accountId) } else { - getMetaAccountKeypair(metaAccount.id, isEthereumBased) + getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased) } /** @@ -173,6 +179,12 @@ class SecretsSigner( return when { substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum + // Tron and Bitcoin both reuse the exact same secp256k1 signing scheme as Ethereum - they just have + // their own accountId (different SLIP-44 derivation path), so neither matches ethereumAccountId() + // above and was falling through to the chainAccounts lookup, which doesn't cover them either -> + // null -> NPE on `!!`. + tronAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum + bitcoinAddress?.contentEquals(accountId) == true -> MultiChainEncryption.Ethereum else -> { val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null val cryptoType = chainAccount.cryptoType ?: return null diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 42a1baec..404edfcb 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -104,6 +104,8 @@ import io.novafoundation.nova.feature_account_impl.data.repository.datasource.Ac import io.novafoundation.nova.feature_account_impl.data.repository.datasource.RealSecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.AccountDataMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.BitcoinAddressBackfillMigration +import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.TronAddressBackfillMigration import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule @@ -402,10 +404,12 @@ class AccountFeatureModule { nodeDao: NodeDao, secretStoreV1: SecretStoreV1, accountDataMigration: AccountDataMigration, + tronAddressBackfillMigration: TronAddressBackfillMigration, metaAccountDao: MetaAccountDao, secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory, secretStoreV2: SecretStoreV2, accountMappers: AccountMappers, + bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration, ): AccountDataSource { return AccountDataSourceImpl( preferences, @@ -416,10 +420,22 @@ class AccountFeatureModule { secretStoreV2, secretsMetaAccountLocalFactory, secretStoreV1, - accountDataMigration + accountDataMigration, + tronAddressBackfillMigration, + bitcoinAddressBackfillMigration ) } + @Provides + @FeatureScope + fun provideBitcoinAddressBackfillMigration( + secretStoreV2: SecretStoreV2, + metaAccountDao: MetaAccountDao, + accountSecretsFactory: AccountSecretsFactory, + ): BitcoinAddressBackfillMigration { + return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) + } + @Provides fun provideNodeHostValidator() = NodeHostValidator() @@ -439,6 +455,16 @@ class AccountFeatureModule { return AccountDataMigration(preferences, encryptedPreferences, accountDao) } + @Provides + @FeatureScope + fun provideTronAddressBackfillMigration( + secretStoreV2: SecretStoreV2, + metaAccountDao: MetaAccountDao, + accountSecretsFactory: AccountSecretsFactory, + ): TronAddressBackfillMigration { + return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) + } + @Provides @FeatureScope fun provideExternalAccountActions( diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt index 13ba653a..98661b2f 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/DefaultMetaAccount.kt @@ -24,6 +24,8 @@ open class DefaultMetaAccount( override val parentMetaId: Long?, override val tronAddress: ByteArray? = null, override val tronPublicKey: ByteArray? = null, + override val bitcoinAddress: ByteArray? = null, + override val bitcoinPublicKey: ByteArray? = null, ) : MetaAccount { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { @@ -34,6 +36,7 @@ open class DefaultMetaAccount( return when { hasChainAccountIn(chain.id) -> true chain.isTronBased -> tronAddress != null + chain.isBitcoinBased -> bitcoinAddress != null chain.isEthereumBased -> ethereumAddress != null else -> substrateAccountId != null } @@ -43,6 +46,7 @@ open class DefaultMetaAccount( return when { hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId chain.isTronBased -> tronAddress + chain.isBitcoinBased -> bitcoinAddress chain.isEthereumBased -> ethereumAddress else -> substrateAccountId } @@ -52,6 +56,7 @@ open class DefaultMetaAccount( return when { hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey chain.isTronBased -> tronPublicKey + chain.isBitcoinBased -> bitcoinPublicKey chain.isEthereumBased -> ethereumPublicKey else -> substratePublicKey } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt index 6e0c5164..7ac80199 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/GenericLedgerMetaAccount.kt @@ -24,6 +24,8 @@ class GenericLedgerMetaAccount( private val supportedGenericLedgerChains: Set, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -39,7 +41,9 @@ class GenericLedgerMetaAccount( chainAccounts = chainAccounts, parentMetaId = parentMetaId, tronAddress = tronAddress, - tronPublicKey = tronPublicKey + tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey ) { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt index 9eae9bc5..0f5d5f11 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/LegacyLedgerMetaAccount.kt @@ -24,6 +24,8 @@ class LegacyLedgerMetaAccount( parentMetaId: Long?, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -39,7 +41,9 @@ class LegacyLedgerMetaAccount( chainAccounts = chainAccounts, parentMetaId = parentMetaId, tronAddress = tronAddress, - tronPublicKey = tronPublicKey + tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey ) { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt index 2f16fe46..146c4fa7 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/MultisigMetaAccount.kt @@ -30,6 +30,8 @@ class RealMultisigMetaAccount( parentMetaId: Long?, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -45,7 +47,9 @@ class RealMultisigMetaAccount( chainAccounts = chainAccounts, parentMetaId = parentMetaId, tronAddress = tronAddress, - tronPublicKey = tronPublicKey + tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey ), MultisigMetaAccount { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt index bba2effc..a1ace6ad 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/PolkadotVaultMetaAccount.kt @@ -22,6 +22,8 @@ class PolkadotVaultMetaAccount( parentMetaId: Long?, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -37,10 +39,12 @@ class PolkadotVaultMetaAccount( chainAccounts = chainAccounts, parentMetaId = parentMetaId, tronAddress = tronAddress, - tronPublicKey = tronPublicKey + tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey ) { override suspend fun supportsAddingChainAccount(chain: Chain): Boolean { - return !chain.isEthereumBased && !chain.isTronBased + return !chain.isEthereumBased && !chain.isTronBased && !chain.isBitcoinBased } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt index 46c2bfe7..51cb92cf 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealProxiedMetaAccount.kt @@ -24,6 +24,8 @@ internal class RealProxiedMetaAccount( parentMetaId: Long?, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -39,7 +41,9 @@ internal class RealProxiedMetaAccount( chainAccounts = chainAccounts, parentMetaId = parentMetaId, tronAddress = tronAddress, - tronPublicKey = tronPublicKey + tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey ), ProxiedMetaAccount { diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt index 7b88a202..f29bc849 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/domain/account/model/RealSecretsMetaAccount.kt @@ -25,6 +25,8 @@ class RealSecretsMetaAccount( parentMetaId: Long?, tronAddress: ByteArray? = null, tronPublicKey: ByteArray? = null, + bitcoinAddress: ByteArray? = null, + bitcoinPublicKey: ByteArray? = null, ) : DefaultMetaAccount( id = id, globallyUniqueId = globallyUniqueId, @@ -40,7 +42,9 @@ class RealSecretsMetaAccount( chainAccounts = chainAccounts, parentMetaId = parentMetaId, tronAddress = tronAddress, - tronPublicKey = tronPublicKey + tronPublicKey = tronPublicKey, + bitcoinAddress = bitcoinAddress, + bitcoinPublicKey = bitcoinPublicKey ), SecretsMetaAccount { @@ -49,7 +53,10 @@ class RealSecretsMetaAccount( hasChainAccountIn(chain.id) -> { val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null - if (chain.isEthereumBased) { + // Tron and Bitcoin both reuse the same secp256k1 keypair/signing as Ethereum - see + // RealTronTransactionService/RealBitcoinTransactionService's use of + // Signer.sign(MultiChainEncryption.Ethereum, ...). + if (chain.isEthereumBased || chain.isTronBased || chain.isBitcoinBased) { MultiChainEncryption.Ethereum } else { MultiChainEncryption.substrateFrom(cryptoType) @@ -58,6 +65,10 @@ class RealSecretsMetaAccount( chain.isEthereumBased -> MultiChainEncryption.Ethereum + chain.isTronBased -> MultiChainEncryption.Ethereum + + chain.isBitcoinBased -> MultiChainEncryption.Ethereum + else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom) } } diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt index 864d33f0..a426577a 100644 --- a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/cloudBackup/RealLocalAccountsCloudBackupFacadeTest.kt @@ -7,6 +7,8 @@ import io.novafoundation.nova.common.data.secrets.v2.ChainAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 import io.novafoundation.nova.common.data.secrets.v2.entropy +import io.novafoundation.nova.common.data.secrets.v2.tronDerivationPath +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair import io.novafoundation.nova.core.model.CryptoType import io.novafoundation.nova.core_db.dao.MetaAccountDao import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal @@ -536,6 +538,64 @@ class RealLocalAccountsCloudBackupFacadeTest { verifyEvent(expectedEvent) } + // Regression test for a wallet's Tron address/keypair being silently dropped on a cloud backup round trip: + // WalletPublicInfo/WalletPrivateInfo had no tron field at all until this fix, so a backup written from a + // wallet that genuinely had a Tron address, once applied back to local (e.g. after "clear all data" + + // restore, or on a fresh install created via the cloud-backup-first-wallet flow), landed with + // tronAddress/tronPublicKey/tronKeypair = null - found via real-device testing, not by this test. + @Test + fun shouldApplyAddAccountDiffWithTronSecrets() = runBlocking { + LocalAccountsMocker.setupMocks(metaAccountDao) {} + SecretStoreMocker.setupMocks(secretStore) {} + + allChainsAreEvm(false) + + val localBackup = buildTestCloudBackup { + publicData { } + privateData { } + } + + val bytes32 = bytes32of(0) + val tronAddressBytes = bytes20of(1) + val tronDerivationPath = "//44//195//0/0/0" + + val cloudBackup = buildTestCloudBackup { + publicData { + wallet(walletUUid(0)) { + substrateAccountId(bytes32) + substrateCryptoType(CryptoType.SR25519) + substratePublicKey(bytes32) + + tronPublicKey(bytes32) + tronAddress(tronAddressBytes) + } + } + + privateData { + wallet(walletUUid(0)) { + entropy(bytes32) + + substrate { + seed(bytes32) + keypair(KeyPairSecrets(bytes32, bytes32, bytes32)) + } + + tron { + derivationPath(tronDerivationPath) + keypair(KeyPairSecrets(bytes32, bytes32, nonce = null)) + } + } + } + } + + val diff = localBackup.localVsCloudDiff(cloudBackup, BackupDiffStrategy.overwriteLocal()) + + facade.applyBackupDiff(diff, cloudBackup) + + verify(metaAccountDao).insertMetaAccount(metaAccountWithTronAddress(tronAddressBytes)) + verify(secretStore).putMetaAccountSecrets(eq(0), metaAccountSecretsWithTronDerivationPath(tronDerivationPath)) + } + @Test fun shouldApplyRemoveAccountDiff(): Unit = runBlocking { allChainsAreEvm(false) @@ -1205,6 +1265,14 @@ class RealLocalAccountsCloudBackupFacadeTest { return argThat { it.globallyUniqueId == id } } + private fun metaAccountWithTronAddress(tronAddress: ByteArray): MetaAccountLocal { + return argThat { it.tronAddress.contentEquals(tronAddress) } + } + + private fun metaAccountSecretsWithTronDerivationPath(derivationPath: String): EncodableStruct { + return argThat { it.tronDerivationPath == derivationPath && it.tronKeypair != null } + } + private suspend fun verifyNoAdditionalSecretsInserted() { verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any()) } diff --git a/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt new file mode 100644 index 00000000..6e075706 --- /dev/null +++ b/feature-account-impl/src/test/java/io/novafoundation/nova/feature_account_impl/data/repository/datasource/migration/TronAddressBackfillMigrationTest.kt @@ -0,0 +1,192 @@ +package io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration + +import io.novafoundation.nova.common.data.secrets.v2.KeyPairSchema +import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets +import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2 +import io.novafoundation.nova.common.data.secrets.v2.mapKeypairStructToKeypair +import io.novafoundation.nova.common.data.secrets.v2.tronKeypair +import io.novafoundation.nova.common.utils.invoke +import io.novafoundation.nova.common.utils.tronAddressToAccountId +import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId +import io.novafoundation.nova.core.model.CryptoType +import io.novafoundation.nova.core_db.dao.MetaAccountDao +import io.novafoundation.nova.core_db.model.chain.account.MetaAccountLocal +import io.novafoundation.nova.feature_account_impl.data.secrets.AccountSecretsFactory +import io.novasama.substrate_sdk_android.encrypt.json.JsonDecoder +import io.novasama.substrate_sdk_android.encrypt.mnemonic.MnemonicCreator +import io.novasama.substrate_sdk_android.scale.EncodableStruct +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatcher +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner + +// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and for the same reason: the +// shared test_shared eq()/any() helpers crash with "eq(...) must not be null" once Mockito's genuinely-null +// runtime return flows into a Kotlin non-null-typed parameter - see that test's class doc for the full writeup. +private fun eq(value: T): T = Mockito.eq(value) ?: value + +@Suppress("UNCHECKED_CAST") +private fun any(): T { + Mockito.any() + return null as T +} + +@Suppress("UNCHECKED_CAST") +private fun argThat(matcher: (T) -> Boolean): T { + Mockito.argThat(ArgumentMatcher { matcher(it) }) + return null as T +} + +private fun whenever(methodCall: T?) = Mockito.`when`(methodCall) + +/** + * This is the money-safety-critical half of the Tron backfill fix (see TronAddressBackfillMigration's class + * doc for the full context): a wrong derivation here would silently give a pre-existing wallet a Tron address + * it does NOT actually control, or fail to skip an account it shouldn't touch. Uses a real (non-mocked) + * AccountSecretsFactory so the actual BIP32/secp256k1 derivation math runs for real, and asserts against the + * same live-TronGrid-cross-validated reference address TronDerivationTest already established for the standard + * BIP39 test mnemonic, so this test and that one are pinned to the same known-good vector. + */ +@RunWith(MockitoJUnitRunner::class) +class TronAddressBackfillMigrationTest { + + @Mock + lateinit var secretStoreV2: SecretStoreV2 + + @Mock + lateinit var metaAccountDao: MetaAccountDao + + @Mock + lateinit var jsonDecoder: JsonDecoder + + private lateinit var subject: TronAddressBackfillMigration + + private val testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + private val expectedTronAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId() + + @Before + fun setup() { + // Real factory, not a mock - the whole point of this test is to exercise the actual derivation. + val accountSecretsFactory = AccountSecretsFactory(jsonDecoder) + subject = TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory) + } + + @Test + fun `migrate should derive and persist the well-known reference Tron address for a pre-existing mnemonic account`(): Unit = runBlocking { + val account = secretsAccount(id = 42, substrateCryptoType = CryptoType.SR25519) + val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = null) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(metaAccountDao.getMetaAccount(eq(42L))).thenReturn(account) + whenever(secretStoreV2.getMetaAccountSecrets(eq(42L))).thenReturn(secrets) + + subject.migrate() + + verify(metaAccountDao).updateMetaAccount( + argThat { updated -> + updated.id == 42L && updated.tronAddress.contentEquals(expectedTronAccountId) + } + ) + + verify(secretStoreV2).putMetaAccountSecrets( + eq(42L), + argThat> { updatedSecrets -> + val tronKeypairStruct = updatedSecrets.tronKeypair + tronKeypairStruct != null && + mapKeypairStructToKeypair(tronKeypairStruct).publicKey.tronPublicKeyToAccountId().contentEquals(expectedTronAccountId) + } + ) + } + + @Test + fun `migrate should skip an account that already has a Tron keypair`(): Unit = runBlocking { + val account = secretsAccount(id = 7, substrateCryptoType = CryptoType.SR25519) + val existingTronKeypair = KeyPairSchema { keypair -> + keypair[KeyPairSchema.PublicKey] = ByteArray(33) { 9 } + keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 8 } + keypair[KeyPairSchema.Nonce] = null + } + val secrets = accountSecrets(entropy = MnemonicCreator.fromWords(testMnemonic).entropy, tronKeypair = existingTronKeypair) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(secretStoreV2.getMetaAccountSecrets(eq(7L))).thenReturn(secrets) + + subject.migrate() + + verify(metaAccountDao, never()).updateMetaAccount(any()) + // metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong(). + verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any()) + } + + @Test + fun `migrate should skip an account with no entropy (raw-seed import, not a mnemonic)`(): Unit = runBlocking { + val account = secretsAccount(id = 11, substrateCryptoType = CryptoType.SR25519) + val secrets = accountSecrets(entropy = null, tronKeypair = null) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(secretStoreV2.getMetaAccountSecrets(eq(11L))).thenReturn(secrets) + + subject.migrate() + + verify(metaAccountDao, never()).updateMetaAccount(any()) + // metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong(). + verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any()) + } + + @Test + fun `migrate should skip an account with no stored secrets at all (watch-only, Ledger, etc)`(): Unit = runBlocking { + val account = secretsAccount(id = 13, substrateCryptoType = CryptoType.SR25519) + + whenever(metaAccountDao.getMetaAccounts()).thenReturn(listOf(account)) + whenever(secretStoreV2.getMetaAccountSecrets(eq(13L))).thenReturn(null) + + subject.migrate() + + verify(metaAccountDao, never()).updateMetaAccount(any()) + // metaId is a primitive Long parameter - same anyBoolean() reasoning applies, use anyLong(). + verify(secretStoreV2, never()).putMetaAccountSecrets(Mockito.anyLong(), any()) + } + + private fun secretsAccount(id: Long, substrateCryptoType: CryptoType): MetaAccountLocal { + return MetaAccountLocal( + substratePublicKey = ByteArray(32) { 1 }, + substrateCryptoType = substrateCryptoType, + substrateAccountId = ByteArray(32) { 2 }, + ethereumPublicKey = null, + ethereumAddress = null, + name = "Test account", + parentMetaId = null, + isSelected = true, + position = 0, + type = MetaAccountLocal.Type.SECRETS, + status = MetaAccountLocal.Status.ACTIVE, + globallyUniqueId = "test-guid-$id", + typeExtras = null + ).also { it.id = id } + } + + private fun accountSecrets(entropy: ByteArray?, tronKeypair: EncodableStruct?): EncodableStruct { + val dummySubstrateKeypair = KeyPairSchema { keypair -> + keypair[KeyPairSchema.PublicKey] = ByteArray(32) { 5 } + keypair[KeyPairSchema.PrivateKey] = ByteArray(32) { 6 } + keypair[KeyPairSchema.Nonce] = ByteArray(8) { 7 } + } + + return MetaAccountSecrets( + substrateKeyPair = mapKeypairStructToKeypair(dummySubstrateKeypair), + entropy = entropy, + substrateSeed = null, + substrateDerivationPath = null, + ethereumKeypair = null, + ethereumDerivationPath = null, + tronKeypair = tronKeypair?.let(::mapKeypairStructToKeypair), + tronDerivationPath = null + ) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt index 80265626..b7c39765 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/data/network/BalancesUpdateSystem.kt @@ -39,12 +39,24 @@ class BalancesUpdateSystem( override fun start(): Flow { return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount -> chainRegistry.currentChains.transformLatestDiffed { chain -> + if (chain.isTronBased) { + Log.d(LOG_TAG, "TronDebug: currentChains delivered chain=${chain.id} name=${chain.name}") + } emitAll(balancesSync(chain, metaAccount)) } }.flowOn(Dispatchers.Default) } private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow { + if (chain.isTronBased) { + Log.d( + LOG_TAG, + "TronDebug: gate check chain=${chain.id} name=${chain.name} hasAccountIn=${metaAccount.hasAccountIn(chain)} " + + "connectionState=${chain.connectionState} isDisabled=${chain.connectionState.isDisabled} " + + "hasSubstrateRuntime=${chain.hasSubstrateRuntime} canPerformFullSync=${chain.canPerformFullSync()}" + ) + } + return when { !metaAccount.hasAccountIn(chain) -> emptyFlow() chain.connectionState.isDisabled -> emptyFlow() @@ -89,6 +101,10 @@ class BalancesUpdateSystem( try { updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) } } catch (e: Exception) { + // Was silently swallowed here with zero logging - listenForUpdates() itself is a suspend + // call that can throw synchronously (e.g. FullSyncPaymentUpdater.listenForUpdates() calling + // requireAccountIdIn(chain)), before ever returning a flow for the .catch{} above to guard. + Log.e(LOG_TAG, "listenForUpdates() threw synchronously for ${updater.javaClass.simpleName} in ${chain.name}", e) emptyFlow() } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt index a299abf8..7ecf6276 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/balance/common/mappers/TokenAssetMappers.kt @@ -6,7 +6,8 @@ import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.presentation.getAssetIconOrFallback import io.novafoundation.nova.common.utils.formatting.formatAsChange import io.novafoundation.nova.common.utils.orZero -import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi +import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi +import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork @@ -90,7 +91,14 @@ class TokenAssetFormatter( group.getId(), mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)), assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration), - mapChainToUi(it.chain) + // Not mapChainToUi() here - this row's subtitle needs to disambiguate which issuance of the + // token this is (e.g. "Ethereum (ERC-20)" vs "Tron (TRC-20)"), which a bare chain name alone + // doesn't when multiple ecosystems share the same symbol (USDT, USDC, etc.). + ChainUi( + id = it.chain.id, + name = it.chain.displayNameWithAssetStandard(), + icon = it.chain.icon + ) ) } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt index 6a35d7ca..6f53ab62 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt @@ -5,11 +5,14 @@ import android.text.TextWatcher import android.view.View import io.novafoundation.nova.common.base.BaseFragment import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.AlertView import io.novafoundation.nova.common.view.setState import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent +import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability class BridgeFragment : BaseFragment() { @@ -18,26 +21,29 @@ class BridgeFragment : BaseFragment() { override fun initViews() { binder.bridgeToolbar.setHomeButtonListener { viewModel.backClicked() } - // Pair selector - binder.bridgePairDotHez.setOnClickListener { - viewModel.setPair(BridgePair.DOT_HEZ) + binder.bridgeToCard.setEditable(false) + + // Tapping the "from" card opens the pair picker - replaces the old segmented pair buttons + binder.bridgeFromCard.setCardClickListener { + val options = viewModel.pairOptions.value.orEmpty() + if (options.isNotEmpty()) { + BridgePairListBottomSheet(requireContext(), options) { selected -> + viewModel.setPair(selected.pair) + }.show() + } } - binder.bridgePairUsdt.setOnClickListener { - viewModel.setPair(BridgePair.USDT) - } - - // Direction toggle - binder.bridgeDirectionLeft.setOnClickListener { - viewModel.setDirectionLeft() - } - - binder.bridgeDirectionRight.setOnClickListener { - viewModel.setDirectionRight() + // One-tap direction flip - replaces the old segmented direction buttons + binder.bridgeFlipButton.setOnClickListener { + when (viewModel.direction.value) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> viewModel.setDirectionRight() + BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> viewModel.setDirectionLeft() + null -> Unit + } } // Amount input - binder.bridgeFromAmount.addTextChangedListener(object : TextWatcher { + binder.bridgeFromCard.amountInput.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun afterTextChanged(s: Editable?) { @@ -46,6 +52,10 @@ class BridgeFragment : BaseFragment() { } }) + binder.bridgeFromMaxAmount.setMaxActionAvailability( + MaxActionAvailability.Available { viewModel.maxClicked() } + ) + // Swap button binder.bridgeSwapButton.setOnClickListener { viewModel.swapClicked() @@ -74,16 +84,16 @@ class BridgeFragment : BaseFragment() { } override fun subscribe(viewModel: BridgeViewModel) { - viewModel.pair.observe { pair -> - updatePairUI(pair) + viewModel.fromCard.observe { model -> + binder.bridgeFromCard.setModel(model) } - viewModel.direction.observe { direction -> - updateDirectionUI(direction) + viewModel.toCard.observe { model -> + binder.bridgeToCard.setModel(model) } viewModel.outputAmount.observe { output -> - binder.bridgeToAmount.text = output + binder.bridgeToCard.setAmountText(output) } viewModel.exchangeRateText.observe { rate -> @@ -99,20 +109,18 @@ class BridgeFragment : BaseFragment() { } viewModel.showWarning.observe { show -> - binder.bridgeHezToDotWarning.visibility = if (show) View.VISIBLE else View.GONE + binder.bridgeWarningAlert.setVisible(show) } viewModel.warningBlocked.observe { blocked -> - if (blocked) { - binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.error_block_background, null)) - } else { - binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.warning_block_background, null)) - } + binder.bridgeWarningAlert.setStylePreset( + if (blocked) AlertView.StylePreset.ERROR else AlertView.StylePreset.WARNING + ) } viewModel.warningText.observe { text -> if (text.isNotEmpty()) { - binder.bridgeHezToDotWarning.text = text + binder.bridgeWarningAlert.setMessage(text) } } @@ -136,65 +144,17 @@ class BridgeFragment : BaseFragment() { viewModel.signButtonLabel.observe { label -> binder.bridgeSignButton.text = label } - } - private fun updatePairUI(pair: BridgePair) { - when (pair) { - BridgePair.DOT_HEZ -> { - binder.bridgePairDotHez.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgePairDotHez.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgePairUsdt.background = null - binder.bridgePairUsdt.setTextColor(resources.getColor(R.color.text_secondary, null)) - } - BridgePair.USDT -> { - binder.bridgePairUsdt.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgePairUsdt.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgePairDotHez.background = null - binder.bridgePairDotHez.setTextColor(resources.getColor(R.color.text_secondary, null)) - } - } - } - - private fun updateDirectionUI(direction: BridgeDirection) { - val isLeft = direction == BridgeDirection.DOT_TO_HEZ || direction == BridgeDirection.USDT_TO_WUSDT - - if (isLeft) { - binder.bridgeDirectionLeft.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgeDirectionLeft.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgeDirectionRight.background = null - binder.bridgeDirectionRight.setTextColor(resources.getColor(R.color.text_secondary, null)) - } else { - binder.bridgeDirectionRight.setBackgroundResource(R.drawable.bg_button_primary) - binder.bridgeDirectionRight.setTextColor(resources.getColor(R.color.text_primary, null)) - binder.bridgeDirectionLeft.background = null - binder.bridgeDirectionLeft.setTextColor(resources.getColor(R.color.text_secondary, null)) + viewModel.maxAmountDisplay.observe { display -> + binder.bridgeFromMaxAmount.setMaxAmountDisplay(display) } - when (direction) { - BridgeDirection.DOT_TO_HEZ -> { - binder.bridgeDirectionLeft.text = "DOT → HEZ" - binder.bridgeDirectionRight.text = "HEZ → DOT" - binder.bridgeFromToken.text = "DOT" - binder.bridgeToToken.text = "HEZ" - } - BridgeDirection.HEZ_TO_DOT -> { - binder.bridgeDirectionLeft.text = "DOT → HEZ" - binder.bridgeDirectionRight.text = "HEZ → DOT" - binder.bridgeFromToken.text = "HEZ" - binder.bridgeToToken.text = "DOT" - } - BridgeDirection.USDT_TO_WUSDT -> { - binder.bridgeDirectionLeft.text = "USDT(Pol) → USDT(Pez)" - binder.bridgeDirectionRight.text = "USDT(Pez) → USDT(Pol)" - binder.bridgeFromToken.text = "USDT" - binder.bridgeToToken.text = "USDT" - } - BridgeDirection.WUSDT_TO_USDT -> { - binder.bridgeDirectionLeft.text = "USDT(Pol) → USDT(Pez)" - binder.bridgeDirectionRight.text = "USDT(Pez) → USDT(Pol)" - binder.bridgeFromToken.text = "USDT" - binder.bridgeToToken.text = "USDT" - } + viewModel.insufficientBalanceError.observe { error -> + binder.bridgeFromCard.setError(error) + } + + viewModel.fillAmountEvent.observeEvent { amount -> + binder.bridgeFromCard.amountInput.setText(amount) } } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt new file mode 100644 index 00000000..189f3cda --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgePairListBottomSheet.kt @@ -0,0 +1,68 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge + +import android.content.Context +import android.os.Bundle +import androidx.recyclerview.widget.DiffUtil +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.images.Icon +import io.novafoundation.nova.common.utils.images.setIconOrMakeGone +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListBottomSheet +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.DynamicListSheetAdapter +import io.novafoundation.nova.common.view.bottomSheet.list.dynamic.HolderCreator +import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.databinding.ItemBridgePairListBinding + +data class BridgePairUi( + val pair: BridgePair, + val icon: Icon, + val title: String +) + +class BridgePairListBottomSheet( + context: Context, + data: List, + onClicked: (BridgePairUi) -> Unit +) : DynamicListBottomSheet( + context, + Payload(data), + BridgePairDiffCallback, + onClicked = { _, item -> onClicked(item) } +) { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setTitle(R.string.bridge_title) + } + + override fun holderCreator(): HolderCreator = { + BridgePairListHolder(ItemBridgePairListBinding.inflate(it.inflater(), it, false)) + } +} + +class BridgePairListHolder( + private val binder: ItemBridgePairListBinding +) : DynamicListSheetAdapter.Holder(binder.root) { + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(binder.root.context).imageLoader() + } + + override fun bind(item: BridgePairUi, isSelected: Boolean, handler: DynamicListSheetAdapter.Handler) { + binder.itemBridgePairIcon.setIconOrMakeGone(item.icon, imageLoader) + binder.itemBridgePairTitle.text = item.title + binder.root.setOnClickListener { handler.itemClicked(item) } + } +} + +private object BridgePairDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: BridgePairUi, newItem: BridgePairUi): Boolean { + return oldItem.pair == newItem.pair + } + + override fun areContentsTheSame(oldItem: BridgePairUi, newItem: BridgePairUi): Boolean { + return true + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt index 5ae43621..8c506231 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt @@ -3,9 +3,14 @@ package io.novafoundation.nova.feature_assets.presentation.bridge import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import io.novafoundation.nova.common.base.BaseViewModel +import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.resources.ResourceManager +import io.novafoundation.nova.common.utils.Event +import io.novafoundation.nova.common.utils.images.Icon import io.novafoundation.nova.common.view.ButtonState +import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_assets.R +import io.novafoundation.nova.feature_assets.domain.WalletInteractor import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeSignerState import io.novafoundation.nova.feature_assets.presentation.AssetsRouter @@ -13,9 +18,11 @@ import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayloa import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload import io.novafoundation.nova.runtime.ext.ChainGeneses import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONObject @@ -27,6 +34,8 @@ class BridgeViewModel( private val router: AssetsRouter, private val resourceManager: ResourceManager, private val chainRegistry: ChainRegistry, + private val assetIconProvider: AssetIconProvider, + private val walletInteractor: WalletInteractor, private val bridgeMultisigInteractor: BridgeMultisigInteractor ) : BaseViewModel() { @@ -91,15 +100,37 @@ class BridgeViewModel( private val _signButtonLabel = MutableLiveData("") val signButtonLabel: LiveData = _signButtonLabel + private val _fromCard = MutableLiveData() + val fromCard: LiveData = _fromCard + + private val _toCard = MutableLiveData() + val toCard: LiveData = _toCard + + private val _pairOptions = MutableLiveData>(emptyList()) + val pairOptions: LiveData> = _pairOptions + + private val _maxAmountDisplay = MutableLiveData(null) + val maxAmountDisplay: LiveData = _maxAmountDisplay + + private val _insufficientBalanceError = MutableLiveData(null) + val insufficientBalanceError: LiveData = _insufficientBalanceError + + private val _fillAmountEvent = MutableLiveData>() + val fillAmountEvent: LiveData> = _fillAmountEvent + private var currentAmount: Double = 0.0 private var dotToHezRate: Double = FALLBACK_RATE private var isHezToDotActive: Boolean = false private var isWusdtToUsdtActive: Boolean = false + private var availableBalance: BigDecimal = BigDecimal.ZERO + private var balanceJob: Job? = null init { fetchExchangeRate() fetchBridgeStatus() refreshSignerState() + updateCards() + loadPairOptions() } fun setPair(newPair: BridgePair) { @@ -113,6 +144,7 @@ class BridgeViewModel( updateUI() calculateOutput() updateWarningState() + updateCards() } } @@ -127,6 +159,7 @@ class BridgeViewModel( updateUI() calculateOutput() updateWarningState() + updateCards() } } @@ -141,15 +174,21 @@ class BridgeViewModel( updateUI() calculateOutput() updateWarningState() + updateCards() } } fun setAmount(amount: Double) { currentAmount = amount calculateOutput() + updateInsufficientBalanceState() updateButtonState() } + fun maxClicked() { + _fillAmountEvent.value = Event(availableBalance.stripTrailingZeros().toPlainString()) + } + fun swapClicked() { val dir = _direction.value ?: return if (currentAmount <= 0) return @@ -329,12 +368,21 @@ class BridgeViewModel( _buttonState.value = when { currentAmount <= 0 -> ButtonState.DISABLED currentAmount < minimum -> ButtonState.DISABLED + BigDecimal.valueOf(currentAmount) > availableBalance -> ButtonState.DISABLED dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED else -> ButtonState.NORMAL } } + private fun updateInsufficientBalanceState() { + _insufficientBalanceError.value = if (currentAmount > 0 && BigDecimal.valueOf(currentAmount) > availableBalance) { + resourceManager.getString(R.string.bridge_insufficient_balance) + } else { + null + } + } + fun refreshBridgeStatus() { fetchBridgeStatus() refreshSignerState() @@ -388,4 +436,81 @@ class BridgeViewModel( } } } + + private fun updateCards() { + val dir = _direction.value ?: return + + // Same chainId/assetId mapping already used by swapClicked() to resolve the origin side - + // mirrored here (plus its destination counterpart) purely to display logos/names, no new business rule. + val originChainId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID + BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID + } + val destChainId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> PEZKUWI_ASSET_HUB_ID + BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> POLKADOT_ASSET_HUB_ID + } + val originAssetId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.HEZ_TO_DOT -> UTILITY_ASSET_ID + BridgeDirection.USDT_TO_WUSDT -> POLKADOT_USDT_ASSET_ID + BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_USDT_ASSET_ID + } + val destAssetId = when (dir) { + BridgeDirection.DOT_TO_HEZ, BridgeDirection.HEZ_TO_DOT -> UTILITY_ASSET_ID + BridgeDirection.USDT_TO_WUSDT -> PEZKUWI_USDT_ASSET_ID + BridgeDirection.WUSDT_TO_USDT -> POLKADOT_USDT_ASSET_ID + } + + launch { + _fromCard.value = cardUiFor(originChainId, originAssetId) + _toCard.value = cardUiFor(destChainId, destAssetId) + } + + observeOriginBalance(originChainId, originAssetId) + } + + private fun observeOriginBalance(chainId: String, assetId: Int) { + balanceJob?.cancel() + balanceJob = launch { + walletInteractor.assetFlow(chainId, assetId).collect { asset -> + availableBalance = asset.transferable + _maxAmountDisplay.postValue( + "${availableBalance.setScale(6, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} ${asset.token.configuration.symbol.value}" + ) + updateInsufficientBalanceState() + updateButtonState() + } + } + } + + private fun loadPairOptions() { + launch { + val dotHezIcon = cardUiFor(POLKADOT_ASSET_HUB_ID, UTILITY_ASSET_ID).assetIcon + val usdtIcon = cardUiFor(POLKADOT_ASSET_HUB_ID, POLKADOT_USDT_ASSET_ID).assetIcon + + _pairOptions.value = listOf( + BridgePairUi(BridgePair.DOT_HEZ, dotHezIcon, resourceManager.getString(R.string.bridge_pair_dot_hez)), + BridgePairUi(BridgePair.USDT, usdtIcon, resourceManager.getString(R.string.bridge_pair_usdt)) + ) + } + } + + private suspend fun cardUiFor(chainId: String, assetId: Int): BridgeAssetCardUi { + val chain = chainRegistry.getChain(chainId) + val asset = chain.assetsById.getValue(assetId) + + return BridgeAssetCardUi( + assetIcon = assetIconProvider.getAssetIconOrFallback(asset), + chainIconUrl = chain.icon, + symbol = asset.symbol.value, + chainName = chain.displayNameWithAssetStandard() + ) + } } + +data class BridgeAssetCardUi( + val assetIcon: Icon, + val chainIconUrl: String?, + val symbol: String, + val chainName: String +) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt index 9b36f6ff..31242e4c 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt @@ -8,9 +8,11 @@ import dagger.Provides import dagger.multibindings.IntoMap import io.novafoundation.nova.common.di.viewmodel.ViewModelKey import io.novafoundation.nova.common.di.viewmodel.ViewModelModule +import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.resources.ResourceManager import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase +import io.novafoundation.nova.feature_assets.domain.WalletInteractor import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor import io.novafoundation.nova.feature_assets.domain.bridge.multisig.RealBridgeMultisigInteractor import io.novafoundation.nova.feature_assets.presentation.AssetsRouter @@ -40,9 +42,11 @@ class BridgeModule { router: AssetsRouter, resourceManager: ResourceManager, chainRegistry: ChainRegistry, + assetIconProvider: AssetIconProvider, + walletInteractor: WalletInteractor, bridgeMultisigInteractor: BridgeMultisigInteractor ): ViewModel { - return BridgeViewModel(router, resourceManager, chainRegistry, bridgeMultisigInteractor) + return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider, walletInteractor, bridgeMultisigInteractor) } @Provides diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt new file mode 100644 index 00000000..86bb8649 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/view/BridgeAssetInputView.kt @@ -0,0 +1,76 @@ +package io.novafoundation.nova.feature_assets.presentation.bridge.view + +import android.content.Context +import android.util.AttributeSet +import android.widget.EditText +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.view.isVisible +import coil.ImageLoader +import io.novafoundation.nova.common.di.FeatureUtils +import io.novafoundation.nova.common.utils.WithContextExtensions +import io.novafoundation.nova.common.utils.images.asUrlIcon +import io.novafoundation.nova.common.utils.images.setIconOrMakeGone +import io.novafoundation.nova.common.utils.inflater +import io.novafoundation.nova.common.utils.setVisible +import io.novafoundation.nova.common.view.shape.getInputBackground +import io.novafoundation.nova.common.view.shape.getInputBackgroundError +import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon +import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding +import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi + +class BridgeAssetInputView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : ConstraintLayout(context, attrs, defStyleAttr), + WithContextExtensions by WithContextExtensions(context) { + + private val binder = ViewBridgeAssetInputBinding.inflate(inflater(), this) + + val amountInput: EditText + get() = binder.bridgeAssetInputField + + private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { + FeatureUtils.getCommonApi(context).imageLoader() + } + + init { + binder.bridgeAssetInputContainer.background = context.getInputBackground() + } + + fun setCardClickListener(listener: OnClickListener) { + binder.bridgeAssetInputContainer.setOnClickListener(listener) + binder.bridgeAssetInputChevron.isVisible = true + } + + fun setEditable(editable: Boolean) { + amountInput.isFocusable = editable + amountInput.isFocusableInTouchMode = editable + amountInput.isClickable = editable + amountInput.isCursorVisible = editable + } + + fun setAmountText(text: CharSequence) { + if (amountInput.text?.toString() != text.toString()) { + amountInput.setText(text) + } + } + + fun setModel(model: BridgeAssetCardUi) { + binder.bridgeAssetInputImage.setTokenIcon(model.assetIcon, imageLoader) + binder.bridgeAssetInputToken.text = model.symbol + binder.bridgeAssetInputSubtitle.text = model.chainName + binder.bridgeAssetInputSubtitleImage.setIconOrMakeGone(model.chainIconUrl?.asUrlIcon(), imageLoader) + } + + fun setError(message: String?) { + binder.bridgeAssetInputContainer.background = if (message != null) { + context.getInputBackgroundError() + } else { + context.getInputBackground() + } + + binder.bridgeAssetInputError.text = message + binder.bridgeAssetInputError.setVisible(message != null) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt index bc7da05a..35dc6de4 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/flow/network/NetworkFlowViewModel.kt @@ -16,6 +16,7 @@ import io.novafoundation.nova.feature_assets.presentation.flow.network.model.Net import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.AmountFormatter import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.model.AmountConfig +import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.asset import kotlinx.coroutines.flow.Flow @@ -73,7 +74,7 @@ abstract class NetworkFlowViewModel( NetworkFlowRvItem( it.chain.id, it.asset.token.configuration.id, - it.chain.name, + it.chain.displayNameWithAssetStandard(), it.chain.icon, amountFormatter.formatAmountToAmountModel( amount = getAssetBalance(it).amount, diff --git a/feature-assets/src/main/res/layout/fragment_bridge.xml b/feature-assets/src/main/res/layout/fragment_bridge.xml index a9cd542f..fb953eac 100644 --- a/feature-assets/src/main/res/layout/fragment_bridge.xml +++ b/feature-assets/src/main/res/layout/fragment_bridge.xml @@ -25,212 +25,65 @@ android:orientation="vertical" android:padding="16dp"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + android:text="@string/bridge_you_send" + android:textColor="@color/text_secondary" /> - + - - + android:layout_marginTop="8dp" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:scaleType="centerInside" + android:src="@drawable/ic_flip_swap" /> - - + + - - - - - - - - - - - - - - - - + android:layout_marginTop="8dp" /> @@ -317,18 +170,14 @@ - - + + android:visibility="gone" + app:alertMode="warning" /> diff --git a/feature-assets/src/main/res/layout/item_bridge_pair_list.xml b/feature-assets/src/main/res/layout/item_bridge_pair_list.xml new file mode 100644 index 00000000..8c5f6b3b --- /dev/null +++ b/feature-assets/src/main/res/layout/item_bridge_pair_list.xml @@ -0,0 +1,29 @@ + + + + + + + + diff --git a/feature-assets/src/main/res/layout/view_bridge_asset_input.xml b/feature-assets/src/main/res/layout/view_bridge_asset_input.xml new file mode 100644 index 00000000..03472090 --- /dev/null +++ b/feature-assets/src/main/res/layout/view_bridge_asset_input.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt index 579a6bfc..3a4a1aa0 100644 --- a/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt +++ b/feature-cloud-backup-api/src/main/java/io/novafoundation/nova/feature_cloud_backup_api/domain/model/CloudBackup.kt @@ -23,7 +23,19 @@ data class CloudBackup( val ethereumPublicKey: ByteArray?, val name: String, val type: Type, - val chainAccounts: Set + val chainAccounts: Set, + // All below are nullable and defaulted so that Gson deserializing a backup written before that + // particular chain family existed - which has no such field in its JSON at all - lands on null here + // rather than failing. Solana has no derivation anywhere in this codebase yet (no native support, + // unlike Tron/Bitcoin) - its fields are reserved now purely so that adding that support later never + // needs another silent-drop-prone trip through every call site that touches this schema, the way + // Tron/Bitcoin did when they were bolted onto a schema that only knew about substrate/ethereum. + val tronAddress: ByteArray? = null, + val tronPublicKey: ByteArray? = null, + val solanaAddress: ByteArray? = null, + val solanaPublicKey: ByteArray? = null, + val bitcoinAddress: ByteArray? = null, + val bitcoinPublicKey: ByteArray? = null, ) : Identifiable { override val identifier: String = walletId @@ -72,7 +84,13 @@ data class CloudBackup( ethereumPublicKey.contentEquals(other.ethereumPublicKey) && name == other.name && type == other.type && - chainAccounts == other.chainAccounts + chainAccounts == other.chainAccounts && + tronAddress.contentEquals(other.tronAddress) && + tronPublicKey.contentEquals(other.tronPublicKey) && + solanaAddress.contentEquals(other.solanaAddress) && + solanaPublicKey.contentEquals(other.solanaPublicKey) && + bitcoinAddress.contentEquals(other.bitcoinAddress) && + bitcoinPublicKey.contentEquals(other.bitcoinPublicKey) } override fun hashCode(): Int { @@ -85,6 +103,12 @@ data class CloudBackup( result = 31 * result + name.hashCode() result = 31 * result + type.hashCode() result = 31 * result + chainAccounts.hashCode() + result = 31 * result + (tronAddress?.contentHashCode() ?: 0) + result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (solanaAddress?.contentHashCode() ?: 0) + result = 31 * result + (solanaPublicKey?.contentHashCode() ?: 0) + result = 31 * result + (bitcoinAddress?.contentHashCode() ?: 0) + result = 31 * result + (bitcoinPublicKey?.contentHashCode() ?: 0) result = 31 * result + identifier.hashCode() return result } @@ -100,6 +124,11 @@ data class CloudBackup( val substrate: SubstrateSecrets?, val ethereum: EthereumSecrets?, val chainAccounts: List, + // See the matching comment on WalletPublicInfo: tron/bitcoin are fully wired end to end, solana is a + // schema-only reservation until native derivation for it exists. + val tron: TronSecrets? = null, + val solana: SolanaSecrets? = null, + val bitcoin: BitcoinSecrets? = null, ) : Identifiable { override val identifier: String = walletId @@ -118,6 +147,9 @@ data class CloudBackup( if (substrate != other.substrate) return false if (ethereum != other.ethereum) return false if (chainAccounts != other.chainAccounts) return false + if (tron != other.tron) return false + if (solana != other.solana) return false + if (bitcoin != other.bitcoin) return false return identifier == other.identifier } @@ -127,6 +159,9 @@ data class CloudBackup( result = 31 * result + (substrate?.hashCode() ?: 0) result = 31 * result + (ethereum?.hashCode() ?: 0) result = 31 * result + chainAccounts.hashCode() + result = 31 * result + (tron?.hashCode() ?: 0) + result = 31 * result + (solana?.hashCode() ?: 0) + result = 31 * result + (bitcoin?.hashCode() ?: 0) result = 31 * result + identifier.hashCode() return result } @@ -201,6 +236,22 @@ data class CloudBackup( val derivationPath: String?, ) + data class TronSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + + // Reserved shape for when native Solana derivation is added - unused until then, see the class-level comment. + data class SolanaSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + + data class BitcoinSecrets( + val keypair: KeyPairSecrets, + val derivationPath: String?, + ) + data class KeyPairSecrets( val publicKey: ByteArray, val privateKey: ByteArray, @@ -234,5 +285,5 @@ data class CloudBackup( } fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean { - return entropy == null && substrate == null && ethereum == null && chainAccounts.isEmpty() + return entropy == null && substrate == null && ethereum == null && tron == null && bitcoin == null && chainAccounts.isEmpty() } diff --git a/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt b/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt index cd7e1901..e5b24c6f 100644 --- a/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt +++ b/feature-cloud-backup-test/src/main/java/io/novafoundation/feature_cloud_backup_test/CloudBackupBuilder.kt @@ -7,6 +7,7 @@ import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup. import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.EthereumSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.KeyPairSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.SubstrateSecrets +import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPrivateInfo.TronSecrets import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType @@ -99,6 +100,7 @@ class WalletPrivateInfoBuilder( private var substrate: SubstrateSecrets? = null private var ethereum: EthereumSecrets? = null + private var tron: TronSecrets? = null private val chainAccounts = mutableListOf() @@ -114,6 +116,10 @@ class WalletPrivateInfoBuilder( ethereum = BackupEthereumSecretsBuilder().apply(builder).build() } + fun tron(builder: BackupTronSecretsBuilder.() -> Unit) { + tron = BackupTronSecretsBuilder().apply(builder).build() + } + fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) { val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build() chainAccounts.add(element) @@ -126,6 +132,7 @@ class WalletPrivateInfoBuilder( substrate = substrate, ethereum = ethereum, chainAccounts = chainAccounts, + tron = tron, ) } } @@ -173,6 +180,25 @@ class BackupEthereumSecretsBuilder { } } +@CloudBackupBuildDsl +class BackupTronSecretsBuilder { + + private var _keypair: KeyPairSecrets? = null + private var _derivationPath: String? = null + + fun derivationPath(value: String?) { + _derivationPath = value + } + + fun keypair(keypair: KeyPairSecrets) { + _keypair = keypair + } + + fun build(): TronSecrets { + return TronSecrets(requireNotNull(_keypair), _derivationPath) + } +} + @CloudBackupBuildDsl class BackupChainAccountSecretsBuilder(private val accountId: AccountId) { @@ -223,6 +249,8 @@ class WalletPublicInfoBuilder( private var _substrateAccountId: ByteArray? = null private var _ethereumPublicKey: ByteArray? = null private var _ethereumAddress: ByteArray? = null + private var _tronPublicKey: ByteArray? = null + private var _tronAddress: ByteArray? = null private var _name: String = "" private var _isSelected: Boolean = false private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS @@ -252,6 +280,14 @@ class WalletPublicInfoBuilder( _ethereumAddress = value } + fun tronPublicKey(value: ByteArray?) { + _tronPublicKey = value + } + + fun tronAddress(value: ByteArray?) { + _tronAddress = value + } + fun name(value: String) { _name = value } @@ -274,7 +310,9 @@ class WalletPublicInfoBuilder( ethereumPublicKey = _ethereumPublicKey, name = _name, type = _type, - chainAccounts = chainAccounts.toSet() + chainAccounts = chainAccounts.toSet(), + tronAddress = _tronAddress, + tronPublicKey = _tronPublicKey, ) } } diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt index 78260131..2c60d1e1 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/NetworkManagementChainInteractor.kt @@ -9,6 +9,7 @@ import io.novafoundation.nova.runtime.ext.isCustomNetwork import io.novafoundation.nova.runtime.ext.isDisabled import io.novafoundation.nova.runtime.ext.isEnabled import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull +import io.novafoundation.nova.runtime.ext.httpNodes import io.novafoundation.nova.runtime.ext.wssNodes import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain @@ -132,7 +133,13 @@ class RealNetworkManagementChainInteractor( } private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow> { - return chain.nodes.wssNodes().map { + // wssNodes() alone leaves an HTTPS-only chain (Tron today - no wss endpoint at all) with an empty list + // here, which silently renders as "nothing to show" rather than a real health state - fall back to the + // http nodes only when there are no wss ones, since a chain that genuinely has wss nodes should still + // prefer testing those. + val nodesToCheck = chain.nodes.wssNodes().ifEmpty { chain.nodes.httpNodes() } + + return nodesToCheck.map { nodeHealthState(chain, it, coroutineScope) }.combine() } diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt index 2b99a9c1..f74d9314 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/domain/utils/CustomChainFactory.kt @@ -125,6 +125,7 @@ class CustomChainFactory( types = prefilledChain?.types, isEthereumBased = isEthereumBased, isTronBased = false, + isBitcoinBased = false, isTestNet = prefilledChain?.isTestNet.orFalse(), source = Chain.Source.CUSTOM, hasSubstrateRuntime = hasSubstrateRuntime, diff --git a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt index e51b8680..60d44e3f 100644 --- a/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt +++ b/feature-settings-impl/src/main/java/io/novafoundation/nova/feature_settings_impl/presentation/networkManagement/networkList/common/NetworkListAdapterItemFactory.kt @@ -61,6 +61,12 @@ class RealNetworkListAdapterItemFactory( private fun getConnectingState(network: NetworkState): ConnectionStateModel? { if (network.chain.isDisabled) return null + // Tron chains never get a ChainConnection/SocketService (TronGrid is a plain REST API, not + // WSS JSON-RPC - see ChainRegistry.registerConnection()), so connectionState is always the + // Disconnected default here, never Connected. Treat that as "nothing to show" rather than + // falling into the generic "Connecting" state below, which would otherwise spin forever. + if (network.chain.isTronBased) return null + return when (network.connectionState) { is SocketStateMachine.State.Connected -> null diff --git a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt index b4ec88b6..9de4217f 100644 --- a/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt +++ b/feature-wallet-api/src/main/java/io/novafoundation/nova/feature_wallet_api/data/network/blockhain/assets/tranfers/TransactionExecution.kt @@ -8,4 +8,8 @@ sealed interface TransactionExecution { class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution + + class Bitcoin(val hash: String) : TransactionExecution + + class Tron(val hash: String) : TransactionExecution } diff --git a/feature-wallet-impl/build.gradle b/feature-wallet-impl/build.gradle index efa0ef15..df610b59 100644 --- a/feature-wallet-impl/build.gradle +++ b/feature-wallet-impl/build.gradle @@ -27,6 +27,17 @@ android { } namespace 'io.novafoundation.nova.feature_wallet_impl' + testOptions { + unitTests.all { + testLogging { + events "failed" + exceptionFormat "full" + showCauses true + showStackTraces true + } + } + } + buildFeatures { viewBinding true } @@ -71,6 +82,7 @@ dependencies { testImplementation jUnitDep testImplementation mockitoDep + testImplementation project(':test-shared') implementation substrateSdkDep diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt new file mode 100644 index 00000000..f7f79b2f --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/BitcoinApi.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.delay +import retrofit2.HttpException +import java.math.BigInteger + +/** A single unspent output, as needed to select inputs and build a transaction. */ +data class BitcoinUtxo( + val txid: String, + val vout: Int, + val valueSat: Long, + val confirmed: Boolean, +) + +interface BitcoinApi { + + suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance + + suspend fun fetchUtxos(baseUrl: String, address: String): List + + /** sat/vB, mempool.space's ~30-minute-confirmation estimate - a balanced default, neither cheapest nor fastest. */ + suspend fun fetchRecommendedFeeRateSatPerVbyte(baseUrl: String): Long + + /** @param rawTxHex fully signed raw transaction, hex-encoded. @return the broadcast transaction's txid. */ + suspend fun broadcastTransaction(baseUrl: String, rawTxHex: String): String +} + +class RealBitcoinApi( + private val retrofitApi: RetrofitBitcoinApi +) : BitcoinApi { + + override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance { + val stats = retryOn429 { retrofitApi.getAddress(addressUrl(baseUrl, address)) }.chainStats + ?: return BigInteger.ZERO + + val funded = stats.fundedTxoSum ?: 0L + val spent = stats.spentTxoSum ?: 0L + + return (funded - spent).toBigInteger().coerceAtLeast(BigInteger.ZERO) + } + + override suspend fun fetchUtxos(baseUrl: String, address: String): List { + val response = retryOn429 { retrofitApi.getUtxos("${baseUrl.trimEnd('/')}/address/$address/utxo") } + + return response.map { utxo -> + BitcoinUtxo( + txid = utxo.txid, + vout = utxo.vout, + valueSat = utxo.value, + confirmed = utxo.status?.confirmed == true + ) + } + } + + override suspend fun fetchRecommendedFeeRateSatPerVbyte(baseUrl: String): Long { + val fees = retryOn429 { retrofitApi.getRecommendedFees("${baseUrl.trimEnd('/')}/v1/fees/recommended") } + + return fees.halfHourFee ?: fees.hourFee ?: fees.fastestFee ?: 1L + } + + override suspend fun broadcastTransaction(baseUrl: String, rawTxHex: String): String { + return retryOn429 { retrofitApi.broadcastTransaction("${baseUrl.trimEnd('/')}/tx", rawTxHex) }.trim() + } + + private fun addressUrl(baseUrl: String, address: String): String { + return "${baseUrl.trimEnd('/')}/address/$address" + } + + private suspend fun retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T { + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: HttpException) { + if (e.code() != 429) throw e + delay(1_000L * (attempt + 1)) + } + } + return block() + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt new file mode 100644 index 00000000..35853a28 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/RetrofitBitcoinApi.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin + +import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinAddressResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinFeeEstimateResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model.BitcoinUtxoResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Url + +interface RetrofitBitcoinApi { + + @GET + @Headers(UserAgent.NOVA) + suspend fun getAddress(@Url url: String): BitcoinAddressResponse + + @GET + @Headers(UserAgent.NOVA) + suspend fun getUtxos(@Url url: String): List + + @GET + @Headers(UserAgent.NOVA) + suspend fun getRecommendedFees(@Url url: String): BitcoinFeeEstimateResponse + + @POST + @Headers(UserAgent.NOVA) + suspend fun broadcastTransaction(@Url url: String, @Body rawTxHex: String): String +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt new file mode 100644 index 00000000..4ced6159 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinAddressResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model + +import com.google.gson.annotations.SerializedName + +/** + * Response shape of mempool.space's `GET /address/{address}`. + * + * `chainStats` reflects only confirmed on-chain activity; `mempoolStats` (unconfirmed) is deliberately not + * used for balance - matching the exchange's proven 2-confirmation-required posture for this same API. + */ +class BitcoinAddressResponse( + @SerializedName("chain_stats") + val chainStats: BitcoinAddressStats? = null, +) + +class BitcoinAddressStats( + @SerializedName("funded_txo_sum") + val fundedTxoSum: Long? = null, + + @SerializedName("spent_txo_sum") + val spentTxoSum: Long? = null, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt new file mode 100644 index 00000000..0468e700 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/model/BitcoinUtxoResponse.kt @@ -0,0 +1,22 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.model + +/** Response shape of mempool.space's `GET /address/{address}/utxo`. */ +class BitcoinUtxoResponse( + val txid: String, + val vout: Int, + val value: Long, + val status: BitcoinUtxoStatus? = null, +) + +class BitcoinUtxoStatus( + val confirmed: Boolean = false, +) + +/** Response shape of mempool.space's `GET /v1/fees/recommended`, all values in sat/vB. */ +class BitcoinFeeEstimateResponse( + val fastestFee: Long? = null, + val halfHourFee: Long? = null, + val hourFee: Long? = null, + val economyFee: Long? = null, + val minimumFee: Long? = null, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt new file mode 100644 index 00000000..f18decb0 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/BitcoinTransactionService.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import java.math.BigInteger + +/** + * Mirrors [io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService]'s + * shape (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Bitcoin. See + * `RealBitcoinTransactionService` for the UTXO-model construction/signing details, which differ substantially + * from Tron's account-model approach. + * + * [recipientAddress] is the raw destination address string, not an [io.novasama.substrate_sdk_android.runtime.AccountId] - + * unlike every other chain's transfer path, a Bitcoin destination can legitimately be a P2SH/P2PKH address (a + * real exchange withdrawal address was confirmed to be P2SH-only, with no way to request native SegWit instead), + * which needs its own distinct scriptPubKey shape. Converting to a generic 20-byte AccountId this early would + * discard which shape it needs to be - see `BitcoinDestinationAddress.kt`. + */ +interface BitcoinTransactionService { + + suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountSat: BigInteger): Fee + + suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipientAddress: String, + presetFee: Fee?, + amountSat: BigInteger + ): Result + + suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipientAddress: String, + presetFee: Fee?, + amountSat: BigInteger + ): Result +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt new file mode 100644 index 00000000..0ddb5829 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/bitcoin/transaction/RealBitcoinTransactionService.kt @@ -0,0 +1,191 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction + +import io.novafoundation.nova.common.utils.BitcoinInput +import io.novafoundation.nova.common.utils.BitcoinOutput +import io.novafoundation.nova.common.utils.BitcoinTransaction +import io.novafoundation.nova.common.utils.DerSignature +import io.novafoundation.nova.common.utils.decodeBitcoinDestination +import io.novafoundation.nova.common.utils.toBitcoinAddress +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.common.utils.toP2wpkhScriptPubKey +import io.novafoundation.nova.common.utils.toScriptPubKey +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.BitcoinFee +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinUtxo +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.ext.requireMempoolSpaceBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import java.math.BigInteger + +/** mempool.space's own dust threshold for a P2WPKH output - see `RealBitcoinTransactionService`'s doc for how this is used. */ +private const val DUST_LIMIT_SAT = 546L + +private const val TX_VERSION = 2 +private const val LOCKTIME = 0 + +private data class UtxoSelection( + val selectedUtxos: List, + val changeSat: Long, + val feeSat: Long, +) + +/** + * Builds, signs and broadcasts Bitcoin transactions from this wallet's own native SegWit (P2WPKH) address - the + * recipient output, however, can be P2WPKH, P2SH or P2PKH (see `BitcoinDestinationAddress.kt`; a real exchange + * withdrawal address was confirmed live to be P2SH-only). UTXO selection and raw construction happen entirely + * client-side (no server-assisted "createtransaction" the way TronGrid offers - mempool.space only exposes + * UTXOs/fee-rate/broadcast, not transaction construction), using the hand-rolled protocol primitives in + * [BitcoinTransaction]/[DerSignature]/`BitcoinAddress.kt` verified against BIP143/BIP173. + * + * ## UTXO selection + * Greedy largest-first over CONFIRMED UTXOs only (unconfirmed outputs are skipped - spending them risks the + * whole transaction unraveling if the parent is replaced/dropped), matching the exchange's proven approach. + * If the leftover after amount+fee would be below Bitcoin's dust threshold ([DUST_LIMIT_SAT]), no change output + * is created and the leftover is folded into the fee instead of producing an uneconomical-to-spend output. + * + * ## Fee + * `feeRate (sat/vB, from mempool.space's ~30-minute estimate) * estimated vsize` (the same + * `inputs*68 + outputs*31 + 11` heuristic already proven in production by `pezkuwi-exchange/wallet-service`). + * + * ## Signing + * Each input gets its own BIP143 sighash (unlike Tron/Ethereum's single whole-transaction hash) - computed by + * [BitcoinTransaction.bip143Sighash], signed via the same [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw] + * primitive Tron/Ethereum already use (`skipMessageHashing = true`, since the sighash is already the final + * digest to sign), then DER-encoded with low-S normalization via [DerSignature] - Bitcoin's one departure from + * Tron/Ethereum's fixed compact r+s+v format. No new signing call path was added. + * + * ## Broadcast + * `POST /tx` with the fully serialized signed transaction, hex-encoded, as a raw text body. + */ +class RealBitcoinTransactionService( + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val bitcoinApi: BitcoinApi, +) : BitcoinTransactionService { + + override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountSat: BigInteger): Fee { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireMempoolSpaceBaseUrl() + + val utxos = confirmedUtxos(baseUrl, ownerAccountId) + val feeRate = bitcoinApi.fetchRecommendedFeeRateSatPerVbyte(baseUrl) + val selection = selectUtxos(utxos, amountSat.toLong().coerceAtLeast(0), feeRate) + + val feeSat = selection?.feeSat ?: 0L + + return BitcoinFee(feeSat.toBigInteger(), SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset) + } + + override suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipientAddress: String, + presetFee: Fee?, + amountSat: BigInteger + ): Result = runCatching { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val ownerPublicKey = requireNotNull(submittingMetaAccount.bitcoinPublicKey) { + "No bitcoin public key found for meta account ${submittingMetaAccount.id}" + } + val baseUrl = chain.requireMempoolSpaceBaseUrl() + val amountSatLong = amountSat.toLong() + val recipientScriptPubKey = recipientAddress.decodeBitcoinDestination().toScriptPubKey() + + val utxos = confirmedUtxos(baseUrl, ownerAccountId) + val feeRate = bitcoinApi.fetchRecommendedFeeRateSatPerVbyte(baseUrl) + val selection = selectUtxos(utxos, amountSatLong, feeRate) + ?: error("Insufficient confirmed UTXOs to cover amount + fee") + + val inputs = selection.selectedUtxos.map { utxo -> + BitcoinInput(txid = utxo.txid.fromHex(), vout = utxo.vout, valueSat = utxo.valueSat) + } + + val outputs = buildList { + add(BitcoinOutput(valueSat = amountSatLong, scriptPubKey = recipientScriptPubKey)) + if (selection.changeSat > 0) { + add(BitcoinOutput(valueSat = selection.changeSat, scriptPubKey = ownerAccountId.toP2wpkhScriptPubKey())) + } + } + + val signer = signerProvider.rootSignerFor(submittingMetaAccount) + + val witnesses = inputs.indices.map { index -> + val sighash = BitcoinTransaction.bip143Sighash(TX_VERSION, inputs, outputs, index, ownerAccountId, LOCKTIME) + val signedRaw = signer.signRaw(SignerPayloadRaw(message = sighash, accountId = ownerAccountId, skipMessageHashing = true)) + val signature = signedRaw.toEcdsaSignatureData() + + DerSignature.encode(signature.r, signature.s) to ownerPublicKey + } + + val signedTxBytes = BitcoinTransaction.serializeSigned(TX_VERSION, inputs, outputs, witnesses, LOCKTIME) + val txid = bitcoinApi.broadcastTransaction(baseUrl, signedTxBytes.toHexString(withPrefix = false)) + + ExtrinsicSubmission( + hash = txid, + submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId), + callExecutionType = CallExecutionType.IMMEDIATE, + submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE) + ) + } + + override suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipientAddress: String, + presetFee: Fee?, + amountSat: BigInteger + ): Result { + // Broadcast acceptance is already a strong signal (same posture as Tron/EVM) - this sits outside the + // primary send flow's critical path, which only ever calls transact(). + return transact(chain, origin, recipientAddress, presetFee, amountSat).map { TransactionExecution.Bitcoin(it.hash) } + } + + private suspend fun confirmedUtxos(baseUrl: String, ownerAccountId: AccountId): List { + val address = ownerAccountId.toBitcoinAddress() + + return bitcoinApi.fetchUtxos(baseUrl, address).filter { it.confirmed } + } + + private fun selectUtxos(utxos: List, amountSat: Long, feeRateSatPerVbyte: Long): UtxoSelection? { + val sorted = utxos.sortedByDescending { it.valueSat } + val selected = mutableListOf() + var total = 0L + + for (utxo in sorted) { + selected += utxo + total += utxo.valueSat + + val vsizeWithChange = BitcoinTransaction.estimateVsize(selected.size, outputCount = 2) + val feeWithChange = feeRateSatPerVbyte * vsizeWithChange + if (total < amountSat + feeWithChange) continue + + val changeSat = total - amountSat - feeWithChange + + return if (changeSat >= DUST_LIMIT_SAT) { + UtxoSelection(selected.toList(), changeSat = changeSat, feeSat = feeWithChange) + } else { + // Folding a sub-dust leftover into the fee instead of creating an uneconomical-to-spend output. + UtxoSelection(selected.toList(), changeSat = 0, feeSat = total - amountSat) + } + } + + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt index 9b505934..627aeafe 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/TypeBasedAssetSourceRegistry.kt @@ -31,6 +31,7 @@ class TypeBasedAssetSourceRegistry( private val equilibriumAssetSource: Lazy, private val tronNativeSource: Lazy, private val trc20Source: Lazy, + private val bitcoinNativeSource: Lazy, private val unsupportedBalanceSource: AssetSource, private val nativeAssetEventDetector: NativeAssetEventDetector, @@ -49,6 +50,7 @@ class TypeBasedAssetSourceRegistry( is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get() is Chain.Asset.Type.TronNative -> tronNativeSource.get() is Chain.Asset.Type.Trc20 -> trc20Source.get() + is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get() Chain.Asset.Type.Unsupported -> unsupportedBalanceSource } } @@ -63,6 +65,7 @@ class TypeBasedAssetSourceRegistry( add(equilibriumAssetSource.get()) add(tronNativeSource.get()) add(trc20Source.get()) + add(bitcoinNativeSource.get()) } } @@ -72,6 +75,7 @@ class TypeBasedAssetSourceRegistry( Chain.Asset.Type.EvmNative, is Chain.Asset.Type.TronNative, is Chain.Asset.Type.Trc20, + is Chain.Asset.Type.BitcoinNative, Chain.Asset.Type.Unsupported -> UnsupportedEventDetector() diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt new file mode 100644 index 00000000..3634d17b --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinBalancePolling.kt @@ -0,0 +1,30 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative + +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +private const val BITCOIN_BALANCE_POLLING_INTERVAL_MS = 30_000L + +/** + * mempool.space is a plain REST API with no push/subscription mechanism, same as TronGrid - see + * `TronBalancePolling.pollingBalanceFlow`'s doc for the full rationale this mirrors. + */ +internal fun pollingBalanceFlow( + intervalMs: Long = BITCOIN_BALANCE_POLLING_INTERVAL_MS, + fetch: suspend () -> Balance +): Flow = flow { + var lastEmitted: Balance? = null + + while (true) { + val latest = fetch() + + if (latest != lastEmitted) { + lastEmitted = latest + emit(latest) + } + + delay(intervalMs) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt new file mode 100644 index 00000000..508e2968 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/bitcoinNative/BitcoinNativeAssetBalance.kt @@ -0,0 +1,83 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative + +import io.novafoundation.nova.core.updater.SharedRequestsBuilder +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi +import io.novafoundation.nova.runtime.ext.addressOf +import io.novafoundation.nova.runtime.ext.requireMempoolSpaceBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import java.math.BigInteger + +/** + * Native BTC balance on a Bitcoin-based chain. Read-only (Phase 4): fetches via a mempool.space-style REST API + * and polls for updates, since that API has no push/subscription mechanism. No transfer/history support here - + * see `BitcoinAssetsModule` for how this is paired with `UnsupportedAssetTransfers`/`UnsupportedAssetHistory`. + */ +class BitcoinNativeAssetBalance( + private val assetCache: AssetCache, + private val bitcoinApi: BitcoinApi, +) : AssetBalance { + + override suspend fun startSyncingBalanceLocks( + metaAccount: MetaAccount, + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow<*> { + // Bitcoin native balance does not support locks + return emptyFlow() + } + + override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger { + // Bitcoin does not have an existential deposit concept (UTXO dust limit is enforced at the transfer level, not here) + return BigInteger.ZERO + } + + override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { + val balance = bitcoinApi.fetchNativeBalance(chain.requireMempoolSpaceBaseUrl(), chain.addressOf(accountId)) + + return ChainAssetBalance.fromFree(chainAsset, balance) + } + + override suspend fun subscribeAccountBalanceUpdatePoint( + chain: Chain, + chainAsset: Chain.Asset, + accountId: AccountId, + ): Flow { + // Not on the critical sync path (mirrors TronNativeAssetBalance/EvmNativeAssetBalance) - out of scope for Phase 4. + TODO("Not yet implemented") + } + + override suspend fun startSyncingBalance( + chain: Chain, + chainAsset: Chain.Asset, + metaAccount: MetaAccount, + accountId: AccountId, + subscriptionBuilder: SharedRequestsBuilder + ): Flow { + val baseUrl = chain.requireMempoolSpaceBaseUrl() + val address = chain.addressOf(accountId) + + return pollingBalanceFlow { bitcoinApi.fetchNativeBalance(baseUrl, address) } + .map { balance -> + assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) + + BalanceSyncUpdate.NoCause + } + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt index 8735685c..15860fbe 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/statemine/StatemineAssetBalance.kt @@ -131,6 +131,10 @@ class StatemineAssetBalance( ) } + // Deliberately lets setup/subscription failures propagate as exceptions rather than swallowing them into + // emptyFlow()/BalanceSyncUpdate.NoCause: the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole + // call in a single retryWhen boundary that exists specifically to catch and retry failures like these. If + // we swallow here, that boundary never triggers - the asset silently stops syncing instead of retrying. override suspend fun startSyncingBalance( chain: Chain, chainAsset: Chain.Asset, @@ -138,40 +142,32 @@ class StatemineAssetBalance( accountId: AccountId, subscriptionBuilder: SharedRequestsBuilder ): Flow { - return runCatching { - val runtime = chainRegistry.getRuntime(chain.id) + val runtime = chainRegistry.getRuntime(chain.id) - val statemineType = chainAsset.requireStatemine() - val encodableAssetId = statemineType.prepareIdForEncoding(runtime) + val statemineType = chainAsset.requireStatemine() + val encodableAssetId = statemineType.prepareIdForEncoding(runtime) - val module = runtime.metadata.statemineModule(statemineType) + val module = runtime.metadata.statemineModule(statemineType) - val assetAccountStorage = module.storage("Account") - val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId) + val assetAccountStorage = module.storage("Account") + val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId) - val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder) + val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder) - combine( - subscriptionBuilder.subscribe(assetAccountKey), - assetDetailsFlow.map { it.status.transfersFrozen } - ) { balanceStorageChange, isAssetFrozen -> - val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime) - val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded) + return combine( + subscriptionBuilder.subscribe(assetAccountKey), + assetDetailsFlow.map { it.status.transfersFrozen } + ) { balanceStorageChange, isAssetFrozen -> + val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime) + val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded) - val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount) + val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount) - if (assetChanged) { - BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block) - } else { - BalanceSyncUpdate.NoCause - } - }.catch { error -> - Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") - emit(BalanceSyncUpdate.NoCause) + if (assetChanged) { + BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block) + } else { + BalanceSyncUpdate.NoCause } - }.getOrElse { error -> - Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}") - emptyFlow() } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt index d3120b3b..2826a3d1 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/trc20/Trc20AssetBalance.kt @@ -10,7 +10,6 @@ import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.b import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.pollingBalanceFlow import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi -import io.novafoundation.nova.runtime.ext.addressOf import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl import io.novafoundation.nova.runtime.ext.requireTrc20 import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain @@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map import java.math.BigInteger /** - * TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via TronGrid's REST API (the same - * `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for - * updates. No transfer/history support here - see `TronAssetsModule`. + * TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract + * call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native + * TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`. */ class Trc20AssetBalance( private val assetCache: AssetCache, @@ -52,9 +51,8 @@ class Trc20AssetBalance( override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance { val contractAddress = chainAsset.requireTrc20().contractAddress - val address = chain.addressOf(accountId) - val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), address, contractAddress) + val balance = tronGridApi.fetchTrc20Balance(chain.requireTronGridBaseUrl(), accountId, contractAddress) return ChainAssetBalance.fromFree(chainAsset, balance) } @@ -77,9 +75,8 @@ class Trc20AssetBalance( ): Flow { val contractAddress = chainAsset.requireTrc20().contractAddress val baseUrl = chain.requireTronGridBaseUrl() - val address = chain.addressOf(accountId) - return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, address, contractAddress) } + return pollingBalanceFlow { tronGridApi.fetchTrc20Balance(baseUrl, accountId, contractAddress) } .map { balance -> assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt index e1b61ddd..81f5a5d9 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/tronNative/TronBalancePolling.kt @@ -1,17 +1,26 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative +import android.util.Log import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow private const val TRON_BALANCE_POLLING_INTERVAL_MS = 30_000L +private const val LOG_TAG = "TronBalancePolling" /** * TronGrid is a plain REST API with no push/subscription mechanism (unlike Ethereum nodes, which expose a * `newHeads`-style websocket subscription EVM balance sync piggybacks on). So balance updates for Tron-based * assets are polled instead of pushed: fetch immediately, then re-fetch on an interval, only emitting when the * balance actually changed. + * + * A failed fetch() must not escape this loop: any uncaught exception here cancels the whole flow permanently + * (the collector - FullSyncPaymentUpdater - only logs and gives up, it doesn't resubscribe), which meant a + * single transient failure (DNS hiccup, timeout, momentary connectivity loss during app cold start) could + * silently and permanently blackhole a Tron asset - no balance write ever happens, so it never even gets a row + * in the local DB and disappears from every UI surface with no visible error. Swallow and retry next interval + * instead. */ internal fun pollingBalanceFlow( intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS, @@ -20,9 +29,11 @@ internal fun pollingBalanceFlow( var lastEmitted: Balance? = null while (true) { - val latest = fetch() + val latest = runCatching { fetch() } + .onFailure { Log.e(LOG_TAG, "Tron balance fetch failed, will retry in ${intervalMs}ms", it) } + .getOrNull() - if (latest != lastEmitted) { + if (latest != null && latest != lastEmitted) { lastEmitted = latest emit(latest) } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt index f5f14bb4..c394ebdb 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/balances/utility/NativeAssetBalance.kt @@ -151,6 +151,17 @@ class NativeAssetBalance( } } + // Setup/subscription failures are allowed to propagate rather than being swallowed into emptyFlow()/NoCause: + // the caller, FullSyncPaymentUpdater.syncAsset(), wraps this whole call in a single retryWhen boundary meant + // to catch and retry exactly these failures. Swallowing here would make that retry boundary never trigger. + // + // NOTE (2026-07-09): a prior attempt switched this to the typed remoteStorage.subscribe { metadata.system + // .account... } DSL, on the theory that it would fix HEZ silently never syncing on Pezkuwi Asset Hub (see + // git history). That attempt made things categorically worse - all assets across all 3 Pezkuwi chains + // stopped syncing, with logs showing what looked like cross-chain key contamination on the shared + // connection. Reverted back to the raw subscriptionBuilder.subscribe(key) form here, which is not broken + // for any OTHER native asset on any OTHER chain - only Pezkuwi Asset Hub's HEZ specifically. That narrower + // bug is still open; do not re-attempt the DSL swap without first understanding why it caused contamination. override suspend fun startSyncingBalance( chain: Chain, chainAsset: Chain.Asset, @@ -160,13 +171,7 @@ class NativeAssetBalance( ): Flow { val runtime = chainRegistry.getRuntime(chain.id) - val key = try { - runtime.metadata.system().storage("Account").storageKey(runtime, accountId) - } catch (e: Exception) { - Log.e(LOG_TAG, "Failed to construct account storage key: ${e.message} in ${chain.name}") - - return emptyFlow() - } + val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId) return subscriptionBuilder.subscribe(key) .map { change -> @@ -179,10 +184,6 @@ class NativeAssetBalance( BalanceSyncUpdate.NoCause } } - .catch { error -> - Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}") - emit(BalanceSyncUpdate.NoCause) - } } private fun bindBalanceHolds(dynamicInstance: Any?): List? { diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt new file mode 100644 index 00000000..46a54f80 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/bitcoinNative/BitcoinNativeAssetTransfers.kt @@ -0,0 +1,86 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.bitcoinNative + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.BitcoinTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +/** + * Native BTC transfer. No RBF fee-bumping or replace-by-fee UI is implemented here (out of scope for this + * send-only phase) - [BitcoinTransactionService] signals RBF (BIP125) on every input regardless, so a stuck + * transaction remains bumpable from a compatible wallet even without in-app support. + */ +class BitcoinNativeAssetTransfers( + private val bitcoinTransactionService: BitcoinTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return bitcoinTransactionService.calculateFee( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipientAddress = transfer.recipient, + amountSat = transfer.amountInPlanks + ) + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return bitcoinTransactionService.transact( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipientAddress = transfer.recipient, + presetFee = transfer.fee.submissionFee, + amountSat = transfer.amountInPlanks + ) + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return bitcoinTransactionService.transactAndAwaitExecution( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipientAddress = transfer.recipient, + presetFee = transfer.fee.submissionFee, + amountSat = transfer.amountInPlanks + ) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt new file mode 100644 index 00000000..7fdc9929 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/trc20/Trc20AssetTransfers.kt @@ -0,0 +1,99 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20 + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionIntent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.ext.requireTrc20 +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +/** + * TRC-20 token transfer (e.g. USDT-TRC20). The fee is always denominated in native TRX, never in the TRC-20 + * token being sent - same pattern as an ERC-20 transfer's fee being paid in ETH, not the ERC-20 token (compare + * [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.evmErc20.EvmErc20AssetTransfers]). + * This falls out for free from [TronTransactionService.calculateFee] always returning a [io.novafoundation.nova.feature_account_api.data.model.TronFee] + * denominated in `chain.commissionAsset` (native TRX), combined with the fully-generic + * [sufficientTransferableBalanceToPayOriginFee] validation checking that commission asset's balance regardless + * of which asset is actually being transferred. + */ +class Trc20AssetTransfers( + private val tronTransactionService: TronTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return tronTransactionService.calculateFee( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + intent = transfer.intoTrc20Intent() + ) + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transact( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = transfer.intoTrc20Intent() + ) + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transactAndAwaitExecution( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = transfer.intoTrc20Intent() + ) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } + + private fun AssetTransfer.intoTrc20Intent(): TronTransactionIntent.Trc20Transfer { + val trc20 = originChainAsset.requireTrc20() + + return TronTransactionIntent.Trc20Transfer(trc20.contractAddress, amountInPlanks) + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt new file mode 100644 index 00000000..3618e7cd --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/tronNative/TronNativeAssetTransfers.kt @@ -0,0 +1,90 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative + +import io.novafoundation.nova.common.validation.ValidationSystem +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionIntent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService +import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer +import io.novafoundation.nova.runtime.ext.accountIdOrDefault +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall +import kotlinx.coroutines.CoroutineScope + +/** + * Native TRX transfer. No Energy/Bandwidth staking or rental is implemented (out of scope for this send-only + * phase) - Tron's default protocol behavior (automatically burning TRX when free Bandwidth is insufficient) is + * all that's needed; [TronTransactionService] estimates that burn and reports it as [Fee], and the generic + * [sufficientTransferableBalanceToPayOriginFee] validation (same one EVM native/ERC-20 transfers already reuse) + * blocks the send if the TRX balance can't cover it. + */ +class TronNativeAssetTransfers( + private val tronTransactionService: TronTransactionService, + private val assetSourceRegistry: AssetSourceRegistry, +) : AssetTransfers { + + override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem { + validAddress() + recipientIsNotSystemAccount() + + positiveAmount() + + sufficientBalanceInUsedAsset() + sufficientTransferableBalanceToPayOriginFee() + + recipientCanAcceptTransfer(assetSourceRegistry) + + checkForFeeChanges(assetSourceRegistry, coroutineScope) + } + + override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee { + return tronTransactionService.calculateFee( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + intent = TronTransactionIntent.Native(transfer.amountInPlanks) + ) + } + + override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transact( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = TronTransactionIntent.Native(transfer.amountInPlanks) + ) + } + + override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result { + return tronTransactionService.transactAndAwaitExecution( + chain = transfer.originChain, + origin = transfer.sender.intoOrigin(), + recipient = transfer.originChain.accountIdOrDefault(transfer.recipient), + presetFee = transfer.fee.submissionFee, + intent = TronTransactionIntent.Native(transfer.amountInPlanks) + ) + } + + override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean { + return true + } + + override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? { + return null + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt index 5fc78510..e886ebdf 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/blockchain/assets/transfers/validations/Common.kt @@ -121,6 +121,7 @@ private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveA is Type.Statemine -> WillRemoveAccount.WillTransferDust(amount) is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust + Type.BitcoinNative -> WillRemoveAccount.WillBurnDust is Type.Equilibrium -> WillRemoveAccount.WillBurnDust Type.Unsupported -> throw IllegalArgumentException("Unsupported") } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt index 887cd961..c1616e91 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/RetrofitTronGridApi.kt @@ -1,14 +1,56 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron import io.novafoundation.nova.common.data.network.UserAgent +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronChainParametersResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.runtime.BuildConfig +import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.Headers +import retrofit2.http.POST import retrofit2.http.Url +// TronGrid's anonymous rate limit is aggressive enough to surface under normal, human-paced app usage (see +// RealTronGridApi.retryOn429's doc comment) - a free API key from trongrid.io raises it substantially. Sent on +// every request rather than only when a 429 is hit, since the point is to avoid needing the retry in the first +// place, not just to have a fallback. +private const val TRON_API_KEY_HEADER = "TRON-PRO-API-KEY: " + BuildConfig.TRONGRID_API_KEY + interface RetrofitTronGridApi { @GET - @Headers(UserAgent.NOVA) + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) suspend fun getAccount(@Url url: String): TronAccountResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun createTransaction(@Url url: String, @Body body: TronCreateTransactionRequest): TronUnsignedTransactionResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun triggerConstantContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun triggerSmartContract(@Url url: String, @Body body: TronTriggerContractRequest): TronTriggerContractResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun broadcastTransaction(@Url url: String, @Body body: TronBroadcastRequest): TronBroadcastResponse + + @GET + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun getChainParameters(@Url url: String): TronChainParametersResponse + + @POST + @Headers(UserAgent.NOVA, TRON_API_KEY_HEADER) + suspend fun getAccountResource(@Url url: String, @Body body: TronAddressRequest): TronAccountResourceResponse } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt index 22853ca7..f0a2f234 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/TronGridApi.kt @@ -1,39 +1,263 @@ package io.novafoundation.nova.feature_wallet_impl.data.network.tron +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToHexAddress import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAddressRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronBroadcastResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronCreateTransactionRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractRequest +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronTriggerContractResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.Trc20TransferAbi +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.runtime.AccountId +import kotlinx.coroutines.delay +import retrofit2.HttpException import java.math.BigInteger +/** + * Thrown whenever TronGrid reports a failure via an HTTP-200 body (rather than an HTTP error status), which is + * how most `/wallet/` endpoints signal validation/execution failures, e.g. + * `{"Error": "... no OwnerAccount."}` from `createtransaction`, or + * `{"code": "CONTRACT_VALIDATE_ERROR", "message": ""}` from `broadcasttransaction`. + */ +class TronApiException(message: String) : Exception(message) + interface TronGridApi { suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance - suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance + /** + * Reads via an on-chain `balanceOf(address)` call (`triggerconstantcontract`), NOT `/v1/accounts` - a + * TRC-20 balance lives in the token contract's own storage, not in the holder's Account object, so an + * address that has only ever received TRC-20 tokens (never native TRX, never otherwise "activated") has no + * Account object at all and `/v1/accounts` returns empty for it regardless of its real token balance. + * Confirmed live: a wallet holding exactly 5 USDT-TRC20 and zero TRX/activation history returned `data: []` + * from `/v1/accounts` while `balanceOf` correctly returned 5000000. + */ + suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance + + /** + * Builds an unsigned native TRX transfer via `POST /wallet/createtransaction`. + * [ownerHexAddress]/[toHexAddress] must be in hex form (`41`-prefixed), matching `visible: false`. + * + * Note: TronGrid refuses to build this for an owner account that has never been activated on-chain + * (confirmed live: returns `{"Error": "... no OwnerAccount."}`) - unlike [triggerSmartContract], which + * happily builds a transaction for an unfunded/unactivated owner. + */ + suspend fun createNativeTransfer(baseUrl: String, ownerHexAddress: String, toHexAddress: String, amountSun: BigInteger): TronUnsignedTransactionResponse + + /** + * Read-only dry run via `POST /wallet/triggerconstantcontract` - does not require the owner account to hold + * any TRX and does not touch chain state. Used to estimate the `energy_used` an actual TRC-20 call would + * cost (see [TronTriggerContractResponse.energyUsed]). + */ + suspend fun triggerConstantContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String + ): TronTriggerContractResponse + + /** + * Builds an unsigned TRC-20 contract call via `POST /wallet/triggersmartcontract`. Unlike + * [createNativeTransfer], this works even for an owner account that has never been activated on-chain + * (confirmed live). + */ + suspend fun triggerSmartContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String, + feeLimitSun: BigInteger + ): TronTriggerContractResponse + + /** + * Signs-and-submits via `POST /wallet/broadcasttransaction`. The full [unsigned] transaction (including its + * `raw_data` object, not just `raw_data_hex`) must be echoed back verbatim alongside the signature - sending + * only `raw_data_hex` + `signature` was confirmed live to fail with a deserialization error on TronGrid's side. + * + * @return the transaction hash (`txID`) on success. + * @throws TronApiException if TronGrid rejects the broadcast (invalid signature, insufficient balance, etc.) + */ + suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String + + /** `key -> value` map from `GET /wallet/getchainparameters`, e.g. `getEnergyFee` (sun/energy), `getTransactionFee` (sun/byte). */ + suspend fun getChainParameters(baseUrl: String): Map + + suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse } class RealTronGridApi( private val retrofitApi: RetrofitTronGridApi ) : TronGridApi { + // TronGrid's public (no API key) endpoint rate-limits aggressively - confirmed live to return a bare HTTP + // 429 under normal, human-paced usage (not just load testing) once a handful of requests land in a short + // window. Without this, a 429 on any call in the send flow (fee estimation re-runs on every keystroke, + // broadcast, etc.) surfaced straight to the user as a raw error dialog, and the only way through was to + // keep tapping Confirm until a request happened to land outside the rate-limit window. Retrying here means + // every TronGrid call gets this transparently, not just the ones a caller remembered to wrap. + private suspend fun retryOn429(maxAttempts: Int = 4, block: suspend () -> T): T { + repeat(maxAttempts - 1) { attempt -> + try { + return block() + } catch (e: HttpException) { + if (e.code() != 429) throw e + delay(1_000L * (attempt + 1)) + } + } + return block() + } + override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance { val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO return accountData.balance?.toBigInteger() ?: BigInteger.ZERO } - override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance { - val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO + override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance { + val response = triggerConstantContract( + baseUrl = baseUrl, + ownerHexAddress = holderAccountId.toTronHexAddress(), + contractHexAddress = contractAddress.tronAddressToHexAddress(), + functionSelector = Trc20TransferAbi.BALANCE_OF_FUNCTION_SELECTOR, + parameterHex = Trc20TransferAbi.encodeBalanceOfParameters(holderAccountId) + ) - val rawBalance = accountData.trc20.orEmpty() - .firstNotNullOfOrNull { entry -> entry[contractAddress] } + val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO - return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO + return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO) } - private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount( - url = accountUrl(baseUrl, address) - ).data?.firstOrNull() + override suspend fun createNativeTransfer( + baseUrl: String, + ownerHexAddress: String, + toHexAddress: String, + amountSun: BigInteger + ): TronUnsignedTransactionResponse { + val request = TronCreateTransactionRequest( + ownerAddress = ownerHexAddress, + toAddress = toHexAddress, + amount = amountSun.toLongExactOrThrow("amount") + ) + + val response = retryOn429 { retrofitApi.createTransaction(walletUrl(baseUrl, "createtransaction"), request) } + + return response.requireConstructed() + } + + override suspend fun triggerConstantContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String + ): TronTriggerContractResponse { + val request = TronTriggerContractRequest( + ownerAddress = ownerHexAddress, + contractAddress = contractHexAddress, + functionSelector = functionSelector, + parameter = parameterHex + ) + + return retryOn429 { retrofitApi.triggerConstantContract(walletUrl(baseUrl, "triggerconstantcontract"), request) } + } + + override suspend fun triggerSmartContract( + baseUrl: String, + ownerHexAddress: String, + contractHexAddress: String, + functionSelector: String, + parameterHex: String, + feeLimitSun: BigInteger + ): TronTriggerContractResponse { + val request = TronTriggerContractRequest( + ownerAddress = ownerHexAddress, + contractAddress = contractHexAddress, + functionSelector = functionSelector, + parameter = parameterHex, + feeLimit = feeLimitSun.toLongExactOrThrow("feeLimit") + ) + + val response = retryOn429 { retrofitApi.triggerSmartContract(walletUrl(baseUrl, "triggersmartcontract"), request) } + + if (response.result?.result != true) { + throw TronApiException(response.result?.message ?: response.result?.code ?: "triggersmartcontract failed without a message") + } + + // Only validate that a transaction was actually returned - callers read [TronTriggerContractResponse.transaction] themselves. + response.transaction?.requireConstructed() + + return response + } + + override suspend fun broadcastTransaction(baseUrl: String, unsigned: TronUnsignedTransactionResponse, signatureHex: String): String { + val txId = requireNotNull(unsigned.txID) { "Cannot broadcast a transaction without a txID" } + + val request = TronBroadcastRequest( + visible = unsigned.visible ?: false, + txID = txId, + rawData = requireNotNull(unsigned.rawData) { "Cannot broadcast a transaction without raw_data" }, + rawDataHex = requireNotNull(unsigned.rawDataHex) { "Cannot broadcast a transaction without raw_data_hex" }, + signature = listOf(signatureHex) + ) + + // Safe to retry on 429 specifically: a 429 means TronGrid rejected the request before processing it + // (rate limit), not that the transaction may have already been broadcast - unlike a timeout, it can't + // cause a double-send. + val response = retryOn429 { retrofitApi.broadcastTransaction(walletUrl(baseUrl, "broadcasttransaction"), request) } + + if (response.result != true) { + throw TronApiException(response.decodeErrorMessage()) + } + + return response.txid ?: txId + } + + override suspend fun getChainParameters(baseUrl: String): Map { + return retryOn429 { retrofitApi.getChainParameters(walletUrl(baseUrl, "getchainparameters")) } + .chainParameter + .associate { it.key to it.value } + } + + override suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse { + return retryOn429 { retrofitApi.getAccountResource(walletUrl(baseUrl, "getaccountresource"), TronAddressRequest(address = addressHex)) } + } + + private suspend fun fetchAccountData(baseUrl: String, address: String) = retryOn429 { + retrofitApi.getAccount(url = accountUrl(baseUrl, address)) + }.data?.firstOrNull() private fun accountUrl(baseUrl: String, address: String): String { return "${baseUrl.trimEnd('/')}/v1/accounts/$address" } + + private fun walletUrl(baseUrl: String, method: String): String { + return "${baseUrl.trimEnd('/')}/wallet/$method" + } + + private fun TronUnsignedTransactionResponse.requireConstructed(): TronUnsignedTransactionResponse { + if (error != null) throw TronApiException(error) + requireNotNull(rawDataHex) { "TronGrid returned no raw_data_hex and no Error" } + requireNotNull(txID) { "TronGrid returned no txID and no Error" } + + return this + } + + private fun TronBroadcastResponse.decodeErrorMessage(): String { + val decodedMessage = message?.let { hex -> runCatching { hex.fromHex().decodeToString() }.getOrNull() } + + return decodedMessage ?: code ?: "broadcasttransaction failed without a message" + } + + private fun BigInteger.toLongExactOrThrow(fieldName: String): Long { + return runCatching { longValueExact() } + .getOrElse { throw IllegalArgumentException("$fieldName overflows Long: $this") } + } } diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt new file mode 100644 index 00000000..e1d8bd3c --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/model/TronTransactionModels.kt @@ -0,0 +1,116 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.model + +import com.google.gson.JsonObject +import com.google.gson.annotations.SerializedName + +/** + * Request/response shapes for TronGrid's transaction-construction/broadcast endpoints (`/wallet/`). + * + * All requests are sent with `"visible": false`, i.e. addresses are hex-encoded (`41` prefix byte ++ 20-byte + * accountId, see `toTronHexAddress`) rather than Base58Check. Every shape below was confirmed against + * TronGrid's Shasta testnet (`https://api.shasta.trongrid.io`) with live HTTP calls - see the Phase 2 + * implementation notes for the exact request/response pairs that were captured. + */ + +class TronCreateTransactionRequest( + @SerializedName("owner_address") val ownerAddress: String, + @SerializedName("to_address") val toAddress: String, + val amount: Long, + val visible: Boolean = false, +) + +class TronTriggerContractRequest( + @SerializedName("owner_address") val ownerAddress: String, + @SerializedName("contract_address") val contractAddress: String, + @SerializedName("function_selector") val functionSelector: String, + val parameter: String, + @SerializedName("fee_limit") val feeLimit: Long? = null, + @SerializedName("call_value") val callValue: Long = 0, + val visible: Boolean = false, +) + +class TronAddressRequest( + val address: String, + val visible: Boolean = false, +) + +/** + * Shape of the unsigned transaction returned by both `/wallet/createtransaction` (flat, at the top level) and + * `/wallet/triggersmartcontract`/`/wallet/triggerconstantcontract` (nested under a `transaction` key - see + * [TronTriggerContractResponse]). + * + * `rawData` is kept as an opaque [JsonObject] rather than being modeled field-by-field: its contents differ + * between contract types (`TransferContract` vs `TriggerSmartContract`) and it is never interpreted by this + * client - it is only ever echoed back verbatim into the broadcast request alongside the signature. The + * cryptographically-authoritative value is [rawDataHex] (`txID == sha256(rawDataHex bytes)`, confirmed live). + * + * `error` is populated (HTTP 200, not an HTTP error) when construction fails, e.g. an unactivated owner account + * trying to build a native TRX transfer returns `{"Error": "... no OwnerAccount."}`. + */ +class TronUnsignedTransactionResponse( + val visible: Boolean? = null, + val txID: String? = null, + @SerializedName("raw_data") val rawData: JsonObject? = null, + @SerializedName("raw_data_hex") val rawDataHex: String? = null, + @SerializedName("Error") val error: String? = null, +) + +class TronContractCallResult( + val result: Boolean = false, + val code: String? = null, + val message: String? = null, +) + +/** + * Response of both `/wallet/triggerconstantcontract` (read-only dry run, used for TRC20 fee/energy estimation) + * and `/wallet/triggersmartcontract` (real construction, used for the actual TRC20 transfer). + */ +class TronTriggerContractResponse( + val result: TronContractCallResult? = null, + @SerializedName("energy_used") val energyUsed: Long? = null, + @SerializedName("constant_result") val constantResult: List? = null, + val transaction: TronUnsignedTransactionResponse? = null, +) + +class TronBroadcastRequest( + val visible: Boolean, + val txID: String, + @SerializedName("raw_data") val rawData: JsonObject, + @SerializedName("raw_data_hex") val rawDataHex: String, + val signature: List, +) + +/** + * On success: `{"result": true, "txid": "..."}`. + * On failure: `{"code": "CONTRACT_VALIDATE_ERROR", "txid": "...", "message": ""}` - confirmed + * live by broadcasting a validly-signed but unfunded-account transaction, e.g. + * `message` hex-decodes to `"Contract validate error : account [...] does not exist"`. + */ +class TronBroadcastResponse( + val result: Boolean? = null, + val txid: String? = null, + val code: String? = null, + val message: String? = null, +) + +class TronChainParameter( + val key: String, + val value: Long = 0, +) + +class TronChainParametersResponse( + @SerializedName("chainParameter") val chainParameter: List = emptyList(), +) + +/** + * Subset of `/wallet/getaccountresource` fields relevant to fee estimation. Fields are omitted by TronGrid + * (rather than sent as `0`) when their value is zero - confirmed live - hence all default to `0`. + */ +class TronAccountResourceResponse( + val freeNetLimit: Long = 0, + val freeNetUsed: Long = 0, + @SerializedName("NetLimit") val netLimit: Long = 0, + @SerializedName("NetUsed") val netUsed: Long = 0, + @SerializedName("EnergyLimit") val energyLimit: Long = 0, + @SerializedName("EnergyUsed") val energyUsed: Long = 0, +) diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt new file mode 100644 index 00000000..19ce0546 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionService.kt @@ -0,0 +1,269 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novafoundation.nova.common.utils.castOrNull +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.common.utils.toEcdsaSignatureData +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToHexAddress +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_account_api.data.model.TronFee +import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.runtime.ext.commissionAsset +import io.novafoundation.nova.runtime.ext.requireTronGridBaseUrl +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import java.math.BigInteger + +private const val ENERGY_FEE_PARAM_KEY = "getEnergyFee" +private const val TRANSACTION_FEE_PARAM_KEY = "getTransactionFee" + +// Fallbacks only used if a live `getchainparameters` call fails or is missing the expected key - both values are +// what was observed live on Shasta testnet at implementation time, which also match Tron's long-standing mainnet +// defaults; the primary path always fetches live values. +private val FALLBACK_ENERGY_FEE_SUN = BigInteger.valueOf(420) +private val FALLBACK_BANDWIDTH_FEE_SUN = BigInteger.valueOf(1000) + +// Used only if a triggerconstantcontract dry run fails outright (e.g. transient network error) and returns no +// energy_used at all - a conservative (intentionally high) stand-in for a simple TRC-20 transfer, which in +// practice costs on the order of 15-30k energy. Mirrors EvmErc20AssetTransfers' ERC_20_UPPER_GAS_LIMIT fallback. +private val FALLBACK_TRC20_ENERGY_UNITS = 65_000L +private val FALLBACK_TRC20_TX_SIZE_BYTES = 350L + +// fee_limit sent with triggersmartcontract: Tron only ever burns what a call actually uses (up to this cap), so +// setting this generously above our own estimate does not cost the user more - it only avoids an OUT_OF_ENERGY +// failure if our estimate undershoots. Bounded above as a sanity guard against a runaway estimate. +private val MIN_FEE_LIMIT_SUN = BigInteger.valueOf(15_000_000) // 15 TRX +private val MAX_FEE_LIMIT_SUN = BigInteger.valueOf(100_000_000) // 100 TRX + +private val EMPTY_RESOURCE = TronAccountResourceResponse() + +/** + * Builds, signs and broadcasts Tron transactions (native TRX and TRC-20) using TronGrid's own REST endpoints for + * construction/broadcast, and this app's existing ECDSA signing primitive for signing - no Tron protobuf + * (`Transaction`/`TransferContract`/`TriggerSmartContract`) encoding and no new crypto library were added. + * + * ## Construction + * - Native TRX: `POST /wallet/createtransaction` with `{owner_address, to_address, amount}` (all hex-encoded, + * `visible: false`). Requires the owner account to already be activated on-chain (confirmed live: an + * unactivated owner gets `{"Error": "... no OwnerAccount."}`) - in practice this is never hit here, since a + * user can only reach the send flow with a positive TRX balance to send from, which itself implies the account + * was already activated by an earlier incoming transfer. + * - TRC-20: `POST /wallet/triggersmartcontract` with the ABI-encoded `transfer(address,uint256)` call (see + * [Trc20TransferAbi]). Unlike `createtransaction`, this was confirmed live to work even for a + * never-activated owner account. + * + * ## Signing + * Tron's signature is `ECDSA_sign(privateKey, sha256(raw_data))` over secp256k1 - the same curve/primitive this + * app already uses for Ethereum. [io.novafoundation.nova.feature_account_api.data.signer.NovaSigner.signRaw] + * (backed by `substrate_sdk_android`'s `Signer.sign(MultiChainEncryption.Ethereum, message, keypair, skipHashing)` + * -> `web3j`'s `Sign.signMessage(hash, keyPair, needToHash = false)`) already supports signing a pre-computed + * hash directly via `SignerPayloadRaw.skipMessageHashing = true` - this is exactly the primitive Ethereum-style + * raw-hash signing needs, and it is reused as-is here. No new cryptographic code or library was added; only the + * hash fed into it differs from the EVM path (`sha256(raw_data)` here vs. an EIP-155 RLP-based digest there). + * + * `web3j`'s `Sign.signMessage` always left-pads `r`/`s` to exactly 32 bytes and encodes `v` as `27/28` (confirmed + * by reading `web3j`'s `Sign.java` source) - which is byte-for-byte the same `r(32) + s(32) + v(1)` = 65-byte + * compact signature format Tron expects (confirmed against `tronweb`'s own `ECKeySign` implementation, and + * independently confirmed live against Shasta testnet - see the Phase 2 implementation notes). + * + * ## Broadcast + * `POST /wallet/broadcasttransaction` with the full unsigned transaction object (not just `raw_data_hex`) plus + * `signature: [<65-byte hex signature>]`. + */ +class RealTronTransactionService( + private val accountRepository: AccountRepository, + private val signerProvider: SignerProvider, + private val tronGridApi: TronGridApi, +) : TronTransactionService { + + override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireTronGridBaseUrl() + val ownerHex = ownerAccountId.toTronHexAddress() + + val feeSun = when (intent) { + is TronTransactionIntent.Native -> estimateNativeFee(baseUrl, ownerHex, recipient, intent.amountSun) + is TronTransactionIntent.Trc20Transfer -> estimateTrc20FeeFromContractHex( + baseUrl, + ownerHex, + recipient, + intent.contractAddress.tronAddressToHexAddress(), + intent.amountSun + ) + } + + return TronFee(feeSun, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset) + } + + override suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result = runCatching { + val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id) + val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain) + val baseUrl = chain.requireTronGridBaseUrl() + val ownerHex = ownerAccountId.toTronHexAddress() + val recipientHex = recipient.toTronHexAddress() + + val unsigned = when (intent) { + is TronTransactionIntent.Native -> tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipientHex, intent.amountSun) + + is TronTransactionIntent.Trc20Transfer -> { + val contractHex = intent.contractAddress.tronAddressToHexAddress() + val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, intent.amountSun) + val feeLimit = feeLimitFor(presetFee, baseUrl, ownerHex, recipient, contractHex, intent.amountSun) + + tronGridApi.triggerSmartContract( + baseUrl = baseUrl, + ownerHexAddress = ownerHex, + contractHexAddress = contractHex, + functionSelector = Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, + parameterHex = parameterHex, + feeLimitSun = feeLimit + ).transaction ?: error("TronGrid returned no transaction from triggersmartcontract") + } + } + + val txHash = signAndBroadcast(baseUrl, unsigned, submittingMetaAccount, ownerAccountId) + + ExtrinsicSubmission( + hash = txHash, + submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId), + callExecutionType = CallExecutionType.IMMEDIATE, + submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE) + ) + } + + override suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result { + // Tron transactions execute atomically with inclusion (no separate "prepare" step, same as EVM) - so + // successful broadcast is already a strong signal. We do not poll for block confirmation here since + // this method sits outside the primary send flow's critical path (`SendInteractor`/`RealSendUseCase` + // only ever call `transact`, not this) - see Phase 2 implementation notes for what remains unverified. + return transact(chain, origin, recipient, presetFee, intent).map { TransactionExecution.Tron(it.hash) } + } + + private suspend fun signAndBroadcast( + baseUrl: String, + unsigned: TronUnsignedTransactionResponse, + metaAccount: MetaAccount, + ownerAccountId: AccountId + ): String { + val rawDataHex = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" } + val messageHash = rawDataHex.fromHex().sha256() + + check(unsigned.txID == null || messageHash.toHexString(withPrefix = false) == unsigned.txID) { + "sha256(raw_data) does not match the txID TronGrid reported - refusing to sign a possibly-tampered transaction" + } + + val signer = signerProvider.rootSignerFor(metaAccount) + val signedRaw = signer.signRaw(SignerPayloadRaw(message = messageHash, accountId = ownerAccountId, skipMessageHashing = true)) + val signature = signedRaw.toEcdsaSignatureData() + + // Tron's compact signature format is r(32) + s(32) + v(1), v = 27/28 - byte-for-byte identical to what + // web3j's Sign.SignatureData already produces for Ethereum signing (see class doc for verification notes). + val signatureBytes = signature.r + signature.s + signature.v + val signatureHex = signatureBytes.toHexString(withPrefix = false) + + return tronGridApi.broadcastTransaction(baseUrl, unsigned, signatureHex) + } + + private suspend fun feeLimitFor( + presetFee: Fee?, + baseUrl: String, + ownerHex: String, + recipient: AccountId, + contractHex: String, + amountSun: BigInteger + ): BigInteger { + val estimatedFee = presetFee?.castOrNull()?.amount + ?: estimateTrc20FeeFromContractHex(baseUrl, ownerHex, recipient, contractHex, amountSun) + + return (estimatedFee * BigInteger.valueOf(3)).coerceIn(MIN_FEE_LIMIT_SUN, MAX_FEE_LIMIT_SUN) + } + + private suspend fun estimateNativeFee(baseUrl: String, ownerHex: String, recipient: AccountId, amountSun: BigInteger): BigInteger { + // TronGrid's createtransaction rejects amount=0 outright with a ContractValidateException (confirmed + // live) - the send screen's fee loader calls calculateFee reactively as the user types, including before + // any amount has been entered. Substitute a minimal placeholder purely for this dry-run construction call; + // it does not affect the real amount used when the transfer is actually built in transact(). + val dryRunAmountSun = amountSun.takeIf { it > BigInteger.ZERO } ?: BigInteger.ONE + val unsigned = tronGridApi.createNativeTransfer(baseUrl, ownerHex, recipient.toTronHexAddress(), dryRunAmountSun) + val txSizeBytes = requireNotNull(unsigned.rawDataHex) { "TronGrid returned no raw_data_hex" }.length / 2 + + val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) + val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN) + + val bandwidthShortfall = shortfall(txSizeBytes.toLong(), resource.availableBandwidth()) + + return bandwidthShortfall.toBigInteger() * bandwidthPrice + } + + private suspend fun estimateTrc20FeeFromContractHex( + baseUrl: String, + ownerHex: String, + recipient: AccountId, + contractHex: String, + amountSun: BigInteger + ): BigInteger { + val parameterHex = Trc20TransferAbi.encodeTransferParameters(recipient, amountSun) + + val dryRun = runCatching { + tronGridApi.triggerConstantContract(baseUrl, ownerHex, contractHex, Trc20TransferAbi.TRANSFER_FUNCTION_SELECTOR, parameterHex) + }.getOrNull() + + // A dry-run revert (e.g. the sender doesn't yet hold the token) still reports the energy spent up to the + // revert point, which remains a meaningful (if slightly different) estimate - it is used as-is rather + // than special-cased, only a wholly-failed HTTP call falls back to the conservative constant. + val energyUsed = dryRun?.energyUsed ?: FALLBACK_TRC20_ENERGY_UNITS + val txSizeBytes = dryRun?.transaction?.rawDataHex?.let { it.length / 2L } ?: FALLBACK_TRC20_TX_SIZE_BYTES + + val resource = runCatching { tronGridApi.getAccountResource(baseUrl, ownerHex) }.getOrDefault(EMPTY_RESOURCE) + val bandwidthPrice = chainParameterOrDefault(baseUrl, TRANSACTION_FEE_PARAM_KEY, FALLBACK_BANDWIDTH_FEE_SUN) + val energyPrice = chainParameterOrDefault(baseUrl, ENERGY_FEE_PARAM_KEY, FALLBACK_ENERGY_FEE_SUN) + + val bandwidthShortfall = shortfall(txSizeBytes, resource.availableBandwidth()) + val energyShortfall = shortfall(energyUsed, resource.availableEnergy()) + + return bandwidthShortfall.toBigInteger() * bandwidthPrice + energyShortfall.toBigInteger() * energyPrice + } + + private suspend fun chainParameterOrDefault(baseUrl: String, key: String, default: BigInteger): BigInteger { + val params = runCatching { tronGridApi.getChainParameters(baseUrl) }.getOrNull() + + return params?.get(key)?.toBigInteger() ?: default + } + + private fun shortfall(needed: Long, available: Long): Long = (needed - available.coerceAtLeast(0)).coerceAtLeast(0) + + private fun TronAccountResourceResponse.availableBandwidth(): Long = + (freeNetLimit - freeNetUsed).coerceAtLeast(0) + (netLimit - netUsed).coerceAtLeast(0) + + private fun TronAccountResourceResponse.availableEnergy(): Long = + (energyLimit - energyUsed).coerceAtLeast(0) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt new file mode 100644 index 00000000..2f411740 --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbi.kt @@ -0,0 +1,49 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * Minimal, hand-written Solidity ABI encoding for the two calls this client makes to a TRC-20 contract: + * `transfer(address,uint256)` (sending) and `balanceOf(address)` (reading a balance - `Trc20AssetBalance` can't + * use TronGrid's `/v1/accounts` REST endpoint for this: a TRC-20 balance lives in the token contract's own + * storage, not the holder's Account object, so an address that has only ever received TRC-20 tokens has no + * Account object at all and `/v1/accounts` silently returns empty for it regardless of its real balance). + * + * Both parameter types involved (`address`, `uint256`) are static (fixed-size), so encoding is just "left-pad + * each to 32 bytes and concatenate" - no dynamic-type/offset table is needed. The 4-byte function selector is + * intentionally NOT computed client-side: TronGrid accepts the human-readable `function_selector` string + * directly and hashes it server-side (confirmed live against Shasta testnet - a call with + * `function_selector: "transfer(address,uint256)"` and no client-computed selector correctly resolved to the + * standard `a9059cbb` selector in the resulting `raw_data`), which avoids needing a keccak256 implementation here. + */ +object Trc20TransferAbi { + + const val TRANSFER_FUNCTION_SELECTOR = "transfer(address,uint256)" + const val BALANCE_OF_FUNCTION_SELECTOR = "balanceOf(address)" + + /** + * @param recipient raw 20-byte Ethereum/Tron-style account id (NOT the `41`-prefixed Tron hex address - + * ABI-encoded Solidity `address` parameters use the bare 20-byte form, confirmed live). + */ + fun encodeTransferParameters(recipient: AccountId, amountSun: BigInteger): String { + require(recipient.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${recipient.size}" } + require(amountSun.signum() >= 0) { "Amount must not be negative, got $amountSun" } + + val addressParam = recipient.toHexString(withPrefix = false).padStart(64, '0') + val amountParam = amountSun.toString(16).padStart(64, '0') + + return addressParam + amountParam + } + + /** + * @param holder raw 20-byte Ethereum/Tron-style account id - same encoding as [encodeTransferParameters]'s + * `recipient`. + */ + fun encodeBalanceOfParameters(holder: AccountId): String { + require(holder.size == 20) { "Tron/EVM-style account id must be 20 bytes, got ${holder.size}" } + + return holder.toHexString(withPrefix = false).padStart(64, '0') + } +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt new file mode 100644 index 00000000..2261418d --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/TronTransactionService.kt @@ -0,0 +1,55 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission +import io.novafoundation.nova.feature_account_api.data.model.Fee +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.runtime.AccountId +import java.math.BigInteger + +/** + * What kind of Tron transaction to build. Both cases ultimately burn TRX for bandwidth/energy per Tron's default + * protocol behavior - this service never stakes/rents Energy or Bandwidth, it only estimates the automatic burn + * and lets the caller (asset transfer validation) block the send if the user's TRX balance can't cover it. + */ +sealed class TronTransactionIntent { + + class Native(val amountSun: BigInteger) : TronTransactionIntent() + + /** @param contractAddress Base58Check TRC-20 contract address, as stored in chain config (`Type.Trc20.contractAddress`). */ + class Trc20Transfer(val contractAddress: String, val amountSun: BigInteger) : TronTransactionIntent() +} + +/** + * Mirrors [io.novafoundation.nova.feature_account_api.data.ethereum.transaction.EvmTransactionService]'s shape + * (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Tron. Unlike the EVM service, + * this lives entirely in `feature-wallet-impl` rather than being split across `feature-account-api`/`-impl`, + * since (for now, Phase 2 send-only scope) it is only ever consumed by `TronNativeAssetTransfers`/ + * `Trc20AssetTransfers` in this module, and it needs [io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi], + * which itself lives in this module (feature-account-impl cannot depend on feature-wallet-impl). + * + * Construction goes through TronGrid's own `/wallet/createtransaction` and `/wallet/triggersmartcontract` + * endpoints rather than hand-rolled protobuf encoding - see `RealTronTransactionService` for details and the + * live-testnet verification notes. + */ +interface TronTransactionService { + + suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipient: AccountId, intent: TronTransactionIntent): Fee + + suspend fun transact( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result + + suspend fun transactAndAwaitExecution( + chain: Chain, + origin: TransactionOrigin, + recipient: AccountId, + presetFee: Fee?, + intent: TronTransactionIntent + ): Result +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt index dd102795..96f5d25e 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/WalletFeatureDependencies.kt @@ -37,6 +37,7 @@ import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicServic import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentProviderRegistry import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigValidationsRepository +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope @@ -74,6 +75,8 @@ interface WalletFeatureDependencies { val evmTransactionService: EvmTransactionService + val signerProvider: SignerProvider + val chainAssetDao: ChainAssetDao val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt index ebe0597d..bbcf0da0 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/AssetsModule.kt @@ -22,6 +22,7 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets EvmNativeAssetsModule::class, EquilibriumAssetsModule::class, TronAssetsModule::class, + BitcoinAssetsModule::class, UnsupportedAssetsModule::class ] ) @@ -38,6 +39,7 @@ class AssetsModule { @EquilibriumAsset equilibrium: Lazy, @TronNativeAssets tronNative: Lazy, @Trc20Assets trc20: Lazy, + @BitcoinNativeAssets bitcoinNative: Lazy, @UnsupportedAssets unsupported: AssetSource, nativeAssetEventDetector: NativeAssetEventDetector, @@ -53,6 +55,7 @@ class AssetsModule { equilibriumAssetSource = equilibrium, tronNativeSource = tronNative, trc20Source = trc20, + bitcoinNativeSource = bitcoinNative, unsupportedBalanceSource = unsupported, nativeAssetEventDetector = nativeAssetEventDetector, diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt new file mode 100644 index 00000000..4e024b8f --- /dev/null +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/BitcoinAssetsModule.kt @@ -0,0 +1,81 @@ +package io.novafoundation.nova.feature_wallet_impl.di.modules + +import dagger.Module +import dagger.Provides +import io.novafoundation.nova.common.data.network.NetworkApiCreator +import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.BitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.RealBitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.RetrofitBitcoinApi +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.BitcoinTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.RealBitcoinTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.BitcoinNativeAssetBalance +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.bitcoinNative.BitcoinNativeAssetTransfers +import javax.inject.Qualifier + +@Qualifier +annotation class BitcoinNativeAssets + +/** + * Bitcoin support: `balance` (REST polling against mempool.space) and `transfers` (client-side UTXO selection, + * BIP143 signing, raw broadcast - see `RealBitcoinTransactionService` for the full construction/signing notes). + * + * `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*` + * stubs used for asset types without history support - see `TronAssetsModule` for the identical precedent). + */ +@Module +class BitcoinAssetsModule { + + @Provides + @FeatureScope + fun provideRetrofitBitcoinApi( + networkApiCreator: NetworkApiCreator + ): RetrofitBitcoinApi = networkApiCreator.create(RetrofitBitcoinApi::class.java) + + @Provides + @FeatureScope + fun provideBitcoinApi(retrofitBitcoinApi: RetrofitBitcoinApi): BitcoinApi = RealBitcoinApi(retrofitBitcoinApi) + + @Provides + @FeatureScope + fun provideBitcoinTransactionService( + accountRepository: AccountRepository, + signerProvider: SignerProvider, + bitcoinApi: BitcoinApi, + ): BitcoinTransactionService = RealBitcoinTransactionService( + accountRepository = accountRepository, + signerProvider = signerProvider, + bitcoinApi = bitcoinApi + ) + + @Provides + @FeatureScope + fun provideBitcoinNativeBalance(assetCache: AssetCache, bitcoinApi: BitcoinApi) = BitcoinNativeAssetBalance(assetCache, bitcoinApi) + + @Provides + @FeatureScope + fun provideBitcoinNativeAssetTransfers( + bitcoinTransactionService: BitcoinTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = BitcoinNativeAssetTransfers(bitcoinTransactionService, assetSourceRegistry) + + @Provides + @BitcoinNativeAssets + @FeatureScope + fun provideBitcoinNativeAssetSource( + bitcoinNativeAssetBalance: BitcoinNativeAssetBalance, + bitcoinNativeAssetTransfers: BitcoinNativeAssetTransfers, + unsupportedAssetHistory: UnsupportedAssetHistory, + ): AssetSource = StaticAssetSource( + transfers = bitcoinNativeAssetTransfers, + balance = bitcoinNativeAssetBalance, + history = unsupportedAssetHistory + ) +} diff --git a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt index 90f633e5..09608781 100644 --- a/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt +++ b/feature-wallet-impl/src/main/java/io/novafoundation/nova/feature_wallet_impl/di/modules/TronAssetsModule.kt @@ -4,16 +4,22 @@ import dagger.Module import dagger.Provides import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.di.scope.FeatureScope +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.trc20.Trc20AssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative.TronNativeAssetBalance import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory -import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.UnsupportedAssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.trc20.Trc20AssetTransfers +import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.tronNative.TronNativeAssetTransfers import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RealTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.RealTronTransactionService +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction.TronTransactionService import javax.inject.Qualifier @Qualifier @@ -23,11 +29,17 @@ annotation class TronNativeAssets annotation class Trc20Assets /** - * Tron/TRC-20 support - Phase 1, read-only. + * Tron/TRC-20 support. * - * Only `balance` is implemented for real; `transfers`/`history` reuse the same `Unsupported*` stubs the rest of - * the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is - * intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work. + * Phase 1 (read-only): `balance`. + * Phase 2 (send/transfer, this module): `transfers`, via [TronTransactionService] - construction/broadcast + * through TronGrid's own REST endpoints, signing through the app's existing Ethereum-style ECDSA signer (see + * `RealTronTransactionService` for the full verification notes). No Energy/Bandwidth staking or rental: only + * Tron's default protocol behavior (auto-burning TRX when free resources are insufficient) is estimated and + * enforced. + * + * `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*` + * stubs used for asset types without history support). */ @Module class TronAssetsModule { @@ -42,6 +54,18 @@ class TronAssetsModule { @FeatureScope fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi) + @Provides + @FeatureScope + fun provideTronTransactionService( + accountRepository: AccountRepository, + signerProvider: SignerProvider, + tronGridApi: TronGridApi, + ): TronTransactionService = RealTronTransactionService( + accountRepository = accountRepository, + signerProvider = signerProvider, + tronGridApi = tronGridApi + ) + @Provides @FeatureScope fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi) @@ -50,15 +74,29 @@ class TronAssetsModule { @FeatureScope fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi) + @Provides + @FeatureScope + fun provideTronNativeAssetTransfers( + tronTransactionService: TronTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = TronNativeAssetTransfers(tronTransactionService, assetSourceRegistry) + + @Provides + @FeatureScope + fun provideTrc20AssetTransfers( + tronTransactionService: TronTransactionService, + assetSourceRegistry: AssetSourceRegistry, + ) = Trc20AssetTransfers(tronTransactionService, assetSourceRegistry) + @Provides @TronNativeAssets @FeatureScope fun provideTronNativeAssetSource( tronNativeAssetBalance: TronNativeAssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + tronNativeAssetTransfers: TronNativeAssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = tronNativeAssetTransfers, balance = tronNativeAssetBalance, history = unsupportedAssetHistory ) @@ -68,10 +106,10 @@ class TronAssetsModule { @FeatureScope fun provideTrc20AssetSource( trc20AssetBalance: Trc20AssetBalance, - unsupportedAssetTransfers: UnsupportedAssetTransfers, + trc20AssetTransfers: Trc20AssetTransfers, unsupportedAssetHistory: UnsupportedAssetHistory, ): AssetSource = StaticAssetSource( - transfers = unsupportedAssetTransfers, + transfers = trc20AssetTransfers, balance = trc20AssetBalance, history = unsupportedAssetHistory ) diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt new file mode 100644 index 00000000..4f0318de --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/RealTronTransactionServiceTest.kt @@ -0,0 +1,276 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import com.google.gson.JsonObject +import io.novafoundation.nova.common.utils.Precision +import io.novafoundation.nova.common.utils.TokenSymbol +import io.novafoundation.nova.common.utils.sha256 +import io.novafoundation.nova.common.utils.toTronHexAddress +import io.novafoundation.nova.common.utils.tronAddressToAccountId +import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin +import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner +import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider +import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository +import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronAccountResourceResponse +import io.novafoundation.nova.feature_wallet_impl.data.network.tron.model.TronUnsignedTransactionResponse +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper +import io.novasama.substrate_sdk_android.extensions.fromHex +import io.novasama.substrate_sdk_android.extensions.toHexString +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw +import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatcher +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.junit.MockitoJUnitRunner +import java.math.BigInteger + +// eq()/any()/argThat() genuinely return null at runtime - that's how Mockito's matcher-stack recording works, +// regardless of whether they're called via test_shared's Kotlin wrappers or the raw org.mockito.Mockito statics +// directly (confirmed: switching to the raw statics did not avoid the "eq(...) must not be null" crash once that +// null flows into a Kotlin non-null-typed parameter somewhere downstream of the call site). Instead of returning +// the real (null) value, these local wrappers fall back to a definitely-non-null stand-in - the real value itself +// for eq() (harmless: the matcher was already recorded on Mockito's thread-local stack by the time this returns, +// so the fallback value is never actually used for matching) and an unchecked-cast dummy for any()/argThat() +// (mirrors mockito-kotlin's own internal implementation of the same helpers). +private fun eq(value: T): T = Mockito.eq(value) ?: value + +@Suppress("UNCHECKED_CAST") +private fun any(): T { + Mockito.any() + return null as T +} + +@Suppress("UNCHECKED_CAST") +private fun argThat(matcher: (T) -> Boolean): T { + Mockito.argThat(ArgumentMatcher { matcher(it) }) + return null as T +} + +private fun whenever(methodCall: T?) = Mockito.`when`(methodCall) + +/** + * Covers the native-TRX branch of [RealTronTransactionService.transact] - the sign/broadcast pipeline this class' + * own doc comment describes (sha256(raw_data) signed via the existing Ethereum-style ECDSA-raw-hash primitive, + * assembled as a 65-byte r+s+v signature) had no test at all before this: [Trc20TransferAbiTest] only covers the + * TRC-20 ABI-encoding side, and [TronDerivationTest]/[io.novafoundation.nova.common.utils.TronAddressTest] only + * cover address derivation/formatting, not transaction construction or signing. + * + * The owner address used throughout is the same one [TronDerivationTest] cross-validated against the standard + * BIP39 test mnemonic ("abandon x11 about" at the coin-195 path) and against live TronGrid data - reusing it here + * keeps every Tron test in this codebase anchored to a single, independently-verified real-world address instead + * of an arbitrary one. The recipient and raw_data_hex/txID pair are self-consistent fixtures created for this + * test only (unlike [Trc20TransferAbiTest]'s ABI vector, this raw_data_hex was not captured live against + * TronGrid - constructing a real `TransferContract` protobuf is out of scope here, since this class deliberately + * never encodes one itself, see its class doc) - what matters for these tests is that this service correctly + * hashes/signs/forwards whatever raw_data TronGrid returns, which a self-consistent fixture exercises just as + * well as a live-captured one. + */ +@RunWith(MockitoJUnitRunner::class) +class RealTronTransactionServiceTest { + + @Mock + lateinit var accountRepository: AccountRepository + + @Mock + lateinit var signerProvider: SignerProvider + + @Mock + lateinit var tronGridApi: TronGridApi + + @Mock + lateinit var metaAccount: MetaAccount + + @Mock + lateinit var signer: NovaSigner + + private lateinit var subject: RealTronTransactionService + + private val ownerAccountId = "TUEZSdKsoDHQMeZwihtdoBiN46zxhGWYdH".tronAddressToAccountId() + private val ownerHex = ownerAccountId.toTronHexAddress() + + private val recipientAccountId = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + private val recipientHex = recipientAccountId.toTronHexAddress() + + private val amountSun = BigInteger.valueOf(1_000_000) + + private val baseUrl = "https://api.trongrid.io" + private val chain = tronChain(baseUrl) + + // Self-consistent fixture: rawDataHex ++ its own sha256 as txID - see class doc. + private val rawDataHex = "0a027a1e2208d1e2b3f4a5b6c7d840e8c896e8b7325a67080112630a2d747970652e676f6f676c65617069732e636f6d2f70726f746f636f6c2e5472616e73666572436f6e747261637412320a1541dfd8703a5c753e17ed52a96a29cea9d425538dfe1215415d10da10f5c60a8e2d5e3c0a70e1e7f3c1b2a3e41880ade20470a08fc9c9e8b732" + private val expectedTxId = rawDataHex.fromHex().sha256().toHexString(withPrefix = false) + + @Before + fun setup() { + subject = RealTronTransactionService(accountRepository, signerProvider, tronGridApi) + + whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(ownerAccountId) + whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer) + } + + @Test + fun `transact with Native intent should build, sign with sha256(raw_data), and broadcast a 65-byte r+s+v signature`(): Unit = runBlocking { + val unsigned = TronUnsignedTransactionResponse( + visible = false, + txID = expectedTxId, + rawData = JsonObject(), + rawDataHex = rawDataHex + ) + + val r = ByteArray(32) { (it + 1).toByte() } + val s = ByteArray(32) { (it + 33).toByte() } + val v = byteArrayOf(27) + val fakeSignedRaw = SignedRaw( + SignerPayloadRaw(message = expectedTxId.fromHex(), accountId = ownerAccountId, skipMessageHashing = true), + SignatureWrapper.Ecdsa(v = v, r = r, s = s) + ) + + whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) + whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw) + whenever(tronGridApi.broadcastTransaction(eq(baseUrl), eq(unsigned), any())).thenReturn("some-broadcast-tx-hash") + + val result = subject.transact( + chain = chain, + origin = TransactionOrigin.Wallet(metaAccount), + recipient = recipientAccountId, + presetFee = null, + intent = TronTransactionIntent.Native(amountSun) + ) + + assertTrue(result.isSuccess) + assertEquals("some-broadcast-tx-hash", result.getOrThrow().hash) + + // The message actually handed to the signer must be sha256(raw_data), not raw_data or txID itself - a + // regression here would silently produce a signature over the wrong bytes. ByteArray has reference + // equality in Kotlin, so this must compare contents (contentEquals), not rely on SignerPayloadRaw.equals(). + val expectedMessage = rawDataHex.fromHex().sha256() + verify(signer).signRaw( + argThat { payload: SignerPayloadRaw -> + payload.message.contentEquals(expectedMessage) && + payload.accountId.contentEquals(ownerAccountId) && + payload.skipMessageHashing + } + ) + + // Tron expects a flat 65-byte r(32)+s(32)+v(1) hex signature - assembled by hand in production code, not + // by any library, so this is the one place that byte order/length could silently regress. + val expectedSignatureHex = (r + s + v).toHexString(withPrefix = false) + verify(tronGridApi).broadcastTransaction(baseUrl, unsigned, expectedSignatureHex) + } + + @Test + fun `transact should refuse to sign when TronGrid's txID does not match sha256(raw_data)`(): Unit = runBlocking { + val tamperedTxId = "0".repeat(64) // deliberately wrong - does not match sha256(rawDataHex) + val unsigned = TronUnsignedTransactionResponse( + visible = false, + txID = tamperedTxId, + rawData = JsonObject(), + rawDataHex = rawDataHex + ) + + whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) + + val result = subject.transact( + chain = chain, + origin = TransactionOrigin.Wallet(metaAccount), + recipient = recipientAccountId, + presetFee = null, + intent = TronTransactionIntent.Native(amountSun) + ) + + assertTrue("expected a failed Result when txID doesn't match sha256(raw_data)", result.isFailure) + + // Signing (and therefore broadcasting) must never be attempted once the txID/raw_data mismatch is + // detected - this is the guard that stops a tampered/malicious response from getting silently signed. + verify(signer, never()).signRaw(any()) + verify(tronGridApi, never()).broadcastTransaction(any(), any(), any()) + } + + @Test + fun `calculateFee for Native intent should charge bandwidth shortfall at the fallback price when TronGrid's own resource_endpoints are unavailable`(): Unit = runBlocking { + val unsigned = TronUnsignedTransactionResponse( + visible = false, + txID = expectedTxId, + rawData = JsonObject(), + rawDataHex = rawDataHex + ) + val txSizeBytes = rawDataHex.length / 2 + + whenever(tronGridApi.createNativeTransfer(eq(baseUrl), eq(ownerHex), eq(recipientHex), eq(amountSun))).thenReturn(unsigned) + whenever(tronGridApi.getAccountResource(eq(baseUrl), eq(ownerHex))).thenReturn(TronAccountResourceResponse()) + whenever(tronGridApi.getChainParameters(eq(baseUrl))).thenReturn(emptyMap()) + + val fee = subject.calculateFee( + chain = chain, + origin = TransactionOrigin.Wallet(metaAccount), + recipient = recipientAccountId, + intent = TronTransactionIntent.Native(amountSun) + ) + + // Zero available bandwidth (empty TronAccountResourceResponse) -> the whole tx size is billed, at the + // fallback bandwidth price (1000 sun/byte) since getChainParameters returned no getTransactionFee entry. + val expectedFeeSun = BigInteger.valueOf(txSizeBytes.toLong()) * BigInteger.valueOf(1000) + assertEquals(expectedFeeSun, fee.amount) + } + + private fun tronChain(baseUrl: String): Chain { + val trxAsset = Chain.Asset( + icon = null, + id = 0, + priceId = "tron", + chainId = "tron:mainnet", + symbol = TokenSymbol("TRX"), + precision = Precision(6), + buyProviders = emptyMap(), + sellProviders = emptyMap(), + staking = emptyList(), + type = Chain.Asset.Type.TronNative, + source = Chain.Asset.Source.DEFAULT, + name = "Tron", + enabled = true + ) + + return Chain( + id = "tron:mainnet", + name = "Tron", + assets = listOf(trxAsset), + nodes = Chain.Nodes( + autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN, + wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance, + nodes = listOf(Chain.Node(chainId = "tron:mainnet", unformattedUrl = baseUrl, name = "TronGrid", orderId = 0, isCustom = false)) + ), + explorers = emptyList(), + externalApis = emptyList(), + icon = null, + addressPrefix = 0, + legacyAddressPrefix = null, + types = null, + isEthereumBased = false, + isTronBased = true, + isBitcoinBased = false, + isTestNet = false, + source = Chain.Source.DEFAULT, + hasSubstrateRuntime = false, + pushSupport = false, + hasCrowdloans = false, + supportProxy = false, + governance = emptyList(), + swap = emptyList(), + customFee = emptyList(), + multisigSupport = false, + connectionState = Chain.ConnectionState.FULL_SYNC, + parentId = null, + additional = null + ) + } +} diff --git a/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt new file mode 100644 index 00000000..b1d96244 --- /dev/null +++ b/feature-wallet-impl/src/test/java/io/novafoundation/nova/feature_wallet_impl/data/network/tron/transaction/Trc20TransferAbiTest.kt @@ -0,0 +1,52 @@ +package io.novafoundation.nova.feature_wallet_impl.data.network.tron.transaction + +import io.novasama.substrate_sdk_android.extensions.fromHex +import org.junit.Assert.assertEquals +import org.junit.Test +import java.math.BigInteger + +class Trc20TransferAbiTest { + + /** + * Real request/response pair captured live against TronGrid's Shasta testnet + * (`POST https://api.shasta.trongrid.io/wallet/triggerconstantcontract`) for a `transfer(address,uint256)` + * call with recipient accountId `dfd8703a5c753e17ed52a96a29cea9d425538dfe` and amount `1000000` (sun) - + * TronGrid accepted this exact `parameter` value and correctly resolved `function_selector` to the standard + * `a9059cbb` selector in the resulting `raw_data.contract[0].parameter.value.data`. + */ + @Test + fun `encodeTransferParameters should match a live-verified TronGrid request`() { + val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + val amountSun = BigInteger.valueOf(1_000_000) + + val expectedParameter = "000000000000000000000000dfd8703a5c753e17ed52a96a29cea9d425538dfe" + + "00000000000000000000000000000000000000000000000000000000000f4240" + + assertEquals(expectedParameter, Trc20TransferAbi.encodeTransferParameters(recipient, amountSun)) + } + + @Test + fun `encodeTransferParameters should reject a negative amount`() { + val recipient = "dfd8703a5c753e17ed52a96a29cea9d425538dfe".fromHex() + + assertThrowsIllegalArgument { + Trc20TransferAbi.encodeTransferParameters(recipient, BigInteger.valueOf(-1)) + } + } + + @Test + fun `encodeTransferParameters should reject a non-20-byte account id`() { + assertThrowsIllegalArgument { + Trc20TransferAbi.encodeTransferParameters(ByteArray(19), BigInteger.ONE) + } + } + + private fun assertThrowsIllegalArgument(block: () -> Unit) { + try { + block() + throw AssertionError("Expected IllegalArgumentException") + } catch (expected: IllegalArgumentException) { + // expected + } + } +} diff --git a/runtime/build.gradle b/runtime/build.gradle index 207a8520..e9f43ae3 100644 --- a/runtime/build.gradle +++ b/runtime/build.gradle @@ -5,17 +5,23 @@ android { defaultConfig { - + // TEMPORARY - points at pending/post-fix-release, NOT master. wallet-util's master was reset to the + // last content the still-live Play Store release can parse (see wallet-util repo history around + // 2026-07-11), so master has neither Tron nor Bitcoin config/icons this branch needs to test against. + // pending/post-fix-release is where both are staged until wallet-android ships the coordinated release. + // MUST be pointed back at "master" before this branch merges - do not ship this override. - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" - buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/android/chains.json\"" + buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/assets/evm/v3/assets.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\"" - buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\"" + buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/chains_for_testBalance.json\"" + buildConfigField "String", "TEST_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/tests/pezkuwi_assets_for_testBalance.json\"" buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY") buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY") + buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY") } buildTypes { @@ -27,10 +33,10 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' - buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\"" - buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\"" - buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\"" + buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/android/chains.json\"" + buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/assets/evm/v3/assets.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/chains.json\"" + buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/pending/post-fix-release/chains/v22/preConfigured/details\"" } } namespace 'io.novafoundation.nova.runtime' diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt index 97e74a26..eff94b38 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/di/ChainRegistryModule.kt @@ -37,7 +37,9 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher import io.novasama.substrate_sdk_android.wsrpc.SocketService import kotlinx.coroutines.flow.MutableStateFlow +import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import java.util.concurrent.TimeUnit import org.web3j.protocol.http.HttpService import javax.inject.Provider @@ -160,12 +162,18 @@ class ChainRegistryModule { socketProvider: Provider, bulkRetriever: BulkRetriever, connectionSecrets: ConnectionSecrets, - web3ApiFactory: Web3ApiFactory + web3ApiFactory: Web3ApiFactory, ) = NodeHealthStateTesterFactory( socketProvider, connectionSecrets, bulkRetriever, - web3ApiFactory + web3ApiFactory, + // A short-lived, minimally-configured client is enough for a health-check ping - unlike Web3ApiFactory's + // client, this never needs to survive/reuse connections across a long-lived RPC session. + OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() ) @Provides diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt index 2f7afc1f..67fa6ae1 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainExt.kt @@ -9,13 +9,21 @@ import io.novafoundation.nova.common.utils.Modules import io.novafoundation.nova.common.utils.TokenSymbol import io.novafoundation.nova.common.utils.Urls import io.novafoundation.nova.common.utils.asTokenSymbol +import io.novafoundation.nova.common.utils.bitcoinAddressToAccountId +import io.novafoundation.nova.common.utils.decodeBitcoinDestination +import io.novafoundation.nova.common.utils.hash +import io.novafoundation.nova.common.utils.bitcoinPublicKeyToAccountId +import io.novafoundation.nova.common.utils.emptyBitcoinAccountId import io.novafoundation.nova.common.utils.emptyEthereumAccountId import io.novafoundation.nova.common.utils.emptySubstrateAccountId import io.novafoundation.nova.common.utils.findIsInstanceOrNull import io.novafoundation.nova.common.utils.formatNamed +import io.novafoundation.nova.common.utils.isValidBitcoinDestinationAddress import io.novafoundation.nova.common.utils.removeHexPrefix import io.novafoundation.nova.common.utils.emptyTronAccountId +import io.novafoundation.nova.common.utils.isValidTronAddress import io.novafoundation.nova.common.utils.substrateAccountId +import io.novafoundation.nova.common.utils.toBitcoinAddress import io.novafoundation.nova.common.utils.toTronAddress import io.novafoundation.nova.common.utils.tronAddressToAccountId import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId @@ -272,6 +280,7 @@ fun Chain.requireGenesisHash() = requireNotNull(genesisHash) fun Chain.addressOf(accountId: ByteArray): String { return when { isTronBased -> accountId.toTronAddress() + isBitcoinBased -> accountId.toBitcoinAddress() isEthereumBased -> accountId.toEthereumAddress() else -> accountId.toAddress(addressPrefix.toShort()) } @@ -282,7 +291,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String { } fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? { - return if (isEthereumBased || isTronBased) { + return if (isEthereumBased || isTronBased || isBitcoinBased) { null } else { legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) } @@ -296,6 +305,12 @@ fun ByteArray.toEthereumAddress(): String { fun Chain.accountIdOf(address: String): ByteArray { return when { isTronBased -> address.tronAddressToAccountId() + // Falls back to decodeBitcoinDestination() for a valid P2SH/P2PKH address (real exchange withdrawal + // addresses were confirmed to be P2SH-only) - this wallet's own address stays native-SegWit-only, but a + // SEND destination legitimately isn't. Safe here ONLY because this generic accountId is used for + // opaque purposes (identicon generation, presence checks) elsewhere, never fed back into building a + // scriptPubKey - see BitcoinDestinationAddress.kt's [hash] doc. + isBitcoinBased -> runCatching { address.bitcoinAddressToAccountId() }.getOrElse { address.decodeBitcoinDestination().hash } isEthereumBased -> address.asEthereumAddress().toAccountId().value else -> address.toAccountId() } @@ -329,6 +344,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? { fun Chain.emptyAccountId() = when { isTronBased -> emptyTronAccountId() + isBitcoinBased -> emptyBitcoinAccountId() isEthereumBased -> emptyEthereumAccountId() else -> emptySubstrateAccountId() } @@ -342,6 +358,7 @@ fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray { fun Chain.accountIdOf(publicKey: ByteArray): ByteArray { return when { isTronBased -> publicKey.tronPublicKeyToAccountId() + isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId() isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value else -> publicKey.substrateAccountId() } @@ -361,13 +378,24 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress { fun Chain.isValidAddress(address: String): Boolean { return runCatching { - if (isEthereumBased) { - address.asEthereumAddress().isValid() - } else { - address.toAccountId() // verify supplied address can be converted to account id + when { + // Wider than isValidBitcoinAddress() (native SegWit only, used for this wallet's OWN address/accountId): + // a valid SEND destination can legitimately be P2SH/P2PKH too - confirmed live via a real exchange + // withdrawal address - see BitcoinDestinationAddress.kt. + isBitcoinBased -> address.isValidBitcoinDestinationAddress() - addressPrefix.toShort() == address.addressPrefix() || - legacyAddressPrefix?.toShort() == address.addressPrefix() + // Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two + // branches below would ever accept them, so this needs its own dedicated check. + isTronBased -> address.isValidTronAddress() + + isEthereumBased -> address.asEthereumAddress().isValid() + + else -> { + address.toAccountId() // verify supplied address can be converted to account id + + addressPrefix.toShort() == address.addressPrefix() || + legacyAddressPrefix?.toShort() == address.addressPrefix() + } } }.getOrDefault(false) } @@ -481,6 +509,7 @@ object ChainGeneses { object ChainIds { const val ETHEREUM = "$EIP_155_PREFIX:1" + const val TRON = "tron:0x2b6653dc" const val MOONBEAM = ChainGeneses.MOONBEAM const val MOONRIVER = ChainGeneses.MOONRIVER @@ -492,6 +521,33 @@ val Chain.Companion.Geneses val Chain.Companion.Ids get() = ChainIds +/** + * A short, user-facing token-standard label for chains where disambiguating "which token standard is this" + * is actually useful (multiple ecosystems all issue their own USDT/USDC etc., so a bare chain name isn't + * always enough context). Deliberately NOT derived from [Chain.Asset.Type] (e.g. every Statemine-type chain + * would get the same label) - this is chain-specific by design, matching exactly which labels are + * recognizable/expected by users (PEZ-20, ERC-20, TRC-20), not a mechanical one-label-per-asset-type mapping. + */ +val Chain.assetStandardLabelOrNull: String? + get() = when { + genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> "PEZ-20" + id == Chain.Ids.ETHEREUM -> "ERC-20" + id == Chain.Ids.TRON -> "TRC-20" + else -> null + } + +/** + * Chain display name with its token-standard label appended where [assetStandardLabelOrNull] applies, e.g. + * "Ethereum (ERC-20)". Shared across every screen that lists the same token symbol once per chain (the + * Send/Receive/etc. network picker, the main balance list's per-token chain breakdown) - a bare chain name + * alone doesn't convey which issuance this is when multiple ecosystems share the same symbol. + */ +fun Chain.displayNameWithAssetStandard(): String { + val standardLabel = assetStandardLabelOrNull ?: return name + + return "$name ($standardLabel)" +} + fun Chain.Asset.requireStatemine(): Type.Statemine { require(type is Type.Statemine) @@ -549,6 +605,18 @@ fun Chain.requireTronGridBaseUrl(): String { } } +/** + * The mempool.space-style REST API base url for a Bitcoin-based chain - same rationale as [requireTronGridBaseUrl]: + * Bitcoin has no JSON-RPC/WS node concept at all, so the configured `nodes` entry directly *is* the REST API base url. + */ +fun Chain.requireMempoolSpaceBaseUrl(): String { + require(isBitcoinBased) { "Chain $id is not Bitcoin-based" } + + return requireNotNull(nodes.nodes.minByOrNull { it.orderId }?.unformattedUrl) { + "No mempool.space-style node configured for chain $id" + } +} + fun Chain.Asset.requireEquilibrium(): Type.Equilibrium { require(type is Type.Equilibrium) @@ -614,6 +682,7 @@ val Chain.Asset.onChainAssetId: String? is Type.EvmNative -> null is Type.Trc20 -> this.type.contractAddress Type.TronNative -> null + Type.BitcoinNative -> null Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}") } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt index 1b8fcd49..9c41b816 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/ChainSorting.kt @@ -3,19 +3,23 @@ package io.novafoundation.nova.runtime.ext import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain val Chain.mainChainsFirstAscendingOrder - get() = when (genesisHash) { + get() = when { // Pezkuwi ecosystem first - Chain.Geneses.PEZKUWI -> 0 - Chain.Geneses.PEZKUWI_ASSET_HUB -> 1 - Chain.Geneses.PEZKUWI_PEOPLE -> 2 + genesisHash == Chain.Geneses.PEZKUWI -> 0 + genesisHash == Chain.Geneses.PEZKUWI_ASSET_HUB -> 1 + genesisHash == Chain.Geneses.PEZKUWI_PEOPLE -> 2 // Then Polkadot ecosystem - Chain.Geneses.POLKADOT -> 3 - Chain.Geneses.POLKADOT_ASSET_HUB -> 4 + genesisHash == Chain.Geneses.POLKADOT -> 3 + genesisHash == Chain.Geneses.POLKADOT_ASSET_HUB -> 4 // Then Kusama ecosystem - Chain.Geneses.KUSAMA -> 5 - Chain.Geneses.KUSAMA_ASSET_HUB -> 6 + genesisHash == Chain.Geneses.KUSAMA -> 5 + genesisHash == Chain.Geneses.KUSAMA_ASSET_HUB -> 6 + // Then Ethereum, then Tron - not identified by genesisHash (that's substrate-only), so this can't + // stay a `when (genesisHash)` subject match once these two are added + id == Chain.Ids.ETHEREUM -> 7 + id == Chain.Ids.TRON -> 8 // Everything else - else -> 7 + else -> 9 } val Chain.testnetsLastAscendingOrder diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt index 4138cac6..8278c4d9 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/ext/TokenSorting.kt @@ -10,7 +10,14 @@ val TokenSymbol.mainTokensFirstAscendingOrder "DOT" -> 3 "KSM" -> 4 "USDC" -> 5 - else -> 6 + "TRX" -> 6 + "BTC" -> 7 + "ETH" -> 8 + "BNB" -> 9 + "AVAX" -> 10 + "LINK" -> 11 + "TAO" -> 12 + else -> 13 } val TokenSymbol.alphabeticalOrder diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt index 1ae735b7..56259063 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/ChainRegistry.kt @@ -7,7 +7,7 @@ import io.novafoundation.nova.common.utils.RuntimeContext import io.novafoundation.nova.common.utils.diffed import io.novafoundation.nova.common.utils.filterList import io.novafoundation.nova.common.utils.inBackground -import io.novafoundation.nova.common.utils.mapList +import io.novafoundation.nova.common.utils.mapListNotNull import io.novafoundation.nova.common.utils.mapNotNullToSet import io.novafoundation.nova.common.utils.provideContext import io.novafoundation.nova.common.utils.removeHexPrefix @@ -41,6 +41,7 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro import io.novasama.substrate_sdk_android.wsrpc.SocketService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.distinctUntilChanged @@ -67,14 +68,37 @@ class ChainRegistry( private val runtimeSyncService: RuntimeSyncService, private val web3ApiPool: Web3ApiPool, private val gson: Gson -) : CoroutineScope by CoroutineScope(Dispatchers.Default) { + // SupervisorJob, not the plain Job a bare CoroutineScope(Dispatchers.Default) would give: without it, an + // uncaught exception in ANY coroutine sharing this scope (e.g. currentChains'/chainsById's shareIn, or any + // launch{} below) cancels every sibling, including the other one - a single malformed/leftover chain row + // would then permanently kill sync for every chain, not just the offending one. +) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) { val currentChains = chainDao.joinChainInfoFlow() - .mapList { mapChainLocalToChain(it, gson) } + // mapListNotNull, not mapList: mapChainLocalToChain() can throw on a single malformed row (e.g. a + // gson.fromJson() failure on the chain's `additional` JSON blob) - since this whole step runs as ONE + // transform over the ENTIRE chain list, one bad chain would previously throw out of this operator and + // permanently kill this Eagerly-shared flow for every chain, not just the offending one. Skip and log + // instead, matching the per-chain isolation already applied to registerChain/unregisterChain below. + .mapListNotNull { chainLocal -> + runCatching { mapChainLocalToChain(chainLocal, gson) } + .onFailure { Log.e(LOG_TAG, "Failed to map chain ${chainLocal.chain.id} (${chainLocal.chain.name}) from local DB", it) } + .getOrNull() + } .diffed() .map { diff -> - diff.removed.forEach { unregisterChain(it) } - diff.newOrUpdated.forEach { chain -> registerChain(chain) } + // Each chain's register/unregister is isolated: one malformed/leftover row (e.g. a chain persisted + // as disabled from an earlier session) must not throw out of this operator and kill this flow for + // every other chain - shareIn(..., Eagerly) never restarts once its upstream completes/throws, so + // any single unhandled exception here would silently and permanently break sync for the whole app. + diff.removed.forEach { chain -> + runCatching { unregisterChain(chain) } + .onFailure { Log.e(LOG_TAG, "Failed to unregister chain ${chain.name} (${chain.id})", it) } + } + diff.newOrUpdated.forEach { chain -> + runCatching { registerChain(chain) } + .onFailure { Log.e(LOG_TAG, "Failed to register chain ${chain.name} (${chain.id})", it) } + } diff.all } @@ -221,11 +245,11 @@ class ChainRegistry( } private suspend fun registerConnection(chain: Chain): ChainConnection? { - // Tron nodes are plain REST APIs (TronGrid), not WSS JSON-RPC endpoints - ChainConnection's - // SocketService can only speak the latter, so attempting to set one up here would hang - // indefinitely instead of failing fast. Tron balance/transfer operations already go through - // their own dedicated TronGridApi client, independent of this connection pool. - if (chain.isTronBased) return null + // Tron/Bitcoin nodes are plain REST APIs (TronGrid / mempool.space), not WSS JSON-RPC endpoints - + // ChainConnection's SocketService can only speak the latter, so attempting to set one up here would + // hang indefinitely instead of failing fast. Balance/transfer operations for both go through their + // own dedicated REST API clients, independent of this connection pool. + if (chain.isTronBased || chain.isBitcoinBased) return null val connection = connectionPool.setupConnection(chain) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt index 36d84d84..b2b19b89 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/asset/EvmAssetsSyncService.kt @@ -1,7 +1,9 @@ package io.novafoundation.nova.runtime.multiNetwork.asset +import android.util.Log import com.google.gson.Gson import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.LOG_TAG import io.novafoundation.nova.common.utils.retryUntilDone import io.novafoundation.nova.core_db.dao.ChainAssetDao import io.novafoundation.nova.core_db.dao.ChainDao @@ -40,6 +42,18 @@ class EvmAssetsSyncService( new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL) } + // Same defensive guard as ChainSyncService: a transient upstream issue can make the fetch return + // successfully with a suspiciously small/empty list. Diffing that against a populated local DB would + // delete most or all of the user's ERC20 tokens (e.g. USDT-ERC20) - skip instead of wiping good data. + if (oldAssets.isNotEmpty() && newAssets.size < oldAssets.size / 2) { + Log.e( + LOG_TAG, + "Refusing to apply EVM asset sync: remote returned ${newAssets.size} assets vs ${oldAssets.size} currently stored " + + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." + ) + return + } + val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false) chainAssetDao.updateAssets(diff) } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt index 057cbea7..5e571e0b 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/ChainSyncService.kt @@ -1,7 +1,9 @@ package io.novafoundation.nova.runtime.multiNetwork.chain +import android.util.Log import com.google.gson.Gson import io.novafoundation.nova.common.utils.CollectionDiffer +import io.novafoundation.nova.common.utils.LOG_TAG import io.novafoundation.nova.common.utils.retryUntilDone import io.novafoundation.nova.core_db.dao.ChainDao import io.novafoundation.nova.core_db.dao.FullAssetIdLocal @@ -40,17 +42,48 @@ class ChainSyncService( val remoteChains = retryUntilDone { chainFetcher.getChains() } - val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) } - val newAssets = remoteChains.flatMap { chain -> - chain.assets.map { - val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId) - val oldAsset = associatedOldAssets[fullAssetId] - mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL) + // A transient upstream issue (CDN hiccup, regional network filtering, a bad publish) can make + // chainFetcher.getChains() return successfully with a suspiciously small/empty list instead of + // throwing. Applying that as a diff against a populated local DB would delete most or all of the + // user's chains/assets - not a sync failure, but active data loss, for something that self-heals on + // the next successful sync if we just skip applying it. Only guard when we HAD data: an empty result + // on a genuinely first-ever sync is normal and must proceed. + if (oldChains.isNotEmpty() && remoteChains.size < oldChains.size / 2) { + Log.e( + LOG_TAG, + "Refusing to apply chain sync: remote returned ${remoteChains.size} chains vs ${oldChains.size} currently stored " + + "(would remove more than half). Likely a transient fetch issue - skipping this sync cycle." + ) + return@withContext + } + + // One malformed/incompatible chain (a new field the app's mapper doesn't understand yet, a bad + // publish, etc.) must not take down sync for every other chain - a plain .map{} here means a single + // throwing chain aborts before chainDao.applyDiff() is ever called, leaving a brand new install with + // zero locally-cached chains forever (a completely empty tokens list), since nothing else in this + // function ever gets a chance to run. Isolate failures per chain, and per asset within a chain that + // otherwise mapped fine, instead. + val remoteChainsWithLocal = remoteChains.mapNotNull { chainRemote -> + runCatching { chainRemote to mapRemoteChainToLocal(chainRemote, oldChainsById[chainRemote.chainId], source = ChainLocal.Source.DEFAULT, gson) } + .onFailure { Log.e(LOG_TAG, "Failed to map remote chain ${chainRemote.chainId} (${chainRemote.name}), skipping it for this sync cycle", it) } + .getOrNull() + } + + val newChains = remoteChainsWithLocal.map { (_, chainLocal) -> chainLocal } + val newAssets = remoteChainsWithLocal.flatMap { (chain, _) -> + chain.assets.mapNotNull { assetRemote -> + runCatching { + val fullAssetId = FullAssetIdLocal(chain.chainId, assetRemote.assetId) + val oldAsset = associatedOldAssets[fullAssetId] + mapRemoteAssetToLocal(chain, assetRemote, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL) + }.onFailure { + Log.e(LOG_TAG, "Failed to map asset ${assetRemote.assetId} (${assetRemote.symbol}) on chain ${chain.chainId}, skipping it", it) + }.getOrNull() } } - val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal) - val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal) - val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal) + val newNodes = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteNodesToLocal(chain) } + val newExplorers = remoteChainsWithLocal.flatMap { (chain, _) -> mapRemoteExplorersToLocal(chain) } + val newExternalApis = remoteChainsWithLocal.flatMap { (chain, _) -> mapExternalApisToLocal(chain) } val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences) val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt index f5925053..9430b07e 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/ChainMappersConstants.kt @@ -12,6 +12,8 @@ const val ASSET_EVM_NATIVE = "evmNative" const val ASSET_TRON_NATIVE = "tronNative" const val ASSET_TRC20 = "trc20" +const val ASSET_BITCOIN_NATIVE = "bitcoinNative" + const val ASSET_EQUILIBRIUM = "equilibrium" const val ASSET_EQUILIBRIUM_ON_CHAIN_ID = "assetId" diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt index 0a61c94e..ff2011c9 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/DomainToLocalChainMapper.kt @@ -69,6 +69,8 @@ fun mapChainAssetTypeToRaw(type: Chain.Asset.Type): Pair ASSET_BITCOIN_NATIVE to null + Chain.Asset.Type.Unsupported -> ASSET_UNSUPPORTED to null } @@ -128,6 +130,7 @@ fun mapChainToLocal(chain: Chain, gson: Gson): ChainLocal { legacyPrefix = chain.legacyAddressPrefix, isEthereumBased = chain.isEthereumBased, isTronBased = chain.isTronBased, + isBitcoinBased = chain.isBitcoinBased, isTestNet = chain.isTestNet, hasSubstrateRuntime = chain.hasSubstrateRuntime, pushSupport = chain.pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt index 8b85a865..27f9ffc3 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/LocalToDomainChainMapper.kt @@ -95,6 +95,8 @@ private fun mapChainAssetTypeFromRaw(type: String?, typeExtras: Map Chain.Asset.Type.Equilibrium((typeExtras!![ASSET_EQUILIBRIUM_ON_CHAIN_ID] as String).toBigInteger()) + ASSET_BITCOIN_NATIVE -> Chain.Asset.Type.BitcoinNative + else -> Chain.Asset.Type.Unsupported } } @@ -276,6 +278,7 @@ fun mapChainLocalToChain( legacyAddressPrefix = legacyPrefix, isEthereumBased = isEthereumBased, isTronBased = isTronBased, + isBitcoinBased = isBitcoinBased, isTestNet = isTestNet, hasCrowdloans = hasCrowdloans, pushSupport = pushSupport, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt index 8f1b36b1..bfa3e342 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/mappers/RemoteToLocalChainMappers.kt @@ -21,6 +21,7 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.remote.model.ChainRemot private const val ETHEREUM_OPTION = "ethereumBased" private const val TRON_OPTION = "tronBased" +private const val BITCOIN_OPTION = "bitcoinBased" private const val CROWDLOAN_OPTION = "crowdloans" private const val TESTNET_OPTION = "testnet" private const val PROXY_OPTION = "proxy" @@ -92,6 +93,7 @@ fun mapRemoteChainToLocal( legacyPrefix = legacyAddressPrefix, isEthereumBased = ETHEREUM_OPTION in optionsOrEmpty, isTronBased = TRON_OPTION in optionsOrEmpty, + isBitcoinBased = BITCOIN_OPTION in optionsOrEmpty, isTestNet = TESTNET_OPTION in optionsOrEmpty, hasCrowdloans = CROWDLOAN_OPTION in optionsOrEmpty, supportProxy = PROXY_OPTION in optionsOrEmpty, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt index 03801dec..e4be32b0 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/chain/model/Chain.kt @@ -33,6 +33,7 @@ data class Chain( val types: Types?, val isEthereumBased: Boolean, val isTronBased: Boolean, + val isBitcoinBased: Boolean, val isTestNet: Boolean, val source: Source, val hasSubstrateRuntime: Boolean, @@ -138,6 +139,12 @@ data class Chain( val contractAddress: String ) : Type() + /** + * Native BTC balance on a Bitcoin-based chain. + * Balance is fetched from a mempool.space-style REST API rather than JSON-RPC. + */ + object BitcoinNative : Type() + object Unsupported : Type() } diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt new file mode 100644 index 00000000..a134f35a --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/BitcoinNodeHealthStateTester.kt @@ -0,0 +1,37 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +/** + * mempool.space speaks a plain REST API, not Ethereum JSON-RPC - see [TronNodeHealthStateTester]'s doc for the + * identical rationale this mirrors. `GET /blocks/tip/height` needs no account/address context and is cheap on + * mempool.space's side, making it a good generic liveness ping. + */ +class BitcoinNodeHealthStateTester( + private val node: Chain.Node, + private val httpClient: OkHttpClient, +) : NodeHealthStateTester { + + @OptIn(ExperimentalTime::class) + override suspend fun testNodeHealthState(): Result = withContext(Dispatchers.IO) { + runCatching { + val request = Request.Builder() + .url("${node.unformattedUrl.trimEnd('/')}/blocks/tip/height") + .build() + + val duration = measureTime { + httpClient.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + duration.inWholeMilliseconds + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt index b9986a1b..2588da8b 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/NodeHealthStateTesterFactory.kt @@ -5,6 +5,7 @@ import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets import io.novasama.substrate_sdk_android.wsrpc.SocketService +import okhttp3.OkHttpClient import javax.inject.Provider import kotlinx.coroutines.CoroutineScope @@ -12,15 +13,26 @@ class NodeHealthStateTesterFactory( private val socketServiceProvider: Provider, private val connectionSecrets: ConnectionSecrets, private val bulkRetriever: BulkRetriever, - private val web3ApiFactory: Web3ApiFactory + private val web3ApiFactory: Web3ApiFactory, + private val httpClient: OkHttpClient, ) { fun create(chain: Chain, node: Chain.Node, coroutineScope: CoroutineScope): NodeHealthStateTester { val nodeIsSupported = chain.nodes.nodes.any { it.unformattedUrl == node.unformattedUrl } require(nodeIsSupported) - return if (chain.hasSubstrateRuntime) { - SubstrateNodeHealthStateTester( + return when { + chain.isBitcoinBased -> BitcoinNodeHealthStateTester( + node = node, + httpClient = httpClient + ) + + chain.isTronBased -> TronNodeHealthStateTester( + node = node, + httpClient = httpClient + ) + + chain.hasSubstrateRuntime -> SubstrateNodeHealthStateTester( chain = chain, socketService = socketServiceProvider.get(), connectionSecrets = connectionSecrets, @@ -28,8 +40,8 @@ class NodeHealthStateTesterFactory( node = node, coroutineScope = coroutineScope ) - } else { - EthereumNodeHealthStateTester( + + else -> EthereumNodeHealthStateTester( socketService = socketServiceProvider.get(), connectionSecrets = connectionSecrets, node = node, diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt new file mode 100644 index 00000000..c19576ac --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/node/healthState/TronNodeHealthStateTester.kt @@ -0,0 +1,41 @@ +package io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState + +import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +/** + * TronGrid speaks a plain REST API, not Ethereum JSON-RPC - reusing [EthereumNodeHealthStateTester] against it + * (as this codebase used to, before Tron nodes were included in health checks at all) would send an + * `eth_getBalance` call TronGrid doesn't understand, always reporting the node as unreachable regardless of its + * actual health. `GET /wallet/getchainparameters` needs no account/address context and is cheap on TronGrid's + * side, making it a good generic liveness ping - same endpoint this codebase already uses elsewhere + * (`TronGridApi.getChainParameters`), just called directly here since `runtime` cannot depend on + * `feature-wallet-impl` (wrong direction) to reuse that Retrofit interface. + */ +class TronNodeHealthStateTester( + private val node: Chain.Node, + private val httpClient: OkHttpClient, +) : NodeHealthStateTester { + + @OptIn(ExperimentalTime::class) + override suspend fun testNodeHealthState(): Result = withContext(Dispatchers.IO) { + runCatching { + val request = Request.Builder() + .url("${node.unformattedUrl.trimEnd('/')}/wallet/getchainparameters") + .build() + + val duration = measureTime { + httpClient.newCall(request).execute().use { response -> + check(response.isSuccessful) { "HTTP ${response.code}" } + } + } + + duration.inWholeMilliseconds + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/BitcoinUriQrFormat.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/BitcoinUriQrFormat.kt new file mode 100644 index 00000000..bf54d9eb --- /dev/null +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/BitcoinUriQrFormat.kt @@ -0,0 +1,36 @@ +package io.novafoundation.nova.runtime.multiNetwork.qr + +import io.novasama.substrate_sdk_android.encrypt.qr.PublicQrFormat +import io.novasama.substrate_sdk_android.encrypt.qr.QrFormat + +private const val BITCOIN_URI_SCHEME = "bitcoin:" + +/** + * BIP21 URI (`bitcoin:
?amount=...&label=...`) - the standard QR payload most wallets/exchanges generate + * for a Bitcoin receive address. Neither `SubstrateQrFormat` (expects a `substrate:
:` triple) + * nor `AddressQrFormat` (treats the whole QR content as a bare address) recognize this, so scanning an external + * wallet's or exchange's BTC address QR failed outright with the generic "QR can't be decoded" error - confirmed + * live against a real BTC withdrawal QR. + */ +class BitcoinUriQrFormat( + private val addressValidator: (String) -> Boolean +) : PublicQrFormat { + + override fun encode(payload: PublicQrFormat.Payload): String { + return "$BITCOIN_URI_SCHEME${payload.address}" + } + + override fun decode(qrContent: String): PublicQrFormat.Payload { + if (!qrContent.startsWith(BITCOIN_URI_SCHEME, ignoreCase = true)) { + throw QrFormat.InvalidFormatException("Not a bitcoin: URI") + } + + val address = qrContent.substring(BITCOIN_URI_SCHEME.length).substringBefore('?') + + return if (addressValidator(address)) { + PublicQrFormat.Payload(address = address) + } else { + throw QrFormat.InvalidFormatException("Supplied bitcoin: URI has an invalid address") + } + } +} diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt index ec55265b..c4561f61 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/qr/MultiChainQrSharingFactory.kt @@ -8,10 +8,12 @@ class MultiChainQrSharingFactory { fun create(addressValidator: (String) -> Boolean): QrSharing { val substrateFormat = SubstrateQrFormat() + val bitcoinUriFormat = BitcoinUriQrFormat(addressValidator) val onlyAddressFormat = AddressQrFormat(addressValidator) val formats = listOf( substrateFormat, + bitcoinUriFormat, onlyAddressFormat ) diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt index 87e7df49..15750566 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/network/updaters/ChainUpdaterGroupUpdateSystem.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.merge import kotlin.coroutines.coroutineContext @@ -24,35 +26,51 @@ abstract class ChainUpdaterGroupUpdateSystem( private val storageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory, ) : UpdateSystem { + // Callers (MultiChainUpdateSystem, SingleChainUpdateSystem) merge several chains' runUpdaters() results + // into one flow. chainRegistry.getRuntime(chain.id) throws for a chain whose runtime isn't ready yet + // (including a disabled chain, via DisabledChainException) - if that throw escapes this function + // uncaught, it propagates through the merge and kills governance/staking/crowdloan sync for every OTHER + // chain in the group too, not just the failing one. Wrapping the whole body in flow{} + catch isolates + // that failure to this chain alone. protected suspend fun runUpdaters(chain: Chain, updaters: Collection>): Flow { - val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata + return flow { + val runtimeMetadata = chainRegistry.getRuntime(chain.id).metadata - val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG - val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName + val logTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG + val selfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName - val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) -> - scope.invalidationFlow().flatMapLatest { scopeValue -> - val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) + val scopeFlows = updaters.groupBy(Updater<*>::scope).map { (scope, scopeUpdaters) -> + scope.invalidationFlow().flatMapLatest { scopeValue -> + val subscriptionBuilder = storageSharedRequestsBuilderFactory.create(chain.id) - val updatersFlow = scopeUpdaters - .filter { it.requiredModules.all(runtimeMetadata::hasModule) } - .map { updater -> - @Suppress("UNCHECKED_CAST") - (updater as Updater).listenForUpdates(subscriptionBuilder, scopeValue) - .catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) } - .flowOn(Dispatchers.Default) + val updatersFlow = scopeUpdaters + .filter { it.requiredModules.all(runtimeMetadata::hasModule) } + .map { updater -> + @Suppress("UNCHECKED_CAST") + (updater as Updater).listenForUpdates(subscriptionBuilder, scopeValue) + .catch { Log.e(logTag, "Failed to start ${updater.javaClass.simpleName} in $selfName for ${chain.name}", it) } + .flowOn(Dispatchers.Default) + } + + if (updatersFlow.isNotEmpty()) { + subscriptionBuilder.subscribe(coroutineContext) + + updatersFlow.merge() + } else { + emptyFlow() } - - if (updatersFlow.isNotEmpty()) { - subscriptionBuilder.subscribe(coroutineContext) - - updatersFlow.merge() - } else { - emptyFlow() } } - } - return scopeFlows.merge() + emitAll(scopeFlows.merge()) + }.catch { error -> + // Explicitly qualified: unqualified LOG_TAG here would resolve against the nearest implicit + // receiver, which is this catch lambda's FlowCollector, not this class - Any.LOG_TAG applies to + // any receiver, so it would silently compile but log the wrong (unhelpful) tag. + val outerLogTag = this@ChainUpdaterGroupUpdateSystem.LOG_TAG + val outerSelfName = this@ChainUpdaterGroupUpdateSystem::class.java.simpleName + + Log.e(outerLogTag, "Failed to start updaters in $outerSelfName for ${chain.name}", error) + } } }