mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-23 18:55:47 +00:00
Merge branch 'feature/btc-native-send' into fix/multisig-first-signer-deep-link
# Conflicts: # feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt # feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt # feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt
This commit is contained in:
@@ -35,7 +35,7 @@ t.start()
|
|||||||
|
|
||||||
def run():
|
def run():
|
||||||
os.system('adb wait-for-device')
|
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)
|
shell=True, stdout=sp.PIPE, stderr=sp.PIPE, stdin=sp.PIPE)
|
||||||
return p.communicate()
|
return p.communicate()
|
||||||
success = re.compile(r'OK \(\d+ tests\)')
|
success = re.compile(r'OK \(\d+ tests\)')
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: Run balances tests
|
name: Run balances tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 */8 * * *'
|
- cron: '0 */8 * * *'
|
||||||
@@ -43,13 +44,50 @@ jobs:
|
|||||||
sudo udevadm control --reload-rules
|
sudo udevadm control --reload-rules
|
||||||
sudo udevadm trigger --name-match=kvm
|
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
|
- name: Run tests
|
||||||
|
id: run-tests-attempt-1
|
||||||
|
continue-on-error: true
|
||||||
uses: reactivecircus/android-emulator-runner@v2
|
uses: reactivecircus/android-emulator-runner@v2
|
||||||
with:
|
with:
|
||||||
disable-animations: true
|
disable-animations: true
|
||||||
profile: Nexus 6
|
profile: Nexus 6
|
||||||
api-level: 29
|
api-level: 29
|
||||||
arch: x86_64
|
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
|
script: .github/scripts/run_balances_test.sh
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
|
|||||||
@@ -18,7 +18,27 @@ runs:
|
|||||||
- name: Install NDK
|
- name: Install NDK
|
||||||
run: |
|
run: |
|
||||||
SDKMANAGER=$(find ${ANDROID_SDK_ROOT}/cmdline-tools -name sdkmanager -type f 2>/dev/null | head -1)
|
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
|
shell: bash
|
||||||
|
|
||||||
- name: Set ndk.dir in local.properties
|
- name: Set ndk.dir in local.properties
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ dependencies {
|
|||||||
androidTestImplementation androidTestRunnerDep
|
androidTestImplementation androidTestRunnerDep
|
||||||
androidTestImplementation androidTestRulesDep
|
androidTestImplementation androidTestRulesDep
|
||||||
androidTestImplementation androidJunitDep
|
androidTestImplementation androidJunitDep
|
||||||
|
androidTestImplementation scalarsConverterDep
|
||||||
|
|
||||||
androidTestImplementation allureKotlinModel
|
androidTestImplementation allureKotlinModel
|
||||||
androidTestImplementation allureKotlinCommons
|
androidTestImplementation allureKotlinCommons
|
||||||
|
|||||||
+154
@@ -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<AssetFixture>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Context>()
|
||||||
|
|
||||||
|
private val dbApi = FeatureUtils.getFeature<DbApi>(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<AssetsFeatureApi>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
+97
@@ -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 <T> 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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext {
|
ext {
|
||||||
// App version
|
// App version
|
||||||
versionName = '1.1.1'
|
versionName = '1.1.2'
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
|
|
||||||
applicationId = "io.pezkuwichain.wallet"
|
applicationId = "io.pezkuwichain.wallet"
|
||||||
|
|||||||
+20
@@ -28,6 +28,9 @@ object MetaAccountSecrets : Schema<MetaAccountSecrets>() {
|
|||||||
|
|
||||||
val TronKeypair by schema(KeyPairSchema).optional()
|
val TronKeypair by schema(KeyPairSchema).optional()
|
||||||
val TronDerivationPath by string().optional()
|
val TronDerivationPath by string().optional()
|
||||||
|
|
||||||
|
val BitcoinKeypair by schema(KeyPairSchema).optional()
|
||||||
|
val BitcoinDerivationPath by string().optional()
|
||||||
}
|
}
|
||||||
|
|
||||||
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
|
object ChainAccountSecrets : Schema<ChainAccountSecrets>() {
|
||||||
@@ -47,6 +50,8 @@ fun MetaAccountSecrets(
|
|||||||
ethereumDerivationPath: String? = null,
|
ethereumDerivationPath: String? = null,
|
||||||
tronKeypair: Keypair? = null,
|
tronKeypair: Keypair? = null,
|
||||||
tronDerivationPath: String? = null,
|
tronDerivationPath: String? = null,
|
||||||
|
bitcoinKeypair: Keypair? = null,
|
||||||
|
bitcoinDerivationPath: String? = null,
|
||||||
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
|
): EncodableStruct<MetaAccountSecrets> = MetaAccountSecrets { secrets ->
|
||||||
secrets[Entropy] = entropy
|
secrets[Entropy] = entropy
|
||||||
secrets[SubstrateSeed] = substrateSeed
|
secrets[SubstrateSeed] = substrateSeed
|
||||||
@@ -75,6 +80,15 @@ fun MetaAccountSecrets(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
secrets[TronDerivationPath] = tronDerivationPath
|
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(
|
fun ChainAccountSecrets(
|
||||||
@@ -103,6 +117,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumDerivationPath
|
|||||||
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
|
val EncodableStruct<MetaAccountSecrets>.tronDerivationPath
|
||||||
get() = get(MetaAccountSecrets.TronDerivationPath)
|
get() = get(MetaAccountSecrets.TronDerivationPath)
|
||||||
|
|
||||||
|
val EncodableStruct<MetaAccountSecrets>.bitcoinDerivationPath
|
||||||
|
get() = get(MetaAccountSecrets.BitcoinDerivationPath)
|
||||||
|
|
||||||
val EncodableStruct<MetaAccountSecrets>.entropy
|
val EncodableStruct<MetaAccountSecrets>.entropy
|
||||||
get() = get(MetaAccountSecrets.Entropy)
|
get() = get(MetaAccountSecrets.Entropy)
|
||||||
|
|
||||||
@@ -118,6 +135,9 @@ val EncodableStruct<MetaAccountSecrets>.ethereumKeypair
|
|||||||
val EncodableStruct<MetaAccountSecrets>.tronKeypair
|
val EncodableStruct<MetaAccountSecrets>.tronKeypair
|
||||||
get() = get(MetaAccountSecrets.TronKeypair)
|
get() = get(MetaAccountSecrets.TronKeypair)
|
||||||
|
|
||||||
|
val EncodableStruct<MetaAccountSecrets>.bitcoinKeypair
|
||||||
|
get() = get(MetaAccountSecrets.BitcoinKeypair)
|
||||||
|
|
||||||
val EncodableStruct<ChainAccountSecrets>.derivationPath
|
val EncodableStruct<ChainAccountSecrets>.derivationPath
|
||||||
get() = get(ChainAccountSecrets.DerivationPath)
|
get() = get(ChainAccountSecrets.DerivationPath)
|
||||||
|
|
||||||
|
|||||||
+21
-6
@@ -156,20 +156,28 @@ val AccountSecrets.isChainAccountSecrets
|
|||||||
suspend fun SecretStoreV2.getMetaAccountKeypair(
|
suspend fun SecretStoreV2.getMetaAccountKeypair(
|
||||||
metaId: Long,
|
metaId: Long,
|
||||||
isEthereum: Boolean,
|
isEthereum: Boolean,
|
||||||
|
isTron: Boolean = false,
|
||||||
|
isBitcoin: Boolean = false,
|
||||||
): Keypair = withContext(Dispatchers.Default) {
|
): Keypair = withContext(Dispatchers.Default) {
|
||||||
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
|
val secrets = getMetaAccountSecrets(metaId) ?: noMetaSecrets(metaId)
|
||||||
|
|
||||||
mapMetaAccountSecretsToKeypair(secrets, isEthereum)
|
mapMetaAccountSecretsToKeypair(secrets, isEthereum, isTron, isBitcoin)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun mapMetaAccountSecretsToKeypair(
|
fun mapMetaAccountSecretsToKeypair(
|
||||||
secrets: EncodableStruct<MetaAccountSecrets>,
|
secrets: EncodableStruct<MetaAccountSecrets>,
|
||||||
ethereum: Boolean,
|
ethereum: Boolean,
|
||||||
|
tron: Boolean = false,
|
||||||
|
bitcoin: Boolean = false,
|
||||||
): Keypair {
|
): Keypair {
|
||||||
val keypairStruct = if (ethereum) {
|
// Tron and Bitcoin both reuse Ethereum's secp256k1 curve but derive their own keypair under a different
|
||||||
secrets[MetaAccountSecrets.EthereumKeypair] ?: noEthereumSecret()
|
// SLIP-44 path - each must be checked before `ethereum`, not folded into it, or that account would get
|
||||||
} else {
|
// signed with the wrong (Ethereum) private key.
|
||||||
secrets[MetaAccountSecrets.SubstrateKeypair]
|
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)
|
return mapKeypairStructToKeypair(keypairStruct)
|
||||||
@@ -178,8 +186,11 @@ fun mapMetaAccountSecretsToKeypair(
|
|||||||
fun mapMetaAccountSecretsToDerivationPath(
|
fun mapMetaAccountSecretsToDerivationPath(
|
||||||
secrets: EncodableStruct<MetaAccountSecrets>,
|
secrets: EncodableStruct<MetaAccountSecrets>,
|
||||||
ethereum: Boolean,
|
ethereum: Boolean,
|
||||||
|
bitcoin: Boolean = false,
|
||||||
): String? {
|
): String? {
|
||||||
return if (ethereum) {
|
return if (bitcoin) {
|
||||||
|
secrets[MetaAccountSecrets.BitcoinDerivationPath]
|
||||||
|
} else if (ethereum) {
|
||||||
secrets[MetaAccountSecrets.EthereumDerivationPath]
|
secrets[MetaAccountSecrets.EthereumDerivationPath]
|
||||||
} else {
|
} else {
|
||||||
secrets[MetaAccountSecrets.SubstrateDerivationPath]
|
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 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<KeyPairSchema>): Keypair {
|
fun mapKeypairStructToKeypair(struct: EncodableStruct<KeyPairSchema>): Keypair {
|
||||||
return Keypair(
|
return Keypair(
|
||||||
publicKey = struct[KeyPairSchema.PublicKey],
|
publicKey = struct[KeyPairSchema.PublicKey],
|
||||||
|
|||||||
@@ -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<Int>()
|
||||||
|
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() })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> <hash160>`, 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 }
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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<BitcoinInput>): ByteArray =
|
||||||
|
inputs.fold(ByteArray(0)) { acc, input -> acc + serializeOutpoint(input) }.sha256d()
|
||||||
|
|
||||||
|
private fun hashSequence(inputs: List<BitcoinInput>): 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<BitcoinOutput>): 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<BitcoinInput>,
|
||||||
|
outputs: List<BitcoinOutput>,
|
||||||
|
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<BitcoinInput>,
|
||||||
|
outputs: List<BitcoinOutput>,
|
||||||
|
witnesses: List<Pair<ByteArray, ByteArray>>,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.asEthereumPublicKey
|
||||||
import io.novasama.substrate_sdk_android.extensions.toAccountId
|
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 io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
import java.math.BigInteger
|
import java.math.BigInteger
|
||||||
|
|
||||||
@@ -113,3 +114,21 @@ fun String.tronAddressToAccountId(): AccountId {
|
|||||||
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
|
fun String.isValidTronAddress(): Boolean = runCatching { tronAddressToAccountId() }.isSuccess
|
||||||
|
|
||||||
fun emptyTronAccountId() = ByteArray(20) { 1 }
|
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()
|
||||||
|
|||||||
@@ -355,6 +355,8 @@
|
|||||||
|
|
||||||
<!-- Bridge Screen -->
|
<!-- Bridge Screen -->
|
||||||
<string name="bridge_title">DOT ↔ HEZ Bridge</string>
|
<string name="bridge_title">DOT ↔ HEZ Bridge</string>
|
||||||
|
<string name="bridge_pair_dot_hez">HEZ ⇄ DOT</string>
|
||||||
|
<string name="bridge_pair_usdt">USDT ⇄ USDT.p</string>
|
||||||
<string name="bridge_you_send">You send</string>
|
<string name="bridge_you_send">You send</string>
|
||||||
<string name="bridge_you_receive">You receive (estimated)</string>
|
<string name="bridge_you_receive">You receive (estimated)</string>
|
||||||
<string name="bridge_exchange_rate">Exchange rate</string>
|
<string name="bridge_exchange_rate">Exchange rate</string>
|
||||||
|
|||||||
@@ -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<Int, ByteArray> {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,18 @@ class TronAddressTest {
|
|||||||
assertTrue(decodedBack.contentEquals(accountId))
|
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
|
@Test
|
||||||
fun `isValidTronAddress should accept known good address`() {
|
fun `isValidTronAddress should accept known good address`() {
|
||||||
assertTrue(knownTronAddress.isValidTronAddress())
|
assertTrue(knownTronAddress.isValidTronAddress())
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fun chainOf(
|
|||||||
isTestNet = false,
|
isTestNet = false,
|
||||||
isEthereumBased = false,
|
isEthereumBased = false,
|
||||||
isTronBased = false,
|
isTronBased = false,
|
||||||
|
isBitcoinBased = false,
|
||||||
hasCrowdloans = false,
|
hasCrowdloans = false,
|
||||||
additional = "",
|
additional = "",
|
||||||
governance = "governance",
|
governance = "governance",
|
||||||
|
|||||||
@@ -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.AddSwapOption_48_49
|
||||||
import io.novafoundation.nova.core_db.migrations.AddTransactionVersionToRuntime_50_51
|
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.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.AddTronSupport_73_74
|
||||||
import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69
|
import io.novafoundation.nova.core_db.migrations.AddTypeExtrasToMetaAccount_68_69
|
||||||
import io.novafoundation.nova.core_db.migrations.AddVersioningToGovernanceDapps_32_33
|
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
|
import io.novafoundation.nova.core_db.model.operation.TransferTypeLocal
|
||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
version = 74,
|
version = 75,
|
||||||
entities = [
|
entities = [
|
||||||
AccountLocal::class,
|
AccountLocal::class,
|
||||||
NodeLocal::class,
|
NodeLocal::class,
|
||||||
@@ -273,6 +274,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
.addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71)
|
.addMigrations(AddTypeExtrasToMetaAccount_68_69, AddMultisigCalls_69_70, AddMultisigSupportFlag_70_71)
|
||||||
.addMigrations(AddGifts_71_72, AddFieldsToContributions)
|
.addMigrations(AddGifts_71_72, AddFieldsToContributions)
|
||||||
.addMigrations(AddTronSupport_73_74)
|
.addMigrations(AddTronSupport_73_74)
|
||||||
|
.addMigrations(AddBitcoinSupport_74_75)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
return instance!!
|
return instance!!
|
||||||
|
|||||||
@@ -164,7 +164,12 @@ abstract class ChainDao {
|
|||||||
|
|
||||||
// ------- Queries ------
|
// ------- 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
|
@Transaction
|
||||||
abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo>
|
abstract suspend fun getJoinChainInfo(): List<JoinedChainInfo>
|
||||||
|
|
||||||
@@ -172,7 +177,7 @@ abstract class ChainDao {
|
|||||||
@Transaction
|
@Transaction
|
||||||
abstract suspend fun getAllChainIds(): List<String>
|
abstract suspend fun getAllChainIds(): List<String>
|
||||||
|
|
||||||
@Query("SELECT * FROM chains")
|
@Query("SELECT * FROM chains ORDER BY rowid")
|
||||||
@Transaction
|
@Transaction
|
||||||
abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>>
|
abstract fun joinChainInfoFlow(): Flow<List<JoinedChainInfo>>
|
||||||
|
|
||||||
|
|||||||
+14
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ data class ChainLocal(
|
|||||||
val isEthereumBased: Boolean,
|
val isEthereumBased: Boolean,
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val isTronBased: Boolean,
|
val isTronBased: Boolean,
|
||||||
|
@ColumnInfo(defaultValue = "0")
|
||||||
|
val isBitcoinBased: Boolean,
|
||||||
val isTestNet: Boolean,
|
val isTestNet: Boolean,
|
||||||
@ColumnInfo(defaultValue = "1")
|
@ColumnInfo(defaultValue = "1")
|
||||||
val hasSubstrateRuntime: Boolean,
|
val hasSubstrateRuntime: Boolean,
|
||||||
|
|||||||
+65
@@ -39,6 +39,8 @@ class MetaAccountLocal(
|
|||||||
val typeExtras: SerializedJson?,
|
val typeExtras: SerializedJson?,
|
||||||
val tronPublicKey: ByteArray? = null,
|
val tronPublicKey: ByteArray? = null,
|
||||||
val tronAddress: ByteArray? = null,
|
val tronAddress: ByteArray? = null,
|
||||||
|
val bitcoinPublicKey: ByteArray? = null,
|
||||||
|
val bitcoinAddress: ByteArray? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
enum class Status {
|
enum class Status {
|
||||||
@@ -59,6 +61,9 @@ class MetaAccountLocal(
|
|||||||
const val TRON_PUBKEY = "tronPublicKey"
|
const val TRON_PUBKEY = "tronPublicKey"
|
||||||
const val TRON_ADDRESS = "tronAddress"
|
const val TRON_ADDRESS = "tronAddress"
|
||||||
|
|
||||||
|
const val BITCOIN_PUBKEY = "bitcoinPublicKey"
|
||||||
|
const val BITCOIN_ADDRESS = "bitcoinAddress"
|
||||||
|
|
||||||
const val NAME = "name"
|
const val NAME = "name"
|
||||||
const val IS_SELECTED = "isSelected"
|
const val IS_SELECTED = "isSelected"
|
||||||
const val POSITION = "position"
|
const val POSITION = "position"
|
||||||
@@ -74,6 +79,62 @@ class MetaAccountLocal(
|
|||||||
fun addEvmAccount(
|
fun addEvmAccount(
|
||||||
ethereumPublicKey: ByteArray,
|
ethereumPublicKey: ByteArray,
|
||||||
ethereumAddress: 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 {
|
): MetaAccountLocal {
|
||||||
return MetaAccountLocal(
|
return MetaAccountLocal(
|
||||||
substratePublicKey = substratePublicKey,
|
substratePublicKey = substratePublicKey,
|
||||||
@@ -109,6 +170,8 @@ class MetaAccountLocal(
|
|||||||
if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false
|
if (!ethereumAddress.contentEquals(other.ethereumAddress)) return false
|
||||||
if (!tronPublicKey.contentEquals(other.tronPublicKey)) return false
|
if (!tronPublicKey.contentEquals(other.tronPublicKey)) return false
|
||||||
if (!tronAddress.contentEquals(other.tronAddress)) 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 (name != other.name) return false
|
||||||
if (parentMetaId != other.parentMetaId) return false
|
if (parentMetaId != other.parentMetaId) return false
|
||||||
if (isSelected != other.isSelected) return false
|
if (isSelected != other.isSelected) return false
|
||||||
@@ -130,6 +193,8 @@ class MetaAccountLocal(
|
|||||||
result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0)
|
result = 31 * result + (ethereumAddress?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0)
|
result = 31 * result + (tronPublicKey?.contentHashCode() ?: 0)
|
||||||
result = 31 * result + (tronAddress?.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 + name.hashCode()
|
||||||
result = 31 * result + (parentMetaId?.hashCode() ?: 0)
|
result = 31 * result + (parentMetaId?.hashCode() ?: 0)
|
||||||
result = 31 * result + isSelected.hashCode()
|
result = 31 * result + isSelected.hashCode()
|
||||||
|
|||||||
+25
@@ -63,6 +63,31 @@ class SubstrateFee(
|
|||||||
override val asset: Chain.Asset
|
override val asset: Chain.Asset
|
||||||
) : Fee
|
) : 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(
|
class SubstrateFeeBase(
|
||||||
override val amount: BigInteger,
|
override val amount: BigInteger,
|
||||||
override val asset: Chain.Asset
|
override val asset: Chain.Asset
|
||||||
|
|||||||
+2
-2
@@ -25,14 +25,14 @@ suspend fun SecretStoreV2.getAccountSecrets(
|
|||||||
|
|
||||||
fun AccountSecrets.keypair(chain: Chain): Keypair {
|
fun AccountSecrets.keypair(chain: Chain): Keypair {
|
||||||
return fold(
|
return fold(
|
||||||
left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased) },
|
left = { mapMetaAccountSecretsToKeypair(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) },
|
||||||
right = { mapChainAccountSecretsToKeypair(it) }
|
right = { mapChainAccountSecretsToKeypair(it) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun AccountSecrets.derivationPath(chain: Chain): String? {
|
fun AccountSecrets.derivationPath(chain: Chain): String? {
|
||||||
return fold(
|
return fold(
|
||||||
left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased) },
|
left = { mapMetaAccountSecretsToDerivationPath(it, ethereum = chain.isEthereumBased, bitcoin = chain.isBitcoinBased) },
|
||||||
right = { it[ChainAccountSecrets.DerivationPath] }
|
right = { it[ChainAccountSecrets.DerivationPath] }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -52,6 +52,10 @@ interface LightMetaAccount {
|
|||||||
*/
|
*/
|
||||||
val tronAddress: ByteArray?
|
val tronAddress: ByteArray?
|
||||||
val tronPublicKey: 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 isSelected: Boolean
|
||||||
val name: String
|
val name: String
|
||||||
val type: Type
|
val type: Type
|
||||||
@@ -90,6 +94,8 @@ fun LightMetaAccount(
|
|||||||
parentMetaId: Long?,
|
parentMetaId: Long?,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) = object : LightMetaAccount {
|
) = object : LightMetaAccount {
|
||||||
override val id: Long = id
|
override val id: Long = id
|
||||||
override val globallyUniqueId: String = globallyUniqueId
|
override val globallyUniqueId: String = globallyUniqueId
|
||||||
@@ -100,6 +106,8 @@ fun LightMetaAccount(
|
|||||||
override val ethereumPublicKey: ByteArray? = ethereumPublicKey
|
override val ethereumPublicKey: ByteArray? = ethereumPublicKey
|
||||||
override val tronAddress: ByteArray? = tronAddress
|
override val tronAddress: ByteArray? = tronAddress
|
||||||
override val tronPublicKey: ByteArray? = tronPublicKey
|
override val tronPublicKey: ByteArray? = tronPublicKey
|
||||||
|
override val bitcoinAddress: ByteArray? = bitcoinAddress
|
||||||
|
override val bitcoinPublicKey: ByteArray? = bitcoinPublicKey
|
||||||
override val isSelected: Boolean = isSelected
|
override val isSelected: Boolean = isSelected
|
||||||
override val name: String = name
|
override val name: String = name
|
||||||
override val type: LightMetaAccount.Type = type
|
override val type: LightMetaAccount.Type = type
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
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 {
|
dependencies {
|
||||||
|
|||||||
+41
-3
@@ -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.MetaAccountSecrets
|
||||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
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.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.entropy
|
||||||
import io.novafoundation.nova.common.data.secrets.v2.ethereumDerivationPath
|
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.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.seed
|
||||||
import io.novafoundation.nova.common.data.secrets.v2.substrateDerivationPath
|
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.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.filterNotNull
|
||||||
import io.novafoundation.nova.common.utils.findById
|
import io.novafoundation.nova.common.utils.findById
|
||||||
import io.novafoundation.nova.common.utils.mapToSet
|
import io.novafoundation.nova.common.utils.mapToSet
|
||||||
@@ -90,6 +94,8 @@ class RealLocalAccountsCloudBackupFacade(
|
|||||||
substrate = baseSecrets.getSubstrateBackupSecrets(),
|
substrate = baseSecrets.getSubstrateBackupSecrets(),
|
||||||
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
||||||
chainAccounts = emptyList(),
|
chainAccounts = emptyList(),
|
||||||
|
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||||
|
tron = baseSecrets.getTronBackupSecrets(),
|
||||||
)
|
)
|
||||||
|
|
||||||
return CloudBackup(
|
return CloudBackup(
|
||||||
@@ -357,6 +363,8 @@ class RealLocalAccountsCloudBackupFacade(
|
|||||||
substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo),
|
substrate = prepareSubstrateBackupSecrets(baseSecrets, joinedMetaAccountInfo),
|
||||||
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
ethereum = baseSecrets.getEthereumBackupSecrets(),
|
||||||
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
|
chainAccounts = chainAccountsFromChainSecrets + chainAccountFromAdditionalSecrets,
|
||||||
|
bitcoin = baseSecrets.getBitcoinBackupSecrets(),
|
||||||
|
tron = baseSecrets.getTronBackupSecrets(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +474,11 @@ class RealLocalAccountsCloudBackupFacade(
|
|||||||
substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null,
|
substrateKeyPair = substrate?.keypair?.toLocalKeyPair() ?: return null,
|
||||||
substrateDerivationPath = substrate?.derivationPath,
|
substrateDerivationPath = substrate?.derivationPath,
|
||||||
ethereumKeypair = ethereum?.keypair?.toLocalKeyPair(),
|
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<MetaAccountSecrets>?.getBitcoinBackupSecrets(): CloudBackup.WalletPrivateInfo.BitcoinSecrets? {
|
||||||
|
if (this == null) return null
|
||||||
|
|
||||||
|
return CloudBackup.WalletPrivateInfo.BitcoinSecrets(
|
||||||
|
keypair = bitcoinKeypair?.toBackupKeypairSecrets() ?: return null,
|
||||||
|
derivationPath = bitcoinDerivationPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EncodableStruct<MetaAccountSecrets>?.getTronBackupSecrets(): CloudBackup.WalletPrivateInfo.TronSecrets? {
|
||||||
|
if (this == null) return null
|
||||||
|
|
||||||
|
return CloudBackup.WalletPrivateInfo.TronSecrets(
|
||||||
|
keypair = tronKeypair?.toBackupKeypairSecrets() ?: return null,
|
||||||
|
derivationPath = tronDerivationPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
|
private fun EncodableStruct<MetaAccountSecrets>?.getSubstrateBackupSecrets(): CloudBackup.WalletPrivateInfo.SubstrateSecrets? {
|
||||||
if (this == null) return null
|
if (this == null) return null
|
||||||
|
|
||||||
@@ -520,7 +550,11 @@ class RealLocalAccountsCloudBackupFacade(
|
|||||||
ethereumPublicKey = metaAccount.ethereumPublicKey,
|
ethereumPublicKey = metaAccount.ethereumPublicKey,
|
||||||
name = metaAccount.name,
|
name = metaAccount.name,
|
||||||
type = metaAccount.type.toBackupWalletType() ?: return null,
|
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,
|
isSelected = isSelected,
|
||||||
position = accountPosition,
|
position = accountPosition,
|
||||||
status = MetaAccountLocal.Status.ACTIVE,
|
status = MetaAccountLocal.Status.ACTIVE,
|
||||||
typeExtras = null
|
typeExtras = null,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
|
tronAddress = tronAddress,
|
||||||
|
tronPublicKey = tronPublicKey,
|
||||||
).also {
|
).also {
|
||||||
if (localIdOverwrite != null) {
|
if (localIdOverwrite != null) {
|
||||||
it.id = localIdOverwrite
|
it.id = localIdOverwrite
|
||||||
|
|||||||
+14
@@ -65,6 +65,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
status = mapMetaAccountStateFromLocal(status),
|
status = mapMetaAccountStateFromLocal(status),
|
||||||
@@ -82,6 +84,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
type = type,
|
type = type,
|
||||||
@@ -101,6 +105,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
type = type,
|
type = type,
|
||||||
@@ -119,6 +125,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
type = type,
|
type = type,
|
||||||
@@ -138,6 +146,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
type = type,
|
type = type,
|
||||||
@@ -165,6 +175,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
status = mapMetaAccountStateFromLocal(status),
|
status = mapMetaAccountStateFromLocal(status),
|
||||||
@@ -183,6 +195,8 @@ class AccountMappers(
|
|||||||
ethereumPublicKey = ethereumPublicKey,
|
ethereumPublicKey = ethereumPublicKey,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey,
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey,
|
||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
isSelected = isSelected,
|
isSelected = isSelected,
|
||||||
name = name,
|
name = name,
|
||||||
|
|||||||
+32
-5
@@ -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.mapMetaAccountTypeToLocal
|
||||||
import io.novafoundation.nova.feature_account_impl.data.mappers.mapMetaAccountWithBalanceFromLocal
|
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.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.ChainAccountInsertionData
|
||||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
|
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.migration.model.MetaAccountInsertionData
|
||||||
import io.novafoundation.nova.runtime.ext.accountIdOf
|
import io.novafoundation.nova.runtime.ext.accountIdOf
|
||||||
@@ -64,15 +66,40 @@ class AccountDataSourceImpl(
|
|||||||
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
private val secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
||||||
secretStoreV1: SecretStoreV1,
|
secretStoreV1: SecretStoreV1,
|
||||||
accountDataMigration: AccountDataMigration,
|
accountDataMigration: AccountDataMigration,
|
||||||
|
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||||
|
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||||
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
|
) : AccountDataSource, SecretStoreV1 by secretStoreV1 {
|
||||||
|
|
||||||
init {
|
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 (accountDataMigration.migrationNeeded()) {
|
||||||
if (migration.migrationNeeded()) {
|
accountDataMigration.migrate(::saveSecuritySource)
|
||||||
migration.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"}"
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -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.KeyPairSchema
|
||||||
import io.novafoundation.nova.common.data.secrets.v2.MetaAccountSecrets
|
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.substrateAccountId
|
||||||
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
||||||
import io.novafoundation.nova.core.model.CryptoType
|
import io.novafoundation.nova.core.model.CryptoType
|
||||||
@@ -31,6 +32,7 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
|
|||||||
val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey]
|
val substratePublicKey = secrets[MetaAccountSecrets.SubstrateKeypair][KeyPairSchema.PublicKey]
|
||||||
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
|
val ethereumPublicKey = secrets[MetaAccountSecrets.EthereumKeypair]?.get(KeyPairSchema.PublicKey)
|
||||||
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
|
val tronPublicKey = secrets[MetaAccountSecrets.TronKeypair]?.get(KeyPairSchema.PublicKey)
|
||||||
|
val bitcoinPublicKey = secrets[MetaAccountSecrets.BitcoinKeypair]?.get(KeyPairSchema.PublicKey)
|
||||||
|
|
||||||
return MetaAccountLocal(
|
return MetaAccountLocal(
|
||||||
substratePublicKey = substratePublicKey,
|
substratePublicKey = substratePublicKey,
|
||||||
@@ -47,7 +49,13 @@ class RealSecretsMetaAccountLocalFactory : SecretsMetaAccountLocalFactory {
|
|||||||
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
|
globallyUniqueId = MetaAccountLocal.generateGloballyUniqueId(),
|
||||||
typeExtras = null,
|
typeExtras = null,
|
||||||
tronPublicKey = tronPublicKey,
|
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()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+119
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
+128
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-1
@@ -34,6 +34,14 @@ import kotlinx.coroutines.withContext
|
|||||||
*/
|
*/
|
||||||
const val TRON_DEFAULT_DERIVATION_PATH = "//44//195//0/0/0"
|
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(
|
class AccountSecretsFactory(
|
||||||
private val JsonDecoder: JsonDecoder
|
private val JsonDecoder: JsonDecoder
|
||||||
) {
|
) {
|
||||||
@@ -131,6 +139,7 @@ class AccountSecretsFactory(
|
|||||||
ethereumDerivationPath: String?,
|
ethereumDerivationPath: String?,
|
||||||
accountSource: AccountSource,
|
accountSource: AccountSource,
|
||||||
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
|
tronDerivationPath: String? = TRON_DEFAULT_DERIVATION_PATH,
|
||||||
|
bitcoinDerivationPath: String? = BITCOIN_DEFAULT_DERIVATION_PATH,
|
||||||
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
|
): Result<MetaAccountSecrets> = withContext(Dispatchers.Default) {
|
||||||
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
|
val (substrateSecrets, substrateCryptoType) = chainAccountSecrets(
|
||||||
derivationPath = substrateDerivationPath,
|
derivationPath = substrateDerivationPath,
|
||||||
@@ -157,6 +166,16 @@ class AccountSecretsFactory(
|
|||||||
Bip32EcdsaKeypairFactory.generate(seed = seed, junctions = decodedTronDerivationPath?.junctions.orEmpty())
|
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<AccountSource.Mnemonic>()?.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(
|
val secrets = MetaAccountSecrets(
|
||||||
entropy = substrateSecrets[ChainAccountSecrets.Entropy],
|
entropy = substrateSecrets[ChainAccountSecrets.Entropy],
|
||||||
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
|
substrateSeed = substrateSecrets[ChainAccountSecrets.Seed],
|
||||||
@@ -165,7 +184,9 @@ class AccountSecretsFactory(
|
|||||||
ethereumKeypair = ethereumKeypair,
|
ethereumKeypair = ethereumKeypair,
|
||||||
ethereumDerivationPath = ethereumDerivationPath,
|
ethereumDerivationPath = ethereumDerivationPath,
|
||||||
tronKeypair = tronKeypair,
|
tronKeypair = tronKeypair,
|
||||||
tronDerivationPath = tronDerivationPath
|
tronDerivationPath = tronDerivationPath,
|
||||||
|
bitcoinKeypair = bitcoinKeypair,
|
||||||
|
bitcoinDerivationPath = bitcoinDerivationPath,
|
||||||
)
|
)
|
||||||
|
|
||||||
Result(secrets = secrets, cryptoType = substrateCryptoType)
|
Result(secrets = secrets, cryptoType = substrateCryptoType)
|
||||||
|
|||||||
+15
-3
@@ -142,11 +142,15 @@ class SecretsSigner(
|
|||||||
private suspend fun getKeypair(accountId: AccountId): Keypair {
|
private suspend fun getKeypair(accountId: AccountId): Keypair {
|
||||||
val chainsById = chainRegistry.chainsById()
|
val chainsById = chainRegistry.chainsById()
|
||||||
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
|
val multiChainEncryption = metaAccount.multiChainEncryptionFor(accountId, chainsById)!!
|
||||||
|
val isTronBased = metaAccount.tronAddress?.contentEquals(accountId) == true
|
||||||
|
val isBitcoinBased = metaAccount.bitcoinAddress?.contentEquals(accountId) == true
|
||||||
|
|
||||||
return secretStoreV2.getKeypair(
|
return secretStoreV2.getKeypair(
|
||||||
metaAccount = metaAccount,
|
metaAccount = metaAccount,
|
||||||
accountId = accountId,
|
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(
|
private suspend fun SecretStoreV2.getKeypair(
|
||||||
metaAccount: MetaAccount,
|
metaAccount: MetaAccount,
|
||||||
accountId: AccountId,
|
accountId: AccountId,
|
||||||
isEthereumBased: Boolean
|
isEthereumBased: Boolean,
|
||||||
|
isTronBased: Boolean = false,
|
||||||
|
isBitcoinBased: Boolean = false,
|
||||||
) = if (hasChainSecrets(metaAccount.id, accountId)) {
|
) = if (hasChainSecrets(metaAccount.id, accountId)) {
|
||||||
getChainAccountKeypair(metaAccount.id, accountId)
|
getChainAccountKeypair(metaAccount.id, accountId)
|
||||||
} else {
|
} else {
|
||||||
getMetaAccountKeypair(metaAccount.id, isEthereumBased)
|
getMetaAccountKeypair(metaAccount.id, isEthereumBased, isTronBased, isBitcoinBased)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,6 +179,12 @@ class SecretsSigner(
|
|||||||
return when {
|
return when {
|
||||||
substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
substrateAccountId.contentEquals(accountId) -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||||
ethereumAccountId().contentEquals(accountId) -> MultiChainEncryption.Ethereum
|
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 -> {
|
else -> {
|
||||||
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
|
val chainAccount = chainAccounts.values.firstOrNull { it.accountId.contentEquals(accountId) } ?: return null
|
||||||
val cryptoType = chainAccount.cryptoType ?: return null
|
val cryptoType = chainAccount.cryptoType ?: return null
|
||||||
|
|||||||
+27
-1
@@ -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.RealSecretsMetaAccountLocalFactory
|
||||||
import io.novafoundation.nova.feature_account_impl.data.repository.datasource.SecretsMetaAccountLocalFactory
|
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.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.secrets.AccountSecretsFactory
|
||||||
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
|
import io.novafoundation.nova.feature_account_impl.data.signer.signingContext.SigningContextFactory
|
||||||
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
|
import io.novafoundation.nova.feature_account_impl.di.AccountFeatureModule.BindsModule
|
||||||
@@ -402,10 +404,12 @@ class AccountFeatureModule {
|
|||||||
nodeDao: NodeDao,
|
nodeDao: NodeDao,
|
||||||
secretStoreV1: SecretStoreV1,
|
secretStoreV1: SecretStoreV1,
|
||||||
accountDataMigration: AccountDataMigration,
|
accountDataMigration: AccountDataMigration,
|
||||||
|
tronAddressBackfillMigration: TronAddressBackfillMigration,
|
||||||
metaAccountDao: MetaAccountDao,
|
metaAccountDao: MetaAccountDao,
|
||||||
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
secretsMetaAccountLocalFactory: SecretsMetaAccountLocalFactory,
|
||||||
secretStoreV2: SecretStoreV2,
|
secretStoreV2: SecretStoreV2,
|
||||||
accountMappers: AccountMappers,
|
accountMappers: AccountMappers,
|
||||||
|
bitcoinAddressBackfillMigration: BitcoinAddressBackfillMigration,
|
||||||
): AccountDataSource {
|
): AccountDataSource {
|
||||||
return AccountDataSourceImpl(
|
return AccountDataSourceImpl(
|
||||||
preferences,
|
preferences,
|
||||||
@@ -416,10 +420,22 @@ class AccountFeatureModule {
|
|||||||
secretStoreV2,
|
secretStoreV2,
|
||||||
secretsMetaAccountLocalFactory,
|
secretsMetaAccountLocalFactory,
|
||||||
secretStoreV1,
|
secretStoreV1,
|
||||||
accountDataMigration
|
accountDataMigration,
|
||||||
|
tronAddressBackfillMigration,
|
||||||
|
bitcoinAddressBackfillMigration
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideBitcoinAddressBackfillMigration(
|
||||||
|
secretStoreV2: SecretStoreV2,
|
||||||
|
metaAccountDao: MetaAccountDao,
|
||||||
|
accountSecretsFactory: AccountSecretsFactory,
|
||||||
|
): BitcoinAddressBackfillMigration {
|
||||||
|
return BitcoinAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideNodeHostValidator() = NodeHostValidator()
|
fun provideNodeHostValidator() = NodeHostValidator()
|
||||||
|
|
||||||
@@ -439,6 +455,16 @@ class AccountFeatureModule {
|
|||||||
return AccountDataMigration(preferences, encryptedPreferences, accountDao)
|
return AccountDataMigration(preferences, encryptedPreferences, accountDao)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideTronAddressBackfillMigration(
|
||||||
|
secretStoreV2: SecretStoreV2,
|
||||||
|
metaAccountDao: MetaAccountDao,
|
||||||
|
accountSecretsFactory: AccountSecretsFactory,
|
||||||
|
): TronAddressBackfillMigration {
|
||||||
|
return TronAddressBackfillMigration(secretStoreV2, metaAccountDao, accountSecretsFactory)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@FeatureScope
|
@FeatureScope
|
||||||
fun provideExternalAccountActions(
|
fun provideExternalAccountActions(
|
||||||
|
|||||||
+5
@@ -24,6 +24,8 @@ open class DefaultMetaAccount(
|
|||||||
override val parentMetaId: Long?,
|
override val parentMetaId: Long?,
|
||||||
override val tronAddress: ByteArray? = null,
|
override val tronAddress: ByteArray? = null,
|
||||||
override val tronPublicKey: ByteArray? = null,
|
override val tronPublicKey: ByteArray? = null,
|
||||||
|
override val bitcoinAddress: ByteArray? = null,
|
||||||
|
override val bitcoinPublicKey: ByteArray? = null,
|
||||||
) : MetaAccount {
|
) : MetaAccount {
|
||||||
|
|
||||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||||
@@ -34,6 +36,7 @@ open class DefaultMetaAccount(
|
|||||||
return when {
|
return when {
|
||||||
hasChainAccountIn(chain.id) -> true
|
hasChainAccountIn(chain.id) -> true
|
||||||
chain.isTronBased -> tronAddress != null
|
chain.isTronBased -> tronAddress != null
|
||||||
|
chain.isBitcoinBased -> bitcoinAddress != null
|
||||||
chain.isEthereumBased -> ethereumAddress != null
|
chain.isEthereumBased -> ethereumAddress != null
|
||||||
else -> substrateAccountId != null
|
else -> substrateAccountId != null
|
||||||
}
|
}
|
||||||
@@ -43,6 +46,7 @@ open class DefaultMetaAccount(
|
|||||||
return when {
|
return when {
|
||||||
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
|
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).accountId
|
||||||
chain.isTronBased -> tronAddress
|
chain.isTronBased -> tronAddress
|
||||||
|
chain.isBitcoinBased -> bitcoinAddress
|
||||||
chain.isEthereumBased -> ethereumAddress
|
chain.isEthereumBased -> ethereumAddress
|
||||||
else -> substrateAccountId
|
else -> substrateAccountId
|
||||||
}
|
}
|
||||||
@@ -52,6 +56,7 @@ open class DefaultMetaAccount(
|
|||||||
return when {
|
return when {
|
||||||
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
|
hasChainAccountIn(chain.id) -> chainAccounts.getValue(chain.id).publicKey
|
||||||
chain.isTronBased -> tronPublicKey
|
chain.isTronBased -> tronPublicKey
|
||||||
|
chain.isBitcoinBased -> bitcoinPublicKey
|
||||||
chain.isEthereumBased -> ethereumPublicKey
|
chain.isEthereumBased -> ethereumPublicKey
|
||||||
else -> substratePublicKey
|
else -> substratePublicKey
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,8 @@ class GenericLedgerMetaAccount(
|
|||||||
private val supportedGenericLedgerChains: Set<ChainId>,
|
private val supportedGenericLedgerChains: Set<ChainId>,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) : DefaultMetaAccount(
|
) : DefaultMetaAccount(
|
||||||
id = id,
|
id = id,
|
||||||
globallyUniqueId = globallyUniqueId,
|
globallyUniqueId = globallyUniqueId,
|
||||||
@@ -39,7 +41,9 @@ class GenericLedgerMetaAccount(
|
|||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,8 @@ class LegacyLedgerMetaAccount(
|
|||||||
parentMetaId: Long?,
|
parentMetaId: Long?,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) : DefaultMetaAccount(
|
) : DefaultMetaAccount(
|
||||||
id = id,
|
id = id,
|
||||||
globallyUniqueId = globallyUniqueId,
|
globallyUniqueId = globallyUniqueId,
|
||||||
@@ -39,7 +41,9 @@ class LegacyLedgerMetaAccount(
|
|||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||||
|
|||||||
+5
-1
@@ -30,6 +30,8 @@ class RealMultisigMetaAccount(
|
|||||||
parentMetaId: Long?,
|
parentMetaId: Long?,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) : DefaultMetaAccount(
|
) : DefaultMetaAccount(
|
||||||
id = id,
|
id = id,
|
||||||
globallyUniqueId = globallyUniqueId,
|
globallyUniqueId = globallyUniqueId,
|
||||||
@@ -45,7 +47,9 @@ class RealMultisigMetaAccount(
|
|||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey
|
||||||
),
|
),
|
||||||
MultisigMetaAccount {
|
MultisigMetaAccount {
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -22,6 +22,8 @@ class PolkadotVaultMetaAccount(
|
|||||||
parentMetaId: Long?,
|
parentMetaId: Long?,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) : DefaultMetaAccount(
|
) : DefaultMetaAccount(
|
||||||
id = id,
|
id = id,
|
||||||
globallyUniqueId = globallyUniqueId,
|
globallyUniqueId = globallyUniqueId,
|
||||||
@@ -37,10 +39,12 @@ class PolkadotVaultMetaAccount(
|
|||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey
|
||||||
) {
|
) {
|
||||||
|
|
||||||
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
override suspend fun supportsAddingChainAccount(chain: Chain): Boolean {
|
||||||
return !chain.isEthereumBased && !chain.isTronBased
|
return !chain.isEthereumBased && !chain.isTronBased && !chain.isBitcoinBased
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -24,6 +24,8 @@ internal class RealProxiedMetaAccount(
|
|||||||
parentMetaId: Long?,
|
parentMetaId: Long?,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) : DefaultMetaAccount(
|
) : DefaultMetaAccount(
|
||||||
id = id,
|
id = id,
|
||||||
globallyUniqueId = globallyUniqueId,
|
globallyUniqueId = globallyUniqueId,
|
||||||
@@ -39,7 +41,9 @@ internal class RealProxiedMetaAccount(
|
|||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey
|
||||||
),
|
),
|
||||||
ProxiedMetaAccount {
|
ProxiedMetaAccount {
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -25,6 +25,8 @@ class RealSecretsMetaAccount(
|
|||||||
parentMetaId: Long?,
|
parentMetaId: Long?,
|
||||||
tronAddress: ByteArray? = null,
|
tronAddress: ByteArray? = null,
|
||||||
tronPublicKey: ByteArray? = null,
|
tronPublicKey: ByteArray? = null,
|
||||||
|
bitcoinAddress: ByteArray? = null,
|
||||||
|
bitcoinPublicKey: ByteArray? = null,
|
||||||
) : DefaultMetaAccount(
|
) : DefaultMetaAccount(
|
||||||
id = id,
|
id = id,
|
||||||
globallyUniqueId = globallyUniqueId,
|
globallyUniqueId = globallyUniqueId,
|
||||||
@@ -40,7 +42,9 @@ class RealSecretsMetaAccount(
|
|||||||
chainAccounts = chainAccounts,
|
chainAccounts = chainAccounts,
|
||||||
parentMetaId = parentMetaId,
|
parentMetaId = parentMetaId,
|
||||||
tronAddress = tronAddress,
|
tronAddress = tronAddress,
|
||||||
tronPublicKey = tronPublicKey
|
tronPublicKey = tronPublicKey,
|
||||||
|
bitcoinAddress = bitcoinAddress,
|
||||||
|
bitcoinPublicKey = bitcoinPublicKey
|
||||||
),
|
),
|
||||||
SecretsMetaAccount {
|
SecretsMetaAccount {
|
||||||
|
|
||||||
@@ -49,7 +53,10 @@ class RealSecretsMetaAccount(
|
|||||||
hasChainAccountIn(chain.id) -> {
|
hasChainAccountIn(chain.id) -> {
|
||||||
val cryptoType = chainAccounts.getValue(chain.id).cryptoType ?: return null
|
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
|
MultiChainEncryption.Ethereum
|
||||||
} else {
|
} else {
|
||||||
MultiChainEncryption.substrateFrom(cryptoType)
|
MultiChainEncryption.substrateFrom(cryptoType)
|
||||||
@@ -58,6 +65,10 @@ class RealSecretsMetaAccount(
|
|||||||
|
|
||||||
chain.isEthereumBased -> MultiChainEncryption.Ethereum
|
chain.isEthereumBased -> MultiChainEncryption.Ethereum
|
||||||
|
|
||||||
|
chain.isTronBased -> MultiChainEncryption.Ethereum
|
||||||
|
|
||||||
|
chain.isBitcoinBased -> MultiChainEncryption.Ethereum
|
||||||
|
|
||||||
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
else -> substrateCryptoType?.let(MultiChainEncryption.Companion::substrateFrom)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -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.MetaAccountSecrets
|
||||||
import io.novafoundation.nova.common.data.secrets.v2.SecretStoreV2
|
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.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.model.CryptoType
|
||||||
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
import io.novafoundation.nova.core_db.dao.MetaAccountDao
|
||||||
import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal
|
import io.novafoundation.nova.core_db.model.chain.account.ChainAccountLocal
|
||||||
@@ -536,6 +538,64 @@ class RealLocalAccountsCloudBackupFacadeTest {
|
|||||||
verifyEvent(expectedEvent)
|
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
|
@Test
|
||||||
fun shouldApplyRemoveAccountDiff(): Unit = runBlocking {
|
fun shouldApplyRemoveAccountDiff(): Unit = runBlocking {
|
||||||
allChainsAreEvm(false)
|
allChainsAreEvm(false)
|
||||||
@@ -1205,6 +1265,14 @@ class RealLocalAccountsCloudBackupFacadeTest {
|
|||||||
return argThat { it.globallyUniqueId == id }
|
return argThat { it.globallyUniqueId == id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun metaAccountWithTronAddress(tronAddress: ByteArray): MetaAccountLocal {
|
||||||
|
return argThat { it.tronAddress.contentEquals(tronAddress) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun metaAccountSecretsWithTronDerivationPath(derivationPath: String): EncodableStruct<MetaAccountSecrets> {
|
||||||
|
return argThat { it.tronDerivationPath == derivationPath && it.tronKeypair != null }
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun verifyNoAdditionalSecretsInserted() {
|
private suspend fun verifyNoAdditionalSecretsInserted() {
|
||||||
verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any())
|
verify(secretStore, never()).putAdditionalMetaAccountSecret(anyLong(), any(), any())
|
||||||
}
|
}
|
||||||
|
|||||||
+192
@@ -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 <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> any(): T {
|
||||||
|
Mockito.any<T>()
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||||
|
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> 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<MetaAccountLocal> { updated ->
|
||||||
|
updated.id == 42L && updated.tronAddress.contentEquals(expectedTronAccountId)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
verify(secretStoreV2).putMetaAccountSecrets(
|
||||||
|
eq(42L),
|
||||||
|
argThat<EncodableStruct<MetaAccountSecrets>> { 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<KeyPairSchema>?): EncodableStruct<MetaAccountSecrets> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -39,12 +39,24 @@ class BalancesUpdateSystem(
|
|||||||
override fun start(): Flow<Updater.SideEffect> {
|
override fun start(): Flow<Updater.SideEffect> {
|
||||||
return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount ->
|
return accountUpdateScope.invalidationFlow().flatMapLatest { metaAccount ->
|
||||||
chainRegistry.currentChains.transformLatestDiffed { chain ->
|
chainRegistry.currentChains.transformLatestDiffed { chain ->
|
||||||
|
if (chain.isTronBased) {
|
||||||
|
Log.d(LOG_TAG, "TronDebug: currentChains delivered chain=${chain.id} name=${chain.name}")
|
||||||
|
}
|
||||||
emitAll(balancesSync(chain, metaAccount))
|
emitAll(balancesSync(chain, metaAccount))
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.Default)
|
}.flowOn(Dispatchers.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> {
|
private suspend fun balancesSync(chain: Chain, metaAccount: MetaAccount): Flow<Updater.SideEffect> {
|
||||||
|
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 {
|
return when {
|
||||||
!metaAccount.hasAccountIn(chain) -> emptyFlow()
|
!metaAccount.hasAccountIn(chain) -> emptyFlow()
|
||||||
chain.connectionState.isDisabled -> emptyFlow()
|
chain.connectionState.isDisabled -> emptyFlow()
|
||||||
@@ -89,6 +101,10 @@ class BalancesUpdateSystem(
|
|||||||
try {
|
try {
|
||||||
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
|
updater.listenForUpdates(subscriptionBuilder, metaAccount).catch { logError(chain, it) }
|
||||||
} catch (e: Exception) {
|
} 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()
|
emptyFlow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -6,7 +6,8 @@ import io.novafoundation.nova.common.presentation.AssetIconProvider
|
|||||||
import io.novafoundation.nova.common.presentation.getAssetIconOrFallback
|
import io.novafoundation.nova.common.presentation.getAssetIconOrFallback
|
||||||
import io.novafoundation.nova.common.utils.formatting.formatAsChange
|
import io.novafoundation.nova.common.utils.formatting.formatAsChange
|
||||||
import io.novafoundation.nova.common.utils.orZero
|
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_assets.R
|
||||||
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
|
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
|
||||||
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
|
import io.novafoundation.nova.feature_assets.domain.common.AssetWithNetwork
|
||||||
@@ -90,7 +91,14 @@ class TokenAssetFormatter(
|
|||||||
group.getId(),
|
group.getId(),
|
||||||
mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)),
|
mapAssetToAssetModel(it.asset, balance(it.balanceWithOffChain)),
|
||||||
assetIconProvider.getAssetIconOrFallback(it.asset.token.configuration),
|
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
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-83
@@ -5,11 +5,14 @@ import android.text.TextWatcher
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import io.novafoundation.nova.common.base.BaseFragment
|
import io.novafoundation.nova.common.base.BaseFragment
|
||||||
import io.novafoundation.nova.common.di.FeatureUtils
|
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.common.view.setState
|
||||||
import io.novafoundation.nova.feature_assets.R
|
import io.novafoundation.nova.feature_assets.R
|
||||||
import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding
|
import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding
|
||||||
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
|
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
|
||||||
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
|
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability
|
||||||
|
|
||||||
class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
||||||
|
|
||||||
@@ -18,26 +21,29 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
override fun initViews() {
|
override fun initViews() {
|
||||||
binder.bridgeToolbar.setHomeButtonListener { viewModel.backClicked() }
|
binder.bridgeToolbar.setHomeButtonListener { viewModel.backClicked() }
|
||||||
|
|
||||||
// Pair selector
|
binder.bridgeToCard.setEditable(false)
|
||||||
binder.bridgePairDotHez.setOnClickListener {
|
|
||||||
viewModel.setPair(BridgePair.DOT_HEZ)
|
// 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 {
|
// One-tap direction flip - replaces the old segmented direction buttons
|
||||||
viewModel.setPair(BridgePair.USDT)
|
binder.bridgeFlipButton.setOnClickListener {
|
||||||
}
|
when (viewModel.direction.value) {
|
||||||
|
BridgeDirection.DOT_TO_HEZ, BridgeDirection.USDT_TO_WUSDT -> viewModel.setDirectionRight()
|
||||||
// Direction toggle
|
BridgeDirection.HEZ_TO_DOT, BridgeDirection.WUSDT_TO_USDT -> viewModel.setDirectionLeft()
|
||||||
binder.bridgeDirectionLeft.setOnClickListener {
|
null -> Unit
|
||||||
viewModel.setDirectionLeft()
|
}
|
||||||
}
|
|
||||||
|
|
||||||
binder.bridgeDirectionRight.setOnClickListener {
|
|
||||||
viewModel.setDirectionRight()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Amount input
|
// 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 beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
@@ -46,6 +52,10 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
binder.bridgeFromMaxAmount.setMaxActionAvailability(
|
||||||
|
MaxActionAvailability.Available { viewModel.maxClicked() }
|
||||||
|
)
|
||||||
|
|
||||||
// Swap button
|
// Swap button
|
||||||
binder.bridgeSwapButton.setOnClickListener {
|
binder.bridgeSwapButton.setOnClickListener {
|
||||||
viewModel.swapClicked()
|
viewModel.swapClicked()
|
||||||
@@ -74,16 +84,16 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun subscribe(viewModel: BridgeViewModel) {
|
override fun subscribe(viewModel: BridgeViewModel) {
|
||||||
viewModel.pair.observe { pair ->
|
viewModel.fromCard.observe { model ->
|
||||||
updatePairUI(pair)
|
binder.bridgeFromCard.setModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.direction.observe { direction ->
|
viewModel.toCard.observe { model ->
|
||||||
updateDirectionUI(direction)
|
binder.bridgeToCard.setModel(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.outputAmount.observe { output ->
|
viewModel.outputAmount.observe { output ->
|
||||||
binder.bridgeToAmount.text = output
|
binder.bridgeToCard.setAmountText(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.exchangeRateText.observe { rate ->
|
viewModel.exchangeRateText.observe { rate ->
|
||||||
@@ -99,20 +109,18 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewModel.showWarning.observe { show ->
|
viewModel.showWarning.observe { show ->
|
||||||
binder.bridgeHezToDotWarning.visibility = if (show) View.VISIBLE else View.GONE
|
binder.bridgeWarningAlert.setVisible(show)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.warningBlocked.observe { blocked ->
|
viewModel.warningBlocked.observe { blocked ->
|
||||||
if (blocked) {
|
binder.bridgeWarningAlert.setStylePreset(
|
||||||
binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.error_block_background, null))
|
if (blocked) AlertView.StylePreset.ERROR else AlertView.StylePreset.WARNING
|
||||||
} else {
|
)
|
||||||
binder.bridgeHezToDotWarning.setBackgroundColor(resources.getColor(R.color.warning_block_background, null))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel.warningText.observe { text ->
|
viewModel.warningText.observe { text ->
|
||||||
if (text.isNotEmpty()) {
|
if (text.isNotEmpty()) {
|
||||||
binder.bridgeHezToDotWarning.text = text
|
binder.bridgeWarningAlert.setMessage(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,65 +144,17 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
viewModel.signButtonLabel.observe { label ->
|
viewModel.signButtonLabel.observe { label ->
|
||||||
binder.bridgeSignButton.text = label
|
binder.bridgeSignButton.text = label
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePairUI(pair: BridgePair) {
|
viewModel.maxAmountDisplay.observe { display ->
|
||||||
when (pair) {
|
binder.bridgeFromMaxAmount.setMaxAmountDisplay(display)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (direction) {
|
viewModel.insufficientBalanceError.observe { error ->
|
||||||
BridgeDirection.DOT_TO_HEZ -> {
|
binder.bridgeFromCard.setError(error)
|
||||||
binder.bridgeDirectionLeft.text = "DOT → HEZ"
|
}
|
||||||
binder.bridgeDirectionRight.text = "HEZ → DOT"
|
|
||||||
binder.bridgeFromToken.text = "DOT"
|
viewModel.fillAmountEvent.observeEvent { amount ->
|
||||||
binder.bridgeToToken.text = "HEZ"
|
binder.bridgeFromCard.amountInput.setText(amount)
|
||||||
}
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
@@ -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<BridgePairUi>,
|
||||||
|
onClicked: (BridgePairUi) -> Unit
|
||||||
|
) : DynamicListBottomSheet<BridgePairUi>(
|
||||||
|
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<BridgePairUi> = {
|
||||||
|
BridgePairListHolder(ItemBridgePairListBinding.inflate(it.inflater(), it, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BridgePairListHolder(
|
||||||
|
private val binder: ItemBridgePairListBinding
|
||||||
|
) : DynamicListSheetAdapter.Holder<BridgePairUi>(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<BridgePairUi>) {
|
||||||
|
binder.itemBridgePairIcon.setIconOrMakeGone(item.icon, imageLoader)
|
||||||
|
binder.itemBridgePairTitle.text = item.title
|
||||||
|
binder.root.setOnClickListener { handler.itemClicked(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object BridgePairDiffCallback : DiffUtil.ItemCallback<BridgePairUi>() {
|
||||||
|
override fun areItemsTheSame(oldItem: BridgePairUi, newItem: BridgePairUi): Boolean {
|
||||||
|
return oldItem.pair == newItem.pair
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: BridgePairUi, newItem: BridgePairUi): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+125
@@ -3,9 +3,14 @@ package io.novafoundation.nova.feature_assets.presentation.bridge
|
|||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import io.novafoundation.nova.common.base.BaseViewModel
|
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.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.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.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.BridgeMultisigInteractor
|
||||||
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeSignerState
|
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeSignerState
|
||||||
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
|
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.feature_wallet_api.presentation.model.AssetPayload
|
||||||
import io.novafoundation.nova.runtime.ext.ChainGeneses
|
import io.novafoundation.nova.runtime.ext.ChainGeneses
|
||||||
import io.novafoundation.nova.runtime.ext.addressOf
|
import io.novafoundation.nova.runtime.ext.addressOf
|
||||||
|
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||||
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
@@ -27,6 +34,8 @@ class BridgeViewModel(
|
|||||||
private val router: AssetsRouter,
|
private val router: AssetsRouter,
|
||||||
private val resourceManager: ResourceManager,
|
private val resourceManager: ResourceManager,
|
||||||
private val chainRegistry: ChainRegistry,
|
private val chainRegistry: ChainRegistry,
|
||||||
|
private val assetIconProvider: AssetIconProvider,
|
||||||
|
private val walletInteractor: WalletInteractor,
|
||||||
private val bridgeMultisigInteractor: BridgeMultisigInteractor
|
private val bridgeMultisigInteractor: BridgeMultisigInteractor
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
@@ -91,15 +100,37 @@ class BridgeViewModel(
|
|||||||
private val _signButtonLabel = MutableLiveData("")
|
private val _signButtonLabel = MutableLiveData("")
|
||||||
val signButtonLabel: LiveData<String> = _signButtonLabel
|
val signButtonLabel: LiveData<String> = _signButtonLabel
|
||||||
|
|
||||||
|
private val _fromCard = MutableLiveData<BridgeAssetCardUi>()
|
||||||
|
val fromCard: LiveData<BridgeAssetCardUi> = _fromCard
|
||||||
|
|
||||||
|
private val _toCard = MutableLiveData<BridgeAssetCardUi>()
|
||||||
|
val toCard: LiveData<BridgeAssetCardUi> = _toCard
|
||||||
|
|
||||||
|
private val _pairOptions = MutableLiveData<List<BridgePairUi>>(emptyList())
|
||||||
|
val pairOptions: LiveData<List<BridgePairUi>> = _pairOptions
|
||||||
|
|
||||||
|
private val _maxAmountDisplay = MutableLiveData<String?>(null)
|
||||||
|
val maxAmountDisplay: LiveData<String?> = _maxAmountDisplay
|
||||||
|
|
||||||
|
private val _insufficientBalanceError = MutableLiveData<String?>(null)
|
||||||
|
val insufficientBalanceError: LiveData<String?> = _insufficientBalanceError
|
||||||
|
|
||||||
|
private val _fillAmountEvent = MutableLiveData<Event<String>>()
|
||||||
|
val fillAmountEvent: LiveData<Event<String>> = _fillAmountEvent
|
||||||
|
|
||||||
private var currentAmount: Double = 0.0
|
private var currentAmount: Double = 0.0
|
||||||
private var dotToHezRate: Double = FALLBACK_RATE
|
private var dotToHezRate: Double = FALLBACK_RATE
|
||||||
private var isHezToDotActive: Boolean = false
|
private var isHezToDotActive: Boolean = false
|
||||||
private var isWusdtToUsdtActive: Boolean = false
|
private var isWusdtToUsdtActive: Boolean = false
|
||||||
|
private var availableBalance: BigDecimal = BigDecimal.ZERO
|
||||||
|
private var balanceJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
fetchExchangeRate()
|
fetchExchangeRate()
|
||||||
fetchBridgeStatus()
|
fetchBridgeStatus()
|
||||||
refreshSignerState()
|
refreshSignerState()
|
||||||
|
updateCards()
|
||||||
|
loadPairOptions()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPair(newPair: BridgePair) {
|
fun setPair(newPair: BridgePair) {
|
||||||
@@ -113,6 +144,7 @@ class BridgeViewModel(
|
|||||||
updateUI()
|
updateUI()
|
||||||
calculateOutput()
|
calculateOutput()
|
||||||
updateWarningState()
|
updateWarningState()
|
||||||
|
updateCards()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +159,7 @@ class BridgeViewModel(
|
|||||||
updateUI()
|
updateUI()
|
||||||
calculateOutput()
|
calculateOutput()
|
||||||
updateWarningState()
|
updateWarningState()
|
||||||
|
updateCards()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,15 +174,21 @@ class BridgeViewModel(
|
|||||||
updateUI()
|
updateUI()
|
||||||
calculateOutput()
|
calculateOutput()
|
||||||
updateWarningState()
|
updateWarningState()
|
||||||
|
updateCards()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setAmount(amount: Double) {
|
fun setAmount(amount: Double) {
|
||||||
currentAmount = amount
|
currentAmount = amount
|
||||||
calculateOutput()
|
calculateOutput()
|
||||||
|
updateInsufficientBalanceState()
|
||||||
updateButtonState()
|
updateButtonState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun maxClicked() {
|
||||||
|
_fillAmountEvent.value = Event(availableBalance.stripTrailingZeros().toPlainString())
|
||||||
|
}
|
||||||
|
|
||||||
fun swapClicked() {
|
fun swapClicked() {
|
||||||
val dir = _direction.value ?: return
|
val dir = _direction.value ?: return
|
||||||
if (currentAmount <= 0) return
|
if (currentAmount <= 0) return
|
||||||
@@ -329,12 +368,21 @@ class BridgeViewModel(
|
|||||||
_buttonState.value = when {
|
_buttonState.value = when {
|
||||||
currentAmount <= 0 -> ButtonState.DISABLED
|
currentAmount <= 0 -> ButtonState.DISABLED
|
||||||
currentAmount < minimum -> ButtonState.DISABLED
|
currentAmount < minimum -> ButtonState.DISABLED
|
||||||
|
BigDecimal.valueOf(currentAmount) > availableBalance -> ButtonState.DISABLED
|
||||||
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
|
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
|
||||||
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
|
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
|
||||||
else -> ButtonState.NORMAL
|
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() {
|
fun refreshBridgeStatus() {
|
||||||
fetchBridgeStatus()
|
fetchBridgeStatus()
|
||||||
refreshSignerState()
|
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
|
||||||
|
)
|
||||||
|
|||||||
+5
-1
@@ -8,9 +8,11 @@ import dagger.Provides
|
|||||||
import dagger.multibindings.IntoMap
|
import dagger.multibindings.IntoMap
|
||||||
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
|
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
|
||||||
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
|
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.common.resources.ResourceManager
|
||||||
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
|
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_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.BridgeMultisigInteractor
|
||||||
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.RealBridgeMultisigInteractor
|
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.RealBridgeMultisigInteractor
|
||||||
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
|
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
|
||||||
@@ -40,9 +42,11 @@ class BridgeModule {
|
|||||||
router: AssetsRouter,
|
router: AssetsRouter,
|
||||||
resourceManager: ResourceManager,
|
resourceManager: ResourceManager,
|
||||||
chainRegistry: ChainRegistry,
|
chainRegistry: ChainRegistry,
|
||||||
|
assetIconProvider: AssetIconProvider,
|
||||||
|
walletInteractor: WalletInteractor,
|
||||||
bridgeMultisigInteractor: BridgeMultisigInteractor
|
bridgeMultisigInteractor: BridgeMultisigInteractor
|
||||||
): ViewModel {
|
): ViewModel {
|
||||||
return BridgeViewModel(router, resourceManager, chainRegistry, bridgeMultisigInteractor)
|
return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider, walletInteractor, bridgeMultisigInteractor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
+76
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -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.AmountFormatter
|
||||||
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
|
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.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.ChainRegistry
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.asset
|
import io.novafoundation.nova.runtime.multiNetwork.asset
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -73,7 +74,7 @@ abstract class NetworkFlowViewModel(
|
|||||||
NetworkFlowRvItem(
|
NetworkFlowRvItem(
|
||||||
it.chain.id,
|
it.chain.id,
|
||||||
it.asset.token.configuration.id,
|
it.asset.token.configuration.id,
|
||||||
it.chain.name,
|
it.chain.displayNameWithAssetStandard(),
|
||||||
it.chain.icon,
|
it.chain.icon,
|
||||||
amountFormatter.formatAmountToAmountModel(
|
amountFormatter.formatAmountToAmountModel(
|
||||||
amount = getAssetBalance(it).amount,
|
amount = getAssetBalance(it).amount,
|
||||||
|
|||||||
@@ -25,212 +25,65 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:padding="16dp">
|
||||||
|
|
||||||
<!-- Pair Selector -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="8dp"
|
android:gravity="center_vertical"
|
||||||
android:background="@drawable/bg_primary_list_item"
|
android:orientation="horizontal">
|
||||||
android:orientation="horizontal"
|
|
||||||
android:padding="4dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/bridgePairDotHez"
|
android:id="@+id/bridgeFromLabel"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:background="@drawable/bg_button_primary"
|
android:text="@string/bridge_you_send"
|
||||||
android:gravity="center"
|
android:textColor="@color/text_secondary" />
|
||||||
android:padding="10dp"
|
|
||||||
android:text="DOT / HEZ"
|
|
||||||
android:textColor="@color/text_primary"
|
|
||||||
android:textSize="13sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
<io.novafoundation.nova.feature_wallet_api.presentation.view.amount.MaxAmountView
|
||||||
android:id="@+id/bridgePairUsdt"
|
android:id="@+id/bridgeFromMaxAmount"
|
||||||
android:layout_width="0dp"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content" />
|
||||||
android:layout_weight="1"
|
|
||||||
android:gravity="center"
|
|
||||||
android:padding="10dp"
|
|
||||||
android:text="USDT"
|
|
||||||
android:textColor="@color/text_secondary"
|
|
||||||
android:textSize="13sp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Direction Toggle -->
|
<io.novafoundation.nova.feature_assets.presentation.bridge.view.BridgeAssetInputView
|
||||||
<LinearLayout
|
android:id="@+id/bridgeFromCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginTop="8dp" />
|
||||||
android:background="@drawable/bg_primary_list_item"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:padding="4dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeDirectionLeft"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:background="@drawable/bg_button_primary"
|
|
||||||
android:gravity="center"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:text="DOT → HEZ"
|
|
||||||
android:textColor="@color/text_primary"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeDirectionRight"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:gravity="center"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:text="HEZ → DOT"
|
|
||||||
android:textColor="@color/text_secondary"
|
|
||||||
android:textSize="14sp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- From Section -->
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginBottom="8dp"
|
|
||||||
app:cardBackgroundColor="@color/block_background"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="0dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeFromLabel"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/bridge_you_send"
|
|
||||||
android:textColor="@color/text_secondary"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/bridgeFromAmount"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:background="@null"
|
|
||||||
android:hint="0.0"
|
|
||||||
android:inputType="numberDecimal"
|
|
||||||
android:textColor="@color/text_primary"
|
|
||||||
android:textColorHint="@color/text_secondary"
|
|
||||||
android:textSize="32sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeFromToken"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="DOT"
|
|
||||||
android:textColor="@color/text_primary"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeFromBalance"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:textColor="@color/text_secondary"
|
|
||||||
android:textSize="12sp"
|
|
||||||
tools:text="Balance: 10.5 DOT" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Swap Icon -->
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
android:id="@+id/bridgeFlipButton"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_gravity="center"
|
android:layout_gravity="center"
|
||||||
android:layout_marginVertical="4dp"
|
android:layout_marginVertical="4dp"
|
||||||
|
android:background="?attr/selectableItemBackgroundBorderless"
|
||||||
android:padding="8dp"
|
android:padding="8dp"
|
||||||
android:src="@drawable/ic_bridge"
|
android:scaleType="centerInside"
|
||||||
app:tint="@color/icon_secondary" />
|
android:src="@drawable/ic_flip_swap" />
|
||||||
|
|
||||||
<!-- To Section -->
|
<TextView
|
||||||
<com.google.android.material.card.MaterialCardView
|
android:id="@+id/bridgeToLabel"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="@string/bridge_you_receive"
|
||||||
|
android:textColor="@color/text_secondary" />
|
||||||
|
|
||||||
|
<io.novafoundation.nova.feature_assets.presentation.bridge.view.BridgeAssetInputView
|
||||||
|
android:id="@+id/bridgeToCard"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginTop="8dp" />
|
||||||
app:cardBackgroundColor="@color/block_background"
|
|
||||||
app:cardCornerRadius="12dp"
|
|
||||||
app:cardElevation="0dp">
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:padding="16dp">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeToLabel"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="@string/bridge_you_receive"
|
|
||||||
android:textColor="@color/text_secondary"
|
|
||||||
android:textSize="12sp" />
|
|
||||||
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="8dp"
|
|
||||||
android:gravity="center_vertical"
|
|
||||||
android:orientation="horizontal">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeToAmount"
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:text="0.0"
|
|
||||||
android:textColor="@color/text_primary"
|
|
||||||
android:textSize="32sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/bridgeToToken"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="HEZ"
|
|
||||||
android:textColor="@color/text_primary"
|
|
||||||
android:textSize="20sp"
|
|
||||||
android:textStyle="bold" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
|
||||||
|
|
||||||
<!-- Rate Info -->
|
<!-- Rate Info -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
app:cardBackgroundColor="@color/block_background"
|
app:cardBackgroundColor="@color/block_background"
|
||||||
app:cardCornerRadius="12dp"
|
app:cardCornerRadius="12dp"
|
||||||
app:cardElevation="0dp">
|
app:cardElevation="0dp">
|
||||||
@@ -317,18 +170,14 @@
|
|||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<!-- Warning for HEZ to DOT -->
|
<!-- Warning for HEZ to DOT / USDT liquidity -->
|
||||||
<TextView
|
<io.novafoundation.nova.common.view.AlertView
|
||||||
android:id="@+id/bridgeHezToDotWarning"
|
android:id="@+id/bridgeWarningAlert"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginTop="16dp"
|
||||||
android:background="@drawable/bg_warning"
|
android:visibility="gone"
|
||||||
android:padding="12dp"
|
app:alertMode="warning" />
|
||||||
android:text="@string/bridge_hez_to_dot_note"
|
|
||||||
android:textColor="@color/text_warning"
|
|
||||||
android:textSize="12sp"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<!-- Multisig signatory-only bridge allowance sign button. Only visible to a wallet
|
<!-- Multisig signatory-only bridge allowance sign button. Only visible to a wallet
|
||||||
that is one of the bridge multisig's 5 known signatories. -->
|
that is one of the bridge multisig's 5 known signatories. -->
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="12dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/itemBridgePairIcon"
|
||||||
|
android:layout_width="32dp"
|
||||||
|
android:layout_height="32dp"
|
||||||
|
android:background="@drawable/bg_token_container"
|
||||||
|
android:scaleType="centerInside"
|
||||||
|
tools:src="@drawable/ic_pezkuwi_logo" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/itemBridgePairTitle"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
tools:text="HEZ ⇄ DOT" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
android:id="@+id/bridgeAssetInputContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
tools:background="@color/input_background">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bridgeAssetInputImage"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:background="@drawable/bg_token_container"
|
||||||
|
android:scaleType="centerInside"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:src="@drawable/ic_token_dot_colored" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bridgeAssetInputSubtitleImage"
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/bridgeAssetInputSubtitle"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/bridgeAssetInputImageBarrier"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/bridgeAssetInputSubtitle"
|
||||||
|
tools:src="@drawable/ic_pezkuwi_logo" />
|
||||||
|
|
||||||
|
<androidx.constraintlayout.widget.Barrier
|
||||||
|
android:id="@+id/bridgeAssetInputImageBarrier"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:barrierDirection="end"
|
||||||
|
app:barrierMargin="12dp"
|
||||||
|
app:constraint_referenced_ids="bridgeAssetInputImage" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bridgeAssetInputToken"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.SemiBold.Body"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
app:layout_constraintBottom_toTopOf="@+id/bridgeAssetInputSubtitle"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/bridgeAssetInputImage"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
tools:text="DOT" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bridgeAssetInputSubtitle"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/bridgeAssetInputSubtitleImage"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/bridgeAssetInputToken"
|
||||||
|
tools:text="Polkadot Asset Hub" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/bridgeAssetInputChevron"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:src="@drawable/ic_chevron_right"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/bridgeAssetInputToken"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/bridgeAssetInputToken"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/bridgeAssetInputToken"
|
||||||
|
app:tint="@color/icon_primary" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/bridgeAssetInputField"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Title2"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:background="@null"
|
||||||
|
android:gravity="end"
|
||||||
|
android:hint="0"
|
||||||
|
android:inputType="numberDecimal"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingBottom="10dp"
|
||||||
|
android:saveEnabled="false"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textColorHint="@color/hint_text"
|
||||||
|
android:textCursorDrawable="@null"
|
||||||
|
android:textStyle="bold"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintHorizontal_bias="1.0"
|
||||||
|
app:layout_constraintStart_toEndOf="@+id/bridgeAssetInputChevron"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
|
tools:text="4.508614" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/bridgeAssetInputError"
|
||||||
|
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="@color/text_negative"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toBottomOf="@+id/bridgeAssetInputContainer" />
|
||||||
|
|
||||||
|
</merge>
|
||||||
+54
-3
@@ -23,7 +23,19 @@ data class CloudBackup(
|
|||||||
val ethereumPublicKey: ByteArray?,
|
val ethereumPublicKey: ByteArray?,
|
||||||
val name: String,
|
val name: String,
|
||||||
val type: Type,
|
val type: Type,
|
||||||
val chainAccounts: Set<ChainAccountInfo>
|
val chainAccounts: Set<ChainAccountInfo>,
|
||||||
|
// 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 {
|
) : Identifiable {
|
||||||
|
|
||||||
override val identifier: String = walletId
|
override val identifier: String = walletId
|
||||||
@@ -72,7 +84,13 @@ data class CloudBackup(
|
|||||||
ethereumPublicKey.contentEquals(other.ethereumPublicKey) &&
|
ethereumPublicKey.contentEquals(other.ethereumPublicKey) &&
|
||||||
name == other.name &&
|
name == other.name &&
|
||||||
type == other.type &&
|
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 {
|
override fun hashCode(): Int {
|
||||||
@@ -85,6 +103,12 @@ data class CloudBackup(
|
|||||||
result = 31 * result + name.hashCode()
|
result = 31 * result + name.hashCode()
|
||||||
result = 31 * result + type.hashCode()
|
result = 31 * result + type.hashCode()
|
||||||
result = 31 * result + chainAccounts.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()
|
result = 31 * result + identifier.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -100,6 +124,11 @@ data class CloudBackup(
|
|||||||
val substrate: SubstrateSecrets?,
|
val substrate: SubstrateSecrets?,
|
||||||
val ethereum: EthereumSecrets?,
|
val ethereum: EthereumSecrets?,
|
||||||
val chainAccounts: List<ChainAccountSecrets>,
|
val chainAccounts: List<ChainAccountSecrets>,
|
||||||
|
// 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 {
|
) : Identifiable {
|
||||||
|
|
||||||
override val identifier: String = walletId
|
override val identifier: String = walletId
|
||||||
@@ -118,6 +147,9 @@ data class CloudBackup(
|
|||||||
if (substrate != other.substrate) return false
|
if (substrate != other.substrate) return false
|
||||||
if (ethereum != other.ethereum) return false
|
if (ethereum != other.ethereum) return false
|
||||||
if (chainAccounts != other.chainAccounts) 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
|
return identifier == other.identifier
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +159,9 @@ data class CloudBackup(
|
|||||||
result = 31 * result + (substrate?.hashCode() ?: 0)
|
result = 31 * result + (substrate?.hashCode() ?: 0)
|
||||||
result = 31 * result + (ethereum?.hashCode() ?: 0)
|
result = 31 * result + (ethereum?.hashCode() ?: 0)
|
||||||
result = 31 * result + chainAccounts.hashCode()
|
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()
|
result = 31 * result + identifier.hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -201,6 +236,22 @@ data class CloudBackup(
|
|||||||
val derivationPath: String?,
|
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(
|
data class KeyPairSecrets(
|
||||||
val publicKey: ByteArray,
|
val publicKey: ByteArray,
|
||||||
val privateKey: ByteArray,
|
val privateKey: ByteArray,
|
||||||
@@ -234,5 +285,5 @@ data class CloudBackup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun CloudBackup.WalletPrivateInfo.isCompletelyEmpty(): Boolean {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-1
@@ -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.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.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.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
|
||||||
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
|
||||||
import io.novafoundation.nova.feature_cloud_backup_api.domain.model.CloudBackup.WalletPublicInfo.ChainAccountInfo.ChainAccountCryptoType
|
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 substrate: SubstrateSecrets? = null
|
||||||
private var ethereum: EthereumSecrets? = null
|
private var ethereum: EthereumSecrets? = null
|
||||||
|
private var tron: TronSecrets? = null
|
||||||
|
|
||||||
private val chainAccounts = mutableListOf<ChainAccountSecrets>()
|
private val chainAccounts = mutableListOf<ChainAccountSecrets>()
|
||||||
|
|
||||||
@@ -114,6 +116,10 @@ class WalletPrivateInfoBuilder(
|
|||||||
ethereum = BackupEthereumSecretsBuilder().apply(builder).build()
|
ethereum = BackupEthereumSecretsBuilder().apply(builder).build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun tron(builder: BackupTronSecretsBuilder.() -> Unit) {
|
||||||
|
tron = BackupTronSecretsBuilder().apply(builder).build()
|
||||||
|
}
|
||||||
|
|
||||||
fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) {
|
fun chainAccount(accountId: AccountId, builder: (BackupChainAccountSecretsBuilder.() -> Unit)? = null) {
|
||||||
val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build()
|
val element = BackupChainAccountSecretsBuilder(accountId).apply { builder?.invoke(this) }.build()
|
||||||
chainAccounts.add(element)
|
chainAccounts.add(element)
|
||||||
@@ -126,6 +132,7 @@ class WalletPrivateInfoBuilder(
|
|||||||
substrate = substrate,
|
substrate = substrate,
|
||||||
ethereum = ethereum,
|
ethereum = ethereum,
|
||||||
chainAccounts = chainAccounts,
|
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
|
@CloudBackupBuildDsl
|
||||||
class BackupChainAccountSecretsBuilder(private val accountId: AccountId) {
|
class BackupChainAccountSecretsBuilder(private val accountId: AccountId) {
|
||||||
|
|
||||||
@@ -223,6 +249,8 @@ class WalletPublicInfoBuilder(
|
|||||||
private var _substrateAccountId: ByteArray? = null
|
private var _substrateAccountId: ByteArray? = null
|
||||||
private var _ethereumPublicKey: ByteArray? = null
|
private var _ethereumPublicKey: ByteArray? = null
|
||||||
private var _ethereumAddress: ByteArray? = null
|
private var _ethereumAddress: ByteArray? = null
|
||||||
|
private var _tronPublicKey: ByteArray? = null
|
||||||
|
private var _tronAddress: ByteArray? = null
|
||||||
private var _name: String = ""
|
private var _name: String = ""
|
||||||
private var _isSelected: Boolean = false
|
private var _isSelected: Boolean = false
|
||||||
private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS
|
private var _type: WalletPublicInfo.Type = WalletPublicInfo.Type.SECRETS
|
||||||
@@ -252,6 +280,14 @@ class WalletPublicInfoBuilder(
|
|||||||
_ethereumAddress = value
|
_ethereumAddress = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun tronPublicKey(value: ByteArray?) {
|
||||||
|
_tronPublicKey = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tronAddress(value: ByteArray?) {
|
||||||
|
_tronAddress = value
|
||||||
|
}
|
||||||
|
|
||||||
fun name(value: String) {
|
fun name(value: String) {
|
||||||
_name = value
|
_name = value
|
||||||
}
|
}
|
||||||
@@ -274,7 +310,9 @@ class WalletPublicInfoBuilder(
|
|||||||
ethereumPublicKey = _ethereumPublicKey,
|
ethereumPublicKey = _ethereumPublicKey,
|
||||||
name = _name,
|
name = _name,
|
||||||
type = _type,
|
type = _type,
|
||||||
chainAccounts = chainAccounts.toSet()
|
chainAccounts = chainAccounts.toSet(),
|
||||||
|
tronAddress = _tronAddress,
|
||||||
|
tronPublicKey = _tronPublicKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -9,6 +9,7 @@ import io.novafoundation.nova.runtime.ext.isCustomNetwork
|
|||||||
import io.novafoundation.nova.runtime.ext.isDisabled
|
import io.novafoundation.nova.runtime.ext.isDisabled
|
||||||
import io.novafoundation.nova.runtime.ext.isEnabled
|
import io.novafoundation.nova.runtime.ext.isEnabled
|
||||||
import io.novafoundation.nova.runtime.ext.selectedUnformattedWssNodeUrlOrNull
|
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.ext.wssNodes
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
@@ -132,7 +133,13 @@ class RealNetworkManagementChainInteractor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> {
|
private fun nodesHealthState(chain: Chain, coroutineScope: CoroutineScope): Flow<List<NodeHealthState>> {
|
||||||
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)
|
nodeHealthState(chain, it, coroutineScope)
|
||||||
}.combine()
|
}.combine()
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -125,6 +125,7 @@ class CustomChainFactory(
|
|||||||
types = prefilledChain?.types,
|
types = prefilledChain?.types,
|
||||||
isEthereumBased = isEthereumBased,
|
isEthereumBased = isEthereumBased,
|
||||||
isTronBased = false,
|
isTronBased = false,
|
||||||
|
isBitcoinBased = false,
|
||||||
isTestNet = prefilledChain?.isTestNet.orFalse(),
|
isTestNet = prefilledChain?.isTestNet.orFalse(),
|
||||||
source = Chain.Source.CUSTOM,
|
source = Chain.Source.CUSTOM,
|
||||||
hasSubstrateRuntime = hasSubstrateRuntime,
|
hasSubstrateRuntime = hasSubstrateRuntime,
|
||||||
|
|||||||
+6
@@ -61,6 +61,12 @@ class RealNetworkListAdapterItemFactory(
|
|||||||
private fun getConnectingState(network: NetworkState): ConnectionStateModel? {
|
private fun getConnectingState(network: NetworkState): ConnectionStateModel? {
|
||||||
if (network.chain.isDisabled) return null
|
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) {
|
return when (network.connectionState) {
|
||||||
is SocketStateMachine.State.Connected -> null
|
is SocketStateMachine.State.Connected -> null
|
||||||
|
|
||||||
|
|||||||
+4
@@ -8,4 +8,8 @@ sealed interface TransactionExecution {
|
|||||||
class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution
|
class Ethereum(val ethereumTransactionExecution: EthereumTransactionExecution) : TransactionExecution
|
||||||
|
|
||||||
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
|
class Substrate(val extrinsicExecutionResult: ExtrinsicExecutionResult) : TransactionExecution
|
||||||
|
|
||||||
|
class Bitcoin(val hash: String) : TransactionExecution
|
||||||
|
|
||||||
|
class Tron(val hash: String) : TransactionExecution
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ android {
|
|||||||
}
|
}
|
||||||
namespace 'io.novafoundation.nova.feature_wallet_impl'
|
namespace 'io.novafoundation.nova.feature_wallet_impl'
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
testLogging {
|
||||||
|
events "failed"
|
||||||
|
exceptionFormat "full"
|
||||||
|
showCauses true
|
||||||
|
showStackTraces true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
}
|
}
|
||||||
@@ -71,6 +82,7 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation jUnitDep
|
testImplementation jUnitDep
|
||||||
testImplementation mockitoDep
|
testImplementation mockitoDep
|
||||||
|
testImplementation project(':test-shared')
|
||||||
|
|
||||||
implementation substrateSdkDep
|
implementation substrateSdkDep
|
||||||
|
|
||||||
|
|||||||
+81
@@ -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<BitcoinUtxo>
|
||||||
|
|
||||||
|
/** 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<BitcoinUtxo> {
|
||||||
|
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 <T> 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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<BitcoinUtxoResponse>
|
||||||
|
|
||||||
|
@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
|
||||||
|
}
|
||||||
+22
@@ -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,
|
||||||
|
)
|
||||||
+22
@@ -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,
|
||||||
|
)
|
||||||
+41
@@ -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<ExtrinsicSubmission>
|
||||||
|
|
||||||
|
suspend fun transactAndAwaitExecution(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipientAddress: String,
|
||||||
|
presetFee: Fee?,
|
||||||
|
amountSat: BigInteger
|
||||||
|
): Result<TransactionExecution>
|
||||||
|
}
|
||||||
+191
@@ -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<BitcoinUtxo>,
|
||||||
|
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<ExtrinsicSubmission> = 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<TransactionExecution> {
|
||||||
|
// 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<BitcoinUtxo> {
|
||||||
|
val address = ownerAccountId.toBitcoinAddress()
|
||||||
|
|
||||||
|
return bitcoinApi.fetchUtxos(baseUrl, address).filter { it.confirmed }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectUtxos(utxos: List<BitcoinUtxo>, amountSat: Long, feeRateSatPerVbyte: Long): UtxoSelection? {
|
||||||
|
val sorted = utxos.sortedByDescending { it.valueSat }
|
||||||
|
val selected = mutableListOf<BitcoinUtxo>()
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -31,6 +31,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
private val equilibriumAssetSource: Lazy<AssetSource>,
|
private val equilibriumAssetSource: Lazy<AssetSource>,
|
||||||
private val tronNativeSource: Lazy<AssetSource>,
|
private val tronNativeSource: Lazy<AssetSource>,
|
||||||
private val trc20Source: Lazy<AssetSource>,
|
private val trc20Source: Lazy<AssetSource>,
|
||||||
|
private val bitcoinNativeSource: Lazy<AssetSource>,
|
||||||
private val unsupportedBalanceSource: AssetSource,
|
private val unsupportedBalanceSource: AssetSource,
|
||||||
|
|
||||||
private val nativeAssetEventDetector: NativeAssetEventDetector,
|
private val nativeAssetEventDetector: NativeAssetEventDetector,
|
||||||
@@ -49,6 +50,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get()
|
is Chain.Asset.Type.Equilibrium -> equilibriumAssetSource.get()
|
||||||
is Chain.Asset.Type.TronNative -> tronNativeSource.get()
|
is Chain.Asset.Type.TronNative -> tronNativeSource.get()
|
||||||
is Chain.Asset.Type.Trc20 -> trc20Source.get()
|
is Chain.Asset.Type.Trc20 -> trc20Source.get()
|
||||||
|
is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get()
|
||||||
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
|
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +65,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
add(equilibriumAssetSource.get())
|
add(equilibriumAssetSource.get())
|
||||||
add(tronNativeSource.get())
|
add(tronNativeSource.get())
|
||||||
add(trc20Source.get())
|
add(trc20Source.get())
|
||||||
|
add(bitcoinNativeSource.get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
Chain.Asset.Type.EvmNative,
|
Chain.Asset.Type.EvmNative,
|
||||||
is Chain.Asset.Type.TronNative,
|
is Chain.Asset.Type.TronNative,
|
||||||
is Chain.Asset.Type.Trc20,
|
is Chain.Asset.Type.Trc20,
|
||||||
|
is Chain.Asset.Type.BitcoinNative,
|
||||||
|
|
||||||
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector()
|
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector()
|
||||||
|
|
||||||
|
|||||||
+30
@@ -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<Balance> = flow {
|
||||||
|
var lastEmitted: Balance? = null
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val latest = fetch()
|
||||||
|
|
||||||
|
if (latest != lastEmitted) {
|
||||||
|
lastEmitted = latest
|
||||||
|
emit(latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(intervalMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
@@ -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<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
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<TransferableBalanceUpdatePoint> {
|
||||||
|
// 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<BalanceSyncUpdate> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+22
-26
@@ -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(
|
override suspend fun startSyncingBalance(
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
chainAsset: Chain.Asset,
|
chainAsset: Chain.Asset,
|
||||||
@@ -138,40 +142,32 @@ class StatemineAssetBalance(
|
|||||||
accountId: AccountId,
|
accountId: AccountId,
|
||||||
subscriptionBuilder: SharedRequestsBuilder
|
subscriptionBuilder: SharedRequestsBuilder
|
||||||
): Flow<BalanceSyncUpdate> {
|
): Flow<BalanceSyncUpdate> {
|
||||||
return runCatching {
|
val runtime = chainRegistry.getRuntime(chain.id)
|
||||||
val runtime = chainRegistry.getRuntime(chain.id)
|
|
||||||
|
|
||||||
val statemineType = chainAsset.requireStatemine()
|
val statemineType = chainAsset.requireStatemine()
|
||||||
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
|
val encodableAssetId = statemineType.prepareIdForEncoding(runtime)
|
||||||
|
|
||||||
val module = runtime.metadata.statemineModule(statemineType)
|
val module = runtime.metadata.statemineModule(statemineType)
|
||||||
|
|
||||||
val assetAccountStorage = module.storage("Account")
|
val assetAccountStorage = module.storage("Account")
|
||||||
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
|
val assetAccountKey = assetAccountStorage.storageKey(runtime, encodableAssetId, accountId)
|
||||||
|
|
||||||
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
|
val assetDetailsFlow = statemineAssetsRepository.subscribeAndSyncAssetDetails(chain.id, statemineType, subscriptionBuilder)
|
||||||
|
|
||||||
combine(
|
return combine(
|
||||||
subscriptionBuilder.subscribe(assetAccountKey),
|
subscriptionBuilder.subscribe(assetAccountKey),
|
||||||
assetDetailsFlow.map { it.status.transfersFrozen }
|
assetDetailsFlow.map { it.status.transfersFrozen }
|
||||||
) { balanceStorageChange, isAssetFrozen ->
|
) { balanceStorageChange, isAssetFrozen ->
|
||||||
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
|
val assetAccountDecoded = assetAccountStorage.decodeValue(balanceStorageChange.value, runtime)
|
||||||
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
|
val assetAccount = bindAssetAccountOrEmpty(assetAccountDecoded)
|
||||||
|
|
||||||
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
|
val assetChanged = updateAssetBalance(metaAccount.id, chainAsset, isAssetFrozen, assetAccount)
|
||||||
|
|
||||||
if (assetChanged) {
|
if (assetChanged) {
|
||||||
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
|
BalanceSyncUpdate.CauseFetchable(balanceStorageChange.block)
|
||||||
} else {
|
} else {
|
||||||
BalanceSyncUpdate.NoCause
|
BalanceSyncUpdate.NoCause
|
||||||
}
|
|
||||||
}.catch { error ->
|
|
||||||
Log.e(LOG_TAG, "Balance sync failed for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
|
||||||
emit(BalanceSyncUpdate.NoCause)
|
|
||||||
}
|
}
|
||||||
}.getOrElse { error ->
|
|
||||||
Log.e(LOG_TAG, "Failed to start balance sync for ${chainAsset.symbol} on ${chain.name}: ${error.message}")
|
|
||||||
emptyFlow()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-8
@@ -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_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.blockchain.assets.balances.tronNative.pollingBalanceFlow
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.TronGridApi
|
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.requireTronGridBaseUrl
|
||||||
import io.novafoundation.nova.runtime.ext.requireTrc20
|
import io.novafoundation.nova.runtime.ext.requireTrc20
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
@@ -21,9 +20,9 @@ import kotlinx.coroutines.flow.map
|
|||||||
import java.math.BigInteger
|
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
|
* TRC-20 token balance on a Tron-based chain. Read-only (Phase 1): fetches via an on-chain `balanceOf` contract
|
||||||
* `/v1/accounts/{address}` endpoint used for native TRX - TronGrid returns both in one response) and polls for
|
* call (see [TronGridApi.fetchTrc20Balance] for why this can't reuse the `/v1/accounts` endpoint that native
|
||||||
* updates. No transfer/history support here - see `TronAssetsModule`.
|
* TRX balance reads from) and polls for updates. No transfer/history support here - see `TronAssetsModule`.
|
||||||
*/
|
*/
|
||||||
class Trc20AssetBalance(
|
class Trc20AssetBalance(
|
||||||
private val assetCache: AssetCache,
|
private val assetCache: AssetCache,
|
||||||
@@ -52,9 +51,8 @@ class Trc20AssetBalance(
|
|||||||
|
|
||||||
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
|
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
|
||||||
val contractAddress = chainAsset.requireTrc20().contractAddress
|
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)
|
return ChainAssetBalance.fromFree(chainAsset, balance)
|
||||||
}
|
}
|
||||||
@@ -77,9 +75,8 @@ class Trc20AssetBalance(
|
|||||||
): Flow<BalanceSyncUpdate> {
|
): Flow<BalanceSyncUpdate> {
|
||||||
val contractAddress = chainAsset.requireTrc20().contractAddress
|
val contractAddress = chainAsset.requireTrc20().contractAddress
|
||||||
val baseUrl = chain.requireTronGridBaseUrl()
|
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 ->
|
.map { balance ->
|
||||||
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
|
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
|
||||||
|
|
||||||
|
|||||||
+13
-2
@@ -1,17 +1,26 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.tronNative
|
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 io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
private const val TRON_BALANCE_POLLING_INTERVAL_MS = 30_000L
|
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
|
* 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
|
* `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
|
* assets are polled instead of pushed: fetch immediately, then re-fetch on an interval, only emitting when the
|
||||||
* balance actually changed.
|
* 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(
|
internal fun pollingBalanceFlow(
|
||||||
intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS,
|
intervalMs: Long = TRON_BALANCE_POLLING_INTERVAL_MS,
|
||||||
@@ -20,9 +29,11 @@ internal fun pollingBalanceFlow(
|
|||||||
var lastEmitted: Balance? = null
|
var lastEmitted: Balance? = null
|
||||||
|
|
||||||
while (true) {
|
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
|
lastEmitted = latest
|
||||||
emit(latest)
|
emit(latest)
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-11
@@ -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(
|
override suspend fun startSyncingBalance(
|
||||||
chain: Chain,
|
chain: Chain,
|
||||||
chainAsset: Chain.Asset,
|
chainAsset: Chain.Asset,
|
||||||
@@ -160,13 +171,7 @@ class NativeAssetBalance(
|
|||||||
): Flow<BalanceSyncUpdate> {
|
): Flow<BalanceSyncUpdate> {
|
||||||
val runtime = chainRegistry.getRuntime(chain.id)
|
val runtime = chainRegistry.getRuntime(chain.id)
|
||||||
|
|
||||||
val key = try {
|
val key = runtime.metadata.system().storage("Account").storageKey(runtime, accountId)
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
return subscriptionBuilder.subscribe(key)
|
return subscriptionBuilder.subscribe(key)
|
||||||
.map { change ->
|
.map { change ->
|
||||||
@@ -179,10 +184,6 @@ class NativeAssetBalance(
|
|||||||
BalanceSyncUpdate.NoCause
|
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<BlockchainHold>? {
|
private fun bindBalanceHolds(dynamicInstance: Any?): List<BlockchainHold>? {
|
||||||
|
|||||||
+86
@@ -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<ExtrinsicSubmission> {
|
||||||
|
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<TransactionExecution> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -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<ExtrinsicSubmission> {
|
||||||
|
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<TransactionExecution> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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<ExtrinsicSubmission> {
|
||||||
|
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<TransactionExecution> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -121,6 +121,7 @@ private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveA
|
|||||||
is Type.Statemine -> WillRemoveAccount.WillTransferDust(amount)
|
is Type.Statemine -> WillRemoveAccount.WillTransferDust(amount)
|
||||||
is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
|
is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
|
||||||
is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust
|
is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust
|
||||||
|
Type.BitcoinNative -> WillRemoveAccount.WillBurnDust
|
||||||
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
|
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
|
||||||
Type.Unsupported -> throw IllegalArgumentException("Unsupported")
|
Type.Unsupported -> throw IllegalArgumentException("Unsupported")
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-1
@@ -1,14 +1,56 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
||||||
|
|
||||||
import io.novafoundation.nova.common.data.network.UserAgent
|
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.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.GET
|
||||||
import retrofit2.http.Headers
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Url
|
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 {
|
interface RetrofitTronGridApi {
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Headers(UserAgent.NOVA)
|
@Headers(UserAgent.NOVA, TRON_API_KEY_HEADER)
|
||||||
suspend fun getAccount(@Url url: String): TronAccountResponse
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+233
-9
@@ -1,39 +1,263 @@
|
|||||||
package io.novafoundation.nova.feature_wallet_impl.data.network.tron
|
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_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
|
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": "<hex>"}` from `broadcasttransaction`.
|
||||||
|
*/
|
||||||
|
class TronApiException(message: String) : Exception(message)
|
||||||
|
|
||||||
interface TronGridApi {
|
interface TronGridApi {
|
||||||
|
|
||||||
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
|
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<String, Long>
|
||||||
|
|
||||||
|
suspend fun getAccountResource(baseUrl: String, addressHex: String): TronAccountResourceResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
class RealTronGridApi(
|
class RealTronGridApi(
|
||||||
private val retrofitApi: RetrofitTronGridApi
|
private val retrofitApi: RetrofitTronGridApi
|
||||||
) : TronGridApi {
|
) : 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 <T> 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 {
|
override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance {
|
||||||
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
|
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
|
||||||
|
|
||||||
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
|
return accountData.balance?.toBigInteger() ?: BigInteger.ZERO
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun fetchTrc20Balance(baseUrl: String, address: String, contractAddress: String): Balance {
|
override suspend fun fetchTrc20Balance(baseUrl: String, holderAccountId: AccountId, contractAddress: String): Balance {
|
||||||
val accountData = fetchAccountData(baseUrl, address) ?: return BigInteger.ZERO
|
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()
|
val resultHex = response.constantResult?.firstOrNull() ?: return BigInteger.ZERO
|
||||||
.firstNotNullOfOrNull { entry -> entry[contractAddress] }
|
|
||||||
|
|
||||||
return rawBalance?.toBigIntegerOrNull() ?: BigInteger.ZERO
|
return runCatching { BigInteger(resultHex, 16) }.getOrDefault(BigInteger.ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchAccountData(baseUrl: String, address: String) = retrofitApi.getAccount(
|
override suspend fun createNativeTransfer(
|
||||||
url = accountUrl(baseUrl, address)
|
baseUrl: String,
|
||||||
).data?.firstOrNull()
|
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<String, Long> {
|
||||||
|
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 {
|
private fun accountUrl(baseUrl: String, address: String): String {
|
||||||
return "${baseUrl.trimEnd('/')}/v1/accounts/$address"
|
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") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+116
@@ -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<String>? = 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<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* On success: `{"result": true, "txid": "..."}`.
|
||||||
|
* On failure: `{"code": "CONTRACT_VALIDATE_ERROR", "txid": "...", "message": "<hex-encoded ascii>"}` - 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<TronChainParameter> = 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,
|
||||||
|
)
|
||||||
+269
@@ -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<ExtrinsicSubmission> = 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<TransactionExecution> {
|
||||||
|
// 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<TronFee>()?.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)
|
||||||
|
}
|
||||||
+49
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -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<ExtrinsicSubmission>
|
||||||
|
|
||||||
|
suspend fun transactAndAwaitExecution(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipient: AccountId,
|
||||||
|
presetFee: Fee?,
|
||||||
|
intent: TronTransactionIntent
|
||||||
|
): Result<TransactionExecution>
|
||||||
|
}
|
||||||
+3
@@ -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.FeePaymentProviderRegistry
|
||||||
import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeCapabilityFacade
|
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.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.AccountRepository
|
||||||
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
|
||||||
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
|
import io.novafoundation.nova.feature_account_api.domain.updaters.AccountUpdateScope
|
||||||
@@ -74,6 +75,8 @@ interface WalletFeatureDependencies {
|
|||||||
|
|
||||||
val evmTransactionService: EvmTransactionService
|
val evmTransactionService: EvmTransactionService
|
||||||
|
|
||||||
|
val signerProvider: SignerProvider
|
||||||
|
|
||||||
val chainAssetDao: ChainAssetDao
|
val chainAssetDao: ChainAssetDao
|
||||||
|
|
||||||
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
|
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
|
||||||
|
|||||||
+3
@@ -22,6 +22,7 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets
|
|||||||
EvmNativeAssetsModule::class,
|
EvmNativeAssetsModule::class,
|
||||||
EquilibriumAssetsModule::class,
|
EquilibriumAssetsModule::class,
|
||||||
TronAssetsModule::class,
|
TronAssetsModule::class,
|
||||||
|
BitcoinAssetsModule::class,
|
||||||
UnsupportedAssetsModule::class
|
UnsupportedAssetsModule::class
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -38,6 +39,7 @@ class AssetsModule {
|
|||||||
@EquilibriumAsset equilibrium: Lazy<AssetSource>,
|
@EquilibriumAsset equilibrium: Lazy<AssetSource>,
|
||||||
@TronNativeAssets tronNative: Lazy<AssetSource>,
|
@TronNativeAssets tronNative: Lazy<AssetSource>,
|
||||||
@Trc20Assets trc20: Lazy<AssetSource>,
|
@Trc20Assets trc20: Lazy<AssetSource>,
|
||||||
|
@BitcoinNativeAssets bitcoinNative: Lazy<AssetSource>,
|
||||||
@UnsupportedAssets unsupported: AssetSource,
|
@UnsupportedAssets unsupported: AssetSource,
|
||||||
|
|
||||||
nativeAssetEventDetector: NativeAssetEventDetector,
|
nativeAssetEventDetector: NativeAssetEventDetector,
|
||||||
@@ -53,6 +55,7 @@ class AssetsModule {
|
|||||||
equilibriumAssetSource = equilibrium,
|
equilibriumAssetSource = equilibrium,
|
||||||
tronNativeSource = tronNative,
|
tronNativeSource = tronNative,
|
||||||
trc20Source = trc20,
|
trc20Source = trc20,
|
||||||
|
bitcoinNativeSource = bitcoinNative,
|
||||||
unsupportedBalanceSource = unsupported,
|
unsupportedBalanceSource = unsupported,
|
||||||
|
|
||||||
nativeAssetEventDetector = nativeAssetEventDetector,
|
nativeAssetEventDetector = nativeAssetEventDetector,
|
||||||
|
|||||||
+81
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
+47
-9
@@ -4,16 +4,22 @@ import dagger.Module
|
|||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import io.novafoundation.nova.common.data.network.NetworkApiCreator
|
import io.novafoundation.nova.common.data.network.NetworkApiCreator
|
||||||
import io.novafoundation.nova.common.di.scope.FeatureScope
|
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.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.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.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.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.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.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.RealTronGridApi
|
||||||
import io.novafoundation.nova.feature_wallet_impl.data.network.tron.RetrofitTronGridApi
|
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.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
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
@Qualifier
|
@Qualifier
|
||||||
@@ -23,11 +29,17 @@ annotation class TronNativeAssets
|
|||||||
annotation class Trc20Assets
|
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
|
* Phase 1 (read-only): `balance`.
|
||||||
* the app uses for asset types with no send/history support yet (see `UnsupportedAssetsModule`). This is
|
* Phase 2 (send/transfer, this module): `transfers`, via [TronTransactionService] - construction/broadcast
|
||||||
* intentional: no transaction-building/signing code exists for Tron yet - that is future, separate work.
|
* 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
|
@Module
|
||||||
class TronAssetsModule {
|
class TronAssetsModule {
|
||||||
@@ -42,6 +54,18 @@ class TronAssetsModule {
|
|||||||
@FeatureScope
|
@FeatureScope
|
||||||
fun provideTronGridApi(retrofitTronGridApi: RetrofitTronGridApi): TronGridApi = RealTronGridApi(retrofitTronGridApi)
|
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
|
@Provides
|
||||||
@FeatureScope
|
@FeatureScope
|
||||||
fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi)
|
fun provideTronNativeBalance(assetCache: AssetCache, tronGridApi: TronGridApi) = TronNativeAssetBalance(assetCache, tronGridApi)
|
||||||
@@ -50,15 +74,29 @@ class TronAssetsModule {
|
|||||||
@FeatureScope
|
@FeatureScope
|
||||||
fun provideTrc20Balance(assetCache: AssetCache, tronGridApi: TronGridApi) = Trc20AssetBalance(assetCache, tronGridApi)
|
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
|
@Provides
|
||||||
@TronNativeAssets
|
@TronNativeAssets
|
||||||
@FeatureScope
|
@FeatureScope
|
||||||
fun provideTronNativeAssetSource(
|
fun provideTronNativeAssetSource(
|
||||||
tronNativeAssetBalance: TronNativeAssetBalance,
|
tronNativeAssetBalance: TronNativeAssetBalance,
|
||||||
unsupportedAssetTransfers: UnsupportedAssetTransfers,
|
tronNativeAssetTransfers: TronNativeAssetTransfers,
|
||||||
unsupportedAssetHistory: UnsupportedAssetHistory,
|
unsupportedAssetHistory: UnsupportedAssetHistory,
|
||||||
): AssetSource = StaticAssetSource(
|
): AssetSource = StaticAssetSource(
|
||||||
transfers = unsupportedAssetTransfers,
|
transfers = tronNativeAssetTransfers,
|
||||||
balance = tronNativeAssetBalance,
|
balance = tronNativeAssetBalance,
|
||||||
history = unsupportedAssetHistory
|
history = unsupportedAssetHistory
|
||||||
)
|
)
|
||||||
@@ -68,10 +106,10 @@ class TronAssetsModule {
|
|||||||
@FeatureScope
|
@FeatureScope
|
||||||
fun provideTrc20AssetSource(
|
fun provideTrc20AssetSource(
|
||||||
trc20AssetBalance: Trc20AssetBalance,
|
trc20AssetBalance: Trc20AssetBalance,
|
||||||
unsupportedAssetTransfers: UnsupportedAssetTransfers,
|
trc20AssetTransfers: Trc20AssetTransfers,
|
||||||
unsupportedAssetHistory: UnsupportedAssetHistory,
|
unsupportedAssetHistory: UnsupportedAssetHistory,
|
||||||
): AssetSource = StaticAssetSource(
|
): AssetSource = StaticAssetSource(
|
||||||
transfers = unsupportedAssetTransfers,
|
transfers = trc20AssetTransfers,
|
||||||
balance = trc20AssetBalance,
|
balance = trc20AssetBalance,
|
||||||
history = unsupportedAssetHistory
|
history = unsupportedAssetHistory
|
||||||
)
|
)
|
||||||
|
|||||||
+276
@@ -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 <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> any(): T {
|
||||||
|
Mockito.any<T>()
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||||
|
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> 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<SignerPayloadRaw> { 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<String, Long>())
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-10
@@ -5,17 +5,23 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
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", "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/master/assets/evm/v3/assets.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/master/chains/v22/preConfigured/chains.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/master/chains/v22/preConfigured/details\""
|
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", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
|
||||||
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
|
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
|
||||||
|
buildConfigField "String", "TRONGRID_API_KEY", readStringSecret("TRONGRID_API_KEY")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -27,10 +33,10 @@ android {
|
|||||||
minifyEnabled false
|
minifyEnabled false
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
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", "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/master/assets/evm/v3/assets.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/master/chains/v22/preConfigured/chains.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/master/chains/v22/preConfigured/details\""
|
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'
|
namespace 'io.novafoundation.nova.runtime'
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchro
|
|||||||
import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher
|
import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher
|
||||||
import io.novasama.substrate_sdk_android.wsrpc.SocketService
|
import io.novasama.substrate_sdk_android.wsrpc.SocketService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.logging.HttpLoggingInterceptor
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
import org.web3j.protocol.http.HttpService
|
import org.web3j.protocol.http.HttpService
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
|
|
||||||
@@ -160,12 +162,18 @@ class ChainRegistryModule {
|
|||||||
socketProvider: Provider<SocketService>,
|
socketProvider: Provider<SocketService>,
|
||||||
bulkRetriever: BulkRetriever,
|
bulkRetriever: BulkRetriever,
|
||||||
connectionSecrets: ConnectionSecrets,
|
connectionSecrets: ConnectionSecrets,
|
||||||
web3ApiFactory: Web3ApiFactory
|
web3ApiFactory: Web3ApiFactory,
|
||||||
) = NodeHealthStateTesterFactory(
|
) = NodeHealthStateTesterFactory(
|
||||||
socketProvider,
|
socketProvider,
|
||||||
connectionSecrets,
|
connectionSecrets,
|
||||||
bulkRetriever,
|
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
|
@Provides
|
||||||
|
|||||||
@@ -9,13 +9,21 @@ import io.novafoundation.nova.common.utils.Modules
|
|||||||
import io.novafoundation.nova.common.utils.TokenSymbol
|
import io.novafoundation.nova.common.utils.TokenSymbol
|
||||||
import io.novafoundation.nova.common.utils.Urls
|
import io.novafoundation.nova.common.utils.Urls
|
||||||
import io.novafoundation.nova.common.utils.asTokenSymbol
|
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.emptyEthereumAccountId
|
||||||
import io.novafoundation.nova.common.utils.emptySubstrateAccountId
|
import io.novafoundation.nova.common.utils.emptySubstrateAccountId
|
||||||
import io.novafoundation.nova.common.utils.findIsInstanceOrNull
|
import io.novafoundation.nova.common.utils.findIsInstanceOrNull
|
||||||
import io.novafoundation.nova.common.utils.formatNamed
|
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.removeHexPrefix
|
||||||
import io.novafoundation.nova.common.utils.emptyTronAccountId
|
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.substrateAccountId
|
||||||
|
import io.novafoundation.nova.common.utils.toBitcoinAddress
|
||||||
import io.novafoundation.nova.common.utils.toTronAddress
|
import io.novafoundation.nova.common.utils.toTronAddress
|
||||||
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
import io.novafoundation.nova.common.utils.tronAddressToAccountId
|
||||||
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
import io.novafoundation.nova.common.utils.tronPublicKeyToAccountId
|
||||||
@@ -272,6 +280,7 @@ fun Chain.requireGenesisHash() = requireNotNull(genesisHash)
|
|||||||
fun Chain.addressOf(accountId: ByteArray): String {
|
fun Chain.addressOf(accountId: ByteArray): String {
|
||||||
return when {
|
return when {
|
||||||
isTronBased -> accountId.toTronAddress()
|
isTronBased -> accountId.toTronAddress()
|
||||||
|
isBitcoinBased -> accountId.toBitcoinAddress()
|
||||||
isEthereumBased -> accountId.toEthereumAddress()
|
isEthereumBased -> accountId.toEthereumAddress()
|
||||||
else -> accountId.toAddress(addressPrefix.toShort())
|
else -> accountId.toAddress(addressPrefix.toShort())
|
||||||
}
|
}
|
||||||
@@ -282,7 +291,7 @@ fun Chain.addressOf(accountId: AccountIdKey): String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
|
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
|
||||||
return if (isEthereumBased || isTronBased) {
|
return if (isEthereumBased || isTronBased || isBitcoinBased) {
|
||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) }
|
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) }
|
||||||
@@ -296,6 +305,12 @@ fun ByteArray.toEthereumAddress(): String {
|
|||||||
fun Chain.accountIdOf(address: String): ByteArray {
|
fun Chain.accountIdOf(address: String): ByteArray {
|
||||||
return when {
|
return when {
|
||||||
isTronBased -> address.tronAddressToAccountId()
|
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
|
isEthereumBased -> address.asEthereumAddress().toAccountId().value
|
||||||
else -> address.toAccountId()
|
else -> address.toAccountId()
|
||||||
}
|
}
|
||||||
@@ -329,6 +344,7 @@ fun Chain.accountIdOrNull(address: String): ByteArray? {
|
|||||||
|
|
||||||
fun Chain.emptyAccountId() = when {
|
fun Chain.emptyAccountId() = when {
|
||||||
isTronBased -> emptyTronAccountId()
|
isTronBased -> emptyTronAccountId()
|
||||||
|
isBitcoinBased -> emptyBitcoinAccountId()
|
||||||
isEthereumBased -> emptyEthereumAccountId()
|
isEthereumBased -> emptyEthereumAccountId()
|
||||||
else -> emptySubstrateAccountId()
|
else -> emptySubstrateAccountId()
|
||||||
}
|
}
|
||||||
@@ -342,6 +358,7 @@ fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray {
|
|||||||
fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
|
fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
|
||||||
return when {
|
return when {
|
||||||
isTronBased -> publicKey.tronPublicKeyToAccountId()
|
isTronBased -> publicKey.tronPublicKeyToAccountId()
|
||||||
|
isBitcoinBased -> publicKey.bitcoinPublicKeyToAccountId()
|
||||||
isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value
|
isEthereumBased -> publicKey.asEthereumPublicKey().toAccountId().value
|
||||||
else -> publicKey.substrateAccountId()
|
else -> publicKey.substrateAccountId()
|
||||||
}
|
}
|
||||||
@@ -361,13 +378,24 @@ fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress {
|
|||||||
|
|
||||||
fun Chain.isValidAddress(address: String): Boolean {
|
fun Chain.isValidAddress(address: String): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
if (isEthereumBased) {
|
when {
|
||||||
address.asEthereumAddress().isValid()
|
// Wider than isValidBitcoinAddress() (native SegWit only, used for this wallet's OWN address/accountId):
|
||||||
} else {
|
// a valid SEND destination can legitimately be P2SH/P2PKH too - confirmed live via a real exchange
|
||||||
address.toAccountId() // verify supplied address can be converted to account id
|
// withdrawal address - see BitcoinDestinationAddress.kt.
|
||||||
|
isBitcoinBased -> address.isValidBitcoinDestinationAddress()
|
||||||
|
|
||||||
addressPrefix.toShort() == address.addressPrefix() ||
|
// Tron addresses are Base58Check(0x41 ++ accountId), not SS58 or plain 0x-hex - neither of the two
|
||||||
legacyAddressPrefix?.toShort() == address.addressPrefix()
|
// 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)
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
@@ -481,6 +509,7 @@ object ChainGeneses {
|
|||||||
object ChainIds {
|
object ChainIds {
|
||||||
|
|
||||||
const val ETHEREUM = "$EIP_155_PREFIX:1"
|
const val ETHEREUM = "$EIP_155_PREFIX:1"
|
||||||
|
const val TRON = "tron:0x2b6653dc"
|
||||||
|
|
||||||
const val MOONBEAM = ChainGeneses.MOONBEAM
|
const val MOONBEAM = ChainGeneses.MOONBEAM
|
||||||
const val MOONRIVER = ChainGeneses.MOONRIVER
|
const val MOONRIVER = ChainGeneses.MOONRIVER
|
||||||
@@ -492,6 +521,33 @@ val Chain.Companion.Geneses
|
|||||||
val Chain.Companion.Ids
|
val Chain.Companion.Ids
|
||||||
get() = ChainIds
|
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 {
|
fun Chain.Asset.requireStatemine(): Type.Statemine {
|
||||||
require(type is 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 {
|
fun Chain.Asset.requireEquilibrium(): Type.Equilibrium {
|
||||||
require(type is Type.Equilibrium)
|
require(type is Type.Equilibrium)
|
||||||
|
|
||||||
@@ -614,6 +682,7 @@ val Chain.Asset.onChainAssetId: String?
|
|||||||
is Type.EvmNative -> null
|
is Type.EvmNative -> null
|
||||||
is Type.Trc20 -> this.type.contractAddress
|
is Type.Trc20 -> this.type.contractAddress
|
||||||
Type.TronNative -> null
|
Type.TronNative -> null
|
||||||
|
Type.BitcoinNative -> null
|
||||||
Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}")
|
Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user