Initial commit: Pezkuwi Wallet Android

Security hardened release:
- Code obfuscation enabled (minifyEnabled=true, shrinkResources=true)
- Sensitive files excluded (google-services.json, keystores)
- Branch.io key moved to BuildConfig placeholder
- Updated dependencies: OkHttp 4.12.0, Gson 2.10.1, BouncyCastle 1.77
- Comprehensive ProGuard rules for crypto wallet
- Navigation 2.7.7, Lifecycle 2.7.0, ConstraintLayout 2.1.4
This commit is contained in:
2026-02-12 05:19:41 +03:00
commit a294aa1a6b
7687 changed files with 441811 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+69
View File
@@ -0,0 +1,69 @@
apply from: '../scripts/secrets.gradle'
apply plugin: 'kotlin-parcelize'
android {
defaultConfig {
buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\""
buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\""
buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\""
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\""
buildConfigField "String", "TEST_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/tests/chains_for_testBalance.json\""
buildConfigField "String", "INFURA_API_KEY", readStringSecret("INFURA_API_KEY")
buildConfigField "String", "DWELLIR_API_KEY", readStringSecret("DWELLIR_API_KEY")
}
buildTypes {
debug {
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
buildConfigField "String", "CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/android/chains.json\""
buildConfigField "String", "EVM_ASSETS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/assets/evm/v3/assets.json\""
buildConfigField "String", "PRE_CONFIGURED_CHAINS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/chains.json\""
buildConfigField "String", "PRE_CONFIGURED_CHAIN_DETAILS_URL", "\"https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/v22/preConfigured/details\""
}
}
namespace 'io.novafoundation.nova.runtime'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(":common")
implementation project(":core-db")
implementation project(":core-api")
implementation project(":bindings:metadata_shortener")
implementation project(":bindings:sr25519-bizinikiwi")
implementation gsonDep
implementation substrateSdkDep
implementation kotlinDep
implementation coroutinesDep
implementation retrofitDep
implementation daggerDep
ksp daggerCompiler
testImplementation project(':test-shared')
implementation lifeCycleKtxDep
androidTestImplementation androidTestRunnerDep
androidTestImplementation androidTestRulesDep
androidTestImplementation androidJunitDep
}
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest>
</manifest>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+410
View File
@@ -0,0 +1,410 @@
{
"runtime_id": 2030,
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"AccountInfo": "AccountInfoWithTripleRefCount",
"BlockNumber": "U32",
"LeasePeriod": "BlockNumber",
"Weight": "u64",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["para_validator", "AccountId"],
["para_assignment", "AccountId"],
["authority_discovery", "AccountId"]
]
},
"DispatchInfo": {
"type": "struct",
"type_mapping": [
["weight", "Weight"],
["class", "DispatchClass"],
["paysFee", "Pays"]
]
},
"ProxyType": {
"type": "enum",
"value_list": [
"Any",
"NonTransfer",
"Governance",
"Staking",
"IdentityJudgement",
"CancelProxy"
]
},
"RefCount": "u32",
"ValidatorPrefs": "ValidatorPrefsWithBlocked"
},
"versioning": [
{
"runtime_range": [1019, 1031],
"types": {
"DispatchError": {
"type": "struct",
"type_mapping": [
["module", "Option<u8>"],
["error", "u8"]
]
}
}
},
{
"runtime_range": [1032, null],
"types": {
"DispatchError": {
"type": "enum",
"type_mapping": [
["Other", "Null"],
["CannotLookup", "Null"],
["BadOrigin", "Null"],
["Module", "DispatchErrorModule"]
]
}
}
},
{
"runtime_range": [1019, 1037],
"types": {
"IdentityInfo": {
"type": "struct",
"type_mapping": [
["additional", "Vec<IdentityInfoAdditional>"],
["display", "Data"],
["legal", "Data"],
["web", "Data"],
["riot", "Data"],
["email", "Data"],
["pgpFingerprint", "Option<H160>"],
["image", "Data"]
]
}
}
},
{
"runtime_range": [1038, null],
"types": {
"IdentityInfo": {
"type": "struct",
"type_mapping": [
["additional", "Vec<IdentityInfoAdditional>"],
["display", "Data"],
["legal", "Data"],
["web", "Data"],
["riot", "Data"],
["email", "Data"],
["pgpFingerprint", "Option<H160>"],
["image", "Data"],
["twitter", "Data"]
]
}
}
},
{
"runtime_range": [1019, 1042],
"types": {
"SlashingSpans": {
"type": "struct",
"type_mapping": [
["spanIndex", "SpanIndex"],
["lastStart", "EraIndex"],
["prior", "Vec<EraIndex>"]
]
}
}
},
{
"runtime_range": [1043, null],
"types": {
"SlashingSpans": {
"type": "struct",
"type_mapping": [
["spanIndex", "SpanIndex"],
["lastStart", "EraIndex"],
["lastNonzeroSlash", "EraIndex"],
["prior", "Vec<EraIndex>"]
]
}
}
},
{
"runtime_range": [1019, 1045],
"types": {
"StakingLedger": "StakingLedgerTo223",
"BalanceLock": {
"type": "struct",
"type_mapping": [
["id", "LockIdentifier"],
["amount", "Balance"],
["until", "BlockNumber"],
["reasons", "WithdrawReasons"]
]
}
}
},
{
"runtime_range": [1050, 1056],
"types": {
"StakingLedger": "StakingLedgerTo240",
"BalanceLock": {
"type": "struct",
"type_mapping": [
["id", "LockIdentifier"],
["amount", "Balance"],
["reasons", "Reasons"]
]
}
}
},
{
"runtime_range": [1057, null],
"types": {
"StakingLedger": {
"type": "struct",
"type_mapping": [
[
"stash",
"AccountId"
],
[
"total",
"Compact<Balance>"
],
[
"active",
"Compact<Balance>"
],
[
"unlocking",
"Vec<UnlockChunk>"
],
[
"claimedRewards",
"Vec<EraIndex>"
]
]
},
"BalanceLock": {
"type": "struct",
"type_mapping": [
["id", "LockIdentifier"],
["amount", "Balance"],
["reasons", "Reasons"]
]
}
}
},
{
"runtime_range": [1019, 1054],
"types": {
"ReferendumInfo": {
"type": "struct",
"type_mapping": [
["end", "BlockNumber"],
["proposal", "Proposal"],
["threshold", "VoteThreshold"],
["delay", "BlockNumber"]
]
}
}
},
{
"runtime_range": [1054, null],
"types": {
"ReferendumInfo": {
"type": "enum",
"type_mapping": [
["Ongoing", "ReferendumStatus"],
["Finished", "ReferendumInfoFinished"]
]
}
}
},
{
"runtime_range": [1019, 1056],
"types": {
"Weight": "u32"
}
},
{
"runtime_range": [1057, null],
"types": {
"Weight": "u64"
}
},
{
"runtime_range": [1019, 1061],
"types": {
"Heartbeat": {
"type": "struct",
"type_mapping": [
["blockNumber", "BlockNumber"],
["networkState", "OpaqueNetworkState"],
["sessionIndex", "SessionIndex"],
["authorityIndex", "AuthIndex"]
]
},
"DispatchInfo": {
"type": "struct",
"type_mapping": [
["weight", "Weight"],
["class", "DispatchClass"],
["paysFee", "bool"]
]
}
}
},
{
"runtime_range": [1062, null],
"types": {
"Heartbeat": {
"type": "struct",
"type_mapping": [
["blockNumber", "BlockNumber"],
["networkState", "OpaqueNetworkState"],
["sessionIndex", "SessionIndex"],
["authorityIndex", "AuthIndex"],
["validatorsLen", "u32"]
]
},
"DispatchInfo": {
"type": "struct",
"type_mapping": [
["weight", "Weight"],
["class", "DispatchClass"],
["paysFee", "Pays"]
]
}
}
},
{
"runtime_range": [1019, 2012],
"types": {
"OpenTip": {
"type": "struct",
"type_mapping": [
["reason", "Hash"],
["who", "AccountId"],
["finder", "Option<OpenTipFinder>"],
["closes", "Option<BlockNumber>"],
["tips", "Vec<OpenTipTip>"]
]
}
}
},
{
"runtime_range": [2013, null],
"types": {
"OpenTip": {
"type": "struct",
"type_mapping": [
["reason", "Hash"],
["who", "AccountId"],
["finder", "AccountId"],
["deposit", "Balance"],
["closes", "Option<BlockNumber>"],
["tips", "Vec<OpenTipTip>"],
["findersFee", "bool"]
]
}
}
},
{
"runtime_range": [1019, 2022],
"types": {
"CompactAssignments": "CompactAssignmentsTo257"
}
},
{
"runtime_range": [2023, null],
"types": {
"CompactAssignments": "CompactAssignmentsFrom258"
}
},
{
"runtime_range": [1019, 2024],
"types": {
"RefCount": "u8"
}
},
{
"runtime_range": [2025, null],
"types": {
"RefCount": "u32"
}
},
{
"runtime_range": [1019, 1045],
"types": {
"Address": "RawAddress",
"LookupSource": "RawAddress",
"AccountInfo": "AccountInfoWithRefCount",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["authority_discovery", "AccountId"],
["parachains", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithCommission"
}
},
{
"runtime_range": [1050, 2027],
"types": {
"Address": "AccountIdAddress",
"LookupSource": "AccountIdAddress",
"AccountInfo": "AccountInfoWithRefCount",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["authority_discovery", "AccountId"],
["parachains", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithCommission"
}
},
{
"runtime_range": [2028, null],
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["para_validator", "AccountId"],
["para_assignment", "AccountId"],
["authority_discovery", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithBlocked"
}
},
{
"runtime_range": [2028, 2029],
"types": {
"AccountInfo": "AccountInfoWithDualRefCount"
}
},
{
"runtime_range": [2030, null],
"types": {
"AccountInfo": "AccountInfoWithTripleRefCount"
}
}
]
}
@@ -0,0 +1,12 @@
{
"types": {
"ExtrinsicSignature": "MultiSignature",
"Address": "MultiAddress",
"LookupSource": "MultiAddress"
},
"typesAlias": {
"pezsp_runtime.multiaddress.MultiAddress": "MultiAddress",
"pezsp_runtime.MultiSignature": "MultiSignature",
"pezsp_runtime.generic.era.Era": "Era"
}
}
+153
View File
@@ -0,0 +1,153 @@
{
"runtime_id": 30,
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"AccountInfo": "AccountInfoWithTripleRefCount",
"BlockNumber": "U32",
"LeasePeriod": "BlockNumber",
"Weight": "u64",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["para_validator", "AccountId"],
["para_assignment", "AccountId"],
["authority_discovery", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithBlocked",
"DispatchInfo": {
"type": "struct",
"type_mapping": [
["weight", "Weight"],
["class", "DispatchClass"],
["paysFee", "Pays"]
]
},
"ProxyType": {
"type": "enum",
"value_list": [
"Any",
"NonTransfer",
"Governance",
"Staking",
"DeprecatedSudoBalances",
"IdentityJudgement",
"CancelProxy"
]
},
"RefCount": "u32"
},
"versioning": [
{
"runtime_range": [0, 12],
"types": {
"OpenTip": {
"type": "struct",
"type_mapping": [
["reason", "Hash"],
["who", "AccountId"],
["finder", "Option<OpenTipFinder>"],
["closes", "Option<BlockNumber>"],
["tips", "Vec<OpenTipTip>"]
]
}
}
},
{
"runtime_range": [13, null],
"types": {
"OpenTip": {
"type": "struct",
"type_mapping": [
["reason", "Hash"],
["who", "AccountId"],
["finder", "AccountId"],
["deposit", "Balance"],
["closes", "Option<BlockNumber>"],
["tips", "Vec<OpenTipTip>"],
["findersFee", "bool"]
]
}
}
},
{
"runtime_range": [0, 22],
"types": {
"CompactAssignments": "CompactAssignmentsTo257"
}
},
{
"runtime_range": [23, null],
"types": {
"CompactAssignments": "CompactAssignmentsFrom258"
}
},
{
"runtime_range": [0, 24],
"types": {
"RefCount": "u8"
}
},
{
"runtime_range": [25, null],
"types": {
"RefCount": "u32"
}
},
{
"runtime_range": [0, 27],
"types": {
"Address": "AccountIdAddress",
"LookupSource": "AccountIdAddress",
"AccountInfo": "AccountInfoWithRefCount",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["authority_discovery", "AccountId"],
["parachains", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithCommission"
}
},
{
"runtime_range": [28, null],
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"AccountInfo": "AccountInfoWithDualRefCount",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["para_validator", "AccountId"],
["para_assignment", "AccountId"],
["authority_discovery", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithBlocked"
}
},
{
"runtime_range": [28, 29],
"types": {
"AccountInfo": "AccountInfoWithDualRefCount"
}
},
{
"runtime_range": [30, null],
"types": {
"AccountInfo": "AccountInfoWithTripleRefCount"
}
}
]
}
+53
View File
@@ -0,0 +1,53 @@
{
"runtime_id": 9001,
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"AccountInfo": "AccountInfoWithTripleRefCount",
"FullIdentification": "()",
"Keys": "SessionKeys7B",
"CompactAssignments": "CompactAssignmentsFrom258"
},
"versioning": [
{
"runtime_range": [0, 200],
"types": {
"Address": "AccountIdAddress",
"LookupSource": "AccountIdAddress",
"AccountInfo": "AccountInfoWithDualRefCount"
}
},
{
"runtime_range": [201, null],
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"AccountInfo": "AccountInfoWithTripleRefCount"
}
},
{
"runtime_range": [0, 228],
"types": {
"Keys": "SessionKeys6"
}
},
{
"runtime_range": [229, null],
"types": {
"Keys": "SessionKeys7B"
}
},
{
"runtime_range": [0, 9009],
"types": {
"CompactAssignments": "CompactAssignmentsFrom258"
}
},
{
"runtime_range": [9010, null],
"types": {
"CompactAssignments": "CompactAssignmentsFrom265"
}
}
]
}
+184
View File
@@ -0,0 +1,184 @@
{
"runtime_id": 900,
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"AccountInfo": "AccountInfoWithTripleRefCount",
"BlockNumber": "U32",
"LeasePeriod": "BlockNumber",
"Keys": "SessionKeys6",
"ValidatorPrefs": "ValidatorPrefsWithBlocked",
"ProxyType": {
"type": "enum",
"value_list": [
"Any",
"NonTransfer",
"Staking",
"SudoBalances",
"IdentityJudgement",
"CancelProxy"
]
},
"RefCount": "u32"
},
"versioning": [
{
"runtime_range": [3, 22],
"types": {
"OpenTip": {
"type": "struct",
"type_mapping": [
["reason", "Hash"],
["who", "AccountId"],
["finder", "Option<OpenTipFinder>"],
["closes", "Option<BlockNumber>"],
["tips", "Vec<OpenTipTip>"]
]
}
}
},
{
"runtime_range": [23, null],
"types": {
"OpenTip": {
"type": "struct",
"type_mapping": [
["reason", "Hash"],
["who", "AccountId"],
["finder", "AccountId"],
["deposit", "Balance"],
["closes", "Option<BlockNumber>"],
["tips", "Vec<OpenTipTip>"],
["findersFee", "bool"]
]
}
}
},
{
"runtime_range": [3, 44],
"types": {
"RefCount": "u8"
}
},
{
"runtime_range": [45, null],
"types": {
"RefCount": "u32"
}
},
{
"runtime_range": [1, 42],
"types": {
"CompactAssignments": "CompactAssignmentsTo257"
}
},
{
"runtime_range": [43, null],
"types": {
"CompactAssignments": "CompactAssignmentsFrom258"
}
},
{
"runtime_range": [1, 44],
"types": {
"Heartbeat": {
"type": "struct",
"type_mapping": [
["blockNumber", "BlockNumber"],
["networkState", "OpaqueNetworkState"],
["sessionIndex", "SessionIndex"],
["authorityIndex", "AuthIndex"]
]
},
"DispatchInfo": {
"type": "struct",
"type_mapping": [
["weight", "Weight"],
["class", "DispatchClass"],
["paysFee", "bool"]
]
}
}
},
{
"runtime_range": [45, null],
"types": {
"Heartbeat": {
"type": "struct",
"type_mapping": [
["blockNumber", "BlockNumber"],
["networkState", "OpaqueNetworkState"],
["sessionIndex", "SessionIndex"],
["authorityIndex", "AuthIndex"],
["validatorsLen", "u32"]
]
},
"DispatchInfo": {
"type": "struct",
"type_mapping": [
["weight", "Weight"],
["class", "DispatchClass"],
["paysFee", "Pays"]
]
}
}
},
{
"runtime_range": [1, 44],
"types": {
"Weight": "u32"
}
},
{
"runtime_range": [45, null],
"types": {
"Weight": "u64"
}
},
{
"runtime_range": [0, 47],
"types": {
"Address": "AccountIdAddress",
"LookupSource": "AccountIdAddress",
"AccountInfo": "AccountInfoWithRefCount",
"Keys": {
"type": "struct",
"type_mapping": [
["grandpa", "AccountId"],
["babe", "AccountId"],
["im_online", "AccountId"],
["authority_discovery", "AccountId"],
["parachains", "AccountId"]
]
},
"ValidatorPrefs": "ValidatorPrefsWithCommission"
}
},
{
"runtime_range": [48, null],
"types": {
"Address": "MultiAddress",
"LookupSource": "MultiAddress",
"ValidatorPrefs": "ValidatorPrefsWithBlocked"
}
},
{
"runtime_range": [48, 49],
"types": {
"AccountInfo": "AccountInfoWithDualRefCount"
}
},
{
"runtime_range": [50, null],
"types": {
"AccountInfo": "AccountInfoWithTripleRefCount"
}
},
{
"runtime_range": [48, null],
"types": {
"Keys": "SessionKeys6"
}
}
]
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.runtime.call
import io.novafoundation.nova.common.utils.hasDetectedRuntimeApi
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.multiNetwork.getSocket
interface MultiChainRuntimeCallsApi {
suspend fun forChain(chainId: ChainId): RuntimeCallsApi
suspend fun isSupported(chainId: ChainId, section: String, method: String): Boolean
}
internal class RealMultiChainRuntimeCallsApi(
private val chainRegistry: ChainRegistry
) : MultiChainRuntimeCallsApi {
override suspend fun forChain(chainId: ChainId): RuntimeCallsApi {
val runtime = chainRegistry.getRuntime(chainId)
val socket = chainRegistry.getSocket(chainId)
return RealRuntimeCallsApi(runtime, chainId, socket)
}
override suspend fun isSupported(chainId: ChainId, section: String, method: String): Boolean {
val runtime = chainRegistry.getRuntime(chainId)
// Avoid extra allocations of RealRuntimeCallsApi and socket retrieval - check directly from the metadata
return runtime.metadata.hasDetectedRuntimeApi(section, method)
}
}
@@ -0,0 +1,139 @@
package io.novafoundation.nova.runtime.call
import io.novafoundation.nova.common.data.network.runtime.binding.fromHexOrIncompatible
import io.novafoundation.nova.common.utils.hasDetectedRuntimeApi
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.network.rpc.stateCall
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.registry.TypeRegistry
import io.novasama.substrate_sdk_android.runtime.definitions.registry.getOrThrow
import io.novasama.substrate_sdk_android.runtime.definitions.types.bytes
import io.novasama.substrate_sdk_android.runtime.metadata.createRequest
import io.novasama.substrate_sdk_android.runtime.metadata.decodeOutput
import io.novasama.substrate_sdk_android.runtime.metadata.method
import io.novasama.substrate_sdk_android.runtime.metadata.runtimeApi
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import io.novasama.substrate_sdk_android.wsrpc.request.runtime.state.StateCallRequest
typealias RuntimeTypeName = String
typealias RuntimeTypeValue = Any?
interface RuntimeCallsApi {
val chainId: ChainId
val runtime: RuntimeSnapshot
/**
* @param arguments - list of pairs [runtimeTypeValue, runtimeTypeName],
* where runtimeTypeValue is value to be encoded and runtimeTypeName is type name that can be found in [TypeRegistry]
* It can also be null, in that case argument is considered as already encoded in hex form
*
* This should only be used if automatic decoding via metadata is not possible
* For the other cases use another [call] overload
*/
suspend fun <R> call(
section: String,
method: String,
arguments: List<Pair<RuntimeTypeValue, RuntimeTypeName?>>,
returnType: RuntimeTypeName,
returnBinding: (Any?) -> R
): R
suspend fun <R> call(
section: String,
method: String,
arguments: Map<String, Any?>,
returnBinding: (Any?) -> R
): R
fun isSupported(
section: String,
method: String
): Boolean
}
suspend fun <R> RuntimeCallsApi.callCatching(
section: String,
method: String,
arguments: Map<String, Any?>,
returnBinding: (Any?) -> R
): Result<R> {
return runCatching { call(section, method, arguments, returnBinding) }
}
internal class RealRuntimeCallsApi(
override val runtime: RuntimeSnapshot,
override val chainId: ChainId,
private val socketService: SocketService,
) : RuntimeCallsApi {
override suspend fun <R> call(
section: String,
method: String,
arguments: List<Pair<RuntimeTypeValue, RuntimeTypeName?>>,
returnType: String,
returnBinding: (Any?) -> R
): R {
val runtimeApiName = createRuntimeApiName(section, method)
val data = encodeArguments(arguments)
val request = StateCallRequest(runtimeApiName, data)
val response = socketService.stateCall(request)
val decoded = decodeResponse(response, returnType)
return returnBinding(decoded)
}
override suspend fun <R> call(
section: String,
method: String,
arguments: Map<String, Any?>,
returnBinding: (Any?) -> R
): R {
val apiMethod = runtime.metadata.runtimeApi(section).method(method)
val request = apiMethod.createRequest(runtime, arguments)
val response = socketService.stateCall(request)
val decoded = response?.let { apiMethod.decodeOutput(runtime, it) }
return returnBinding(decoded)
}
override fun isSupported(section: String, method: String): Boolean {
return runtime.metadata.hasDetectedRuntimeApi(section, method)
}
private fun decodeResponse(responseHex: String?, returnTypeName: String): Any? {
val returnType = runtime.typeRegistry.getOrThrow(returnTypeName)
return responseHex?.let {
returnType.fromHexOrIncompatible(it, runtime)
}
}
private fun encodeArguments(arguments: List<Pair<RuntimeTypeValue, RuntimeTypeName?>>): String {
return buildString {
arguments.forEach { (typeValue, runtimeTypeName) ->
val argument = if (runtimeTypeName != null) {
val type = runtime.typeRegistry.getOrThrow(runtimeTypeName)
val encodedArgument = type.bytes(runtime, typeValue)
encodedArgument.toHexString(withPrefix = false)
} else {
typeValue.toString()
}
append(argument)
}
}.requireHexPrefix()
}
private fun createRuntimeApiName(section: String, method: String): String {
return "${section}_$method"
}
}
@@ -0,0 +1,244 @@
package io.novafoundation.nova.runtime.di
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.BuildConfig
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.network.rpc.BulkRetriever
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.asset.EvmAssetsSyncService
import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher
import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService
import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher
import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnectionFactory
import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionPool
import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets
import io.novafoundation.nova.runtime.multiNetwork.connection.Web3ApiPool
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.NodeAutobalancer
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider
import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.AsyncChainSyncDispatcher
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeCacheMigrator
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFilesCache
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeMetadataFetcher
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSubscriptionPool
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSyncService
import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer
import io.novafoundation.nova.runtime.multiNetwork.runtime.types.TypesFetcher
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.flow.MutableStateFlow
import okhttp3.logging.HttpLoggingInterceptor
import org.web3j.protocol.http.HttpService
import javax.inject.Provider
@Module
class ChainRegistryModule {
@Provides
@ApplicationScope
fun provideChainFetcher(apiCreator: NetworkApiCreator) = apiCreator.create(ChainFetcher::class.java)
@Provides
@ApplicationScope
fun provideChainSyncService(
dao: ChainDao,
chainAssetDao: ChainAssetDao,
chainFetcher: ChainFetcher,
gson: Gson
) = ChainSyncService(dao, chainFetcher, gson)
@Provides
@ApplicationScope
fun provideAssetFetcher(apiCreator: NetworkApiCreator) = apiCreator.create(AssetFetcher::class.java)
@Provides
@ApplicationScope
fun provideAssetSyncService(
chainAssetDao: ChainAssetDao,
chainDao: ChainDao,
assetFetcher: AssetFetcher,
gson: Gson
) = EvmAssetsSyncService(chainDao, chainAssetDao, assetFetcher, gson)
@Provides
@ApplicationScope
fun provideRuntimeFactory(
runtimeFilesCache: RuntimeFilesCache,
chainDao: ChainDao,
gson: Gson,
): RuntimeFactory {
return RuntimeFactory(runtimeFilesCache, chainDao, gson)
}
@Provides
@ApplicationScope
fun provideRuntimeMetadataFetcher(): RuntimeMetadataFetcher {
return RuntimeMetadataFetcher()
}
@Provides
@ApplicationScope
fun provideRuntimeCacheMigrator(): RuntimeCacheMigrator {
return RuntimeCacheMigrator()
}
@Provides
@ApplicationScope
fun provideRuntimeFilesCache(
fileProvider: FileProvider,
preferences: Preferences
) = RuntimeFilesCache(fileProvider, preferences)
@Provides
@ApplicationScope
fun provideTypesFetcher(
networkApiCreator: NetworkApiCreator,
) = networkApiCreator.create(TypesFetcher::class.java)
@Provides
@ApplicationScope
fun provideRuntimeSyncService(
typesFetcher: TypesFetcher,
runtimeFilesCache: RuntimeFilesCache,
chainDao: ChainDao,
runtimeCacheMigrator: RuntimeCacheMigrator,
runtimeMetadataFetcher: RuntimeMetadataFetcher
) = RuntimeSyncService(
typesFetcher = typesFetcher,
runtimeFilesCache = runtimeFilesCache,
chainDao = chainDao,
runtimeMetadataFetcher = runtimeMetadataFetcher,
cacheMigrator = runtimeCacheMigrator,
chainSyncDispatcher = AsyncChainSyncDispatcher()
)
@Provides
@ApplicationScope
fun provideBaseTypeSynchronizer(
typesFetcher: TypesFetcher,
runtimeFilesCache: RuntimeFilesCache,
) = BaseTypeSynchronizer(runtimeFilesCache, typesFetcher)
@Provides
@ApplicationScope
fun provideRuntimeProviderPool(
runtimeFactory: RuntimeFactory,
runtimeSyncService: RuntimeSyncService,
runtimeFilesCache: RuntimeFilesCache,
baseTypeSynchronizer: BaseTypeSynchronizer,
) = RuntimeProviderPool(runtimeFactory, runtimeSyncService, runtimeFilesCache, baseTypeSynchronizer)
@Provides
@ApplicationScope
fun provideAutoBalanceProvider(
connectionSecrets: ConnectionSecrets
) = NodeSelectionStrategyProvider(connectionSecrets)
@Provides
@ApplicationScope
fun provideNodeAutoBalancer(
nodeSelectionStrategyProvider: NodeSelectionStrategyProvider,
) = NodeAutobalancer(nodeSelectionStrategyProvider)
@Provides
@ApplicationScope
fun provideConnectionSecrets(): ConnectionSecrets = ConnectionSecrets.default()
@Provides
@ApplicationScope
fun provideNodeConnectionFactory(
socketProvider: Provider<SocketService>,
bulkRetriever: BulkRetriever,
connectionSecrets: ConnectionSecrets,
web3ApiFactory: Web3ApiFactory
) = NodeHealthStateTesterFactory(
socketProvider,
connectionSecrets,
bulkRetriever,
web3ApiFactory
)
@Provides
@ApplicationScope
fun provideChainConnectionFactory(
socketProvider: Provider<SocketService>,
externalRequirementsFlow: MutableStateFlow<ChainConnection.ExternalRequirement>,
nodeAutobalancer: NodeAutobalancer,
) = ChainConnectionFactory(
externalRequirementsFlow,
nodeAutobalancer,
socketProvider,
)
@Provides
@ApplicationScope
fun provideConnectionPool(chainConnectionFactory: ChainConnectionFactory) = ConnectionPool(chainConnectionFactory)
@Provides
@ApplicationScope
fun provideRuntimeVersionSubscriptionPool(
chainDao: ChainDao,
runtimeSyncService: RuntimeSyncService,
) = RuntimeSubscriptionPool(chainDao, runtimeSyncService)
@Provides
@ApplicationScope
fun provideWeb3ApiFactory(
strategyProvider: NodeSelectionStrategyProvider,
): Web3ApiFactory {
val builder = HttpService.getOkHttpClientBuilder()
builder.interceptors().clear() // getOkHttpClientBuilder() adds logging interceptor which doesn't log into LogCat
if (BuildConfig.DEBUG) {
builder.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
}
val okHttpClient = builder.build()
return Web3ApiFactory(strategyProvider = strategyProvider, httpClient = okHttpClient)
}
@Provides
@ApplicationScope
fun provideWeb3ApiPool(web3ApiFactory: Web3ApiFactory) = Web3ApiPool(web3ApiFactory)
@Provides
@ApplicationScope
fun provideExternalRequirementsFlow() = MutableStateFlow(ChainConnection.ExternalRequirement.ALLOWED)
@Provides
@ApplicationScope
fun provideChainRegistry(
runtimeProviderPool: RuntimeProviderPool,
chainConnectionPool: ConnectionPool,
runtimeSubscriptionPool: RuntimeSubscriptionPool,
chainDao: ChainDao,
chainSyncService: ChainSyncService,
evmAssetsSyncService: EvmAssetsSyncService,
baseTypeSynchronizer: BaseTypeSynchronizer,
runtimeSyncService: RuntimeSyncService,
web3ApiPool: Web3ApiPool,
gson: Gson
) = ChainRegistry(
runtimeProviderPool,
chainConnectionPool,
runtimeSubscriptionPool,
chainDao,
chainSyncService,
evmAssetsSyncService,
baseTypeSynchronizer,
runtimeSyncService,
web3ApiPool,
gson
)
}
@@ -0,0 +1,119 @@
package io.novafoundation.nova.runtime.di
import com.google.gson.Gson
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.ethereum.Web3ApiFactory
import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory
import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory
import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase
import io.novafoundation.nova.runtime.extrinsic.MortalityConstructor
import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.RemoteToDomainChainMapperFacade
import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.multiNetwork.connection.node.healthState.NodeHealthStateTesterFactory
import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeFilesCache
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import io.novafoundation.nova.runtime.repository.BlockLimitsRepository
import io.novafoundation.nova.runtime.repository.ChainNodeRepository
import io.novafoundation.nova.runtime.repository.ChainRepository
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository
import io.novafoundation.nova.runtime.repository.TimestampRepository
import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository
import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import kotlinx.coroutines.flow.MutableStateFlow
import javax.inject.Named
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class ExtrinsicSerialization
interface RuntimeApi {
fun provideExtrinsicBuilderFactory(): ExtrinsicBuilderFactory
fun externalRequirementFlow(): MutableStateFlow<ChainConnection.ExternalRequirement>
fun storageCache(): StorageCache
@Named(REMOTE_STORAGE_SOURCE)
fun remoteStorageSource(): StorageDataSource
@Named(LOCAL_STORAGE_SOURCE)
fun localStorageSource(): StorageDataSource
fun chainSyncService(): ChainSyncService
fun chainStateRepository(): ChainStateRepository
fun chainRegistry(): ChainRegistry
fun rpcCalls(): RpcCalls
@ExtrinsicSerialization
fun extrinsicGson(): Gson
fun runtimeVersionsRepository(): RuntimeVersionsRepository
fun eventsRepository(): EventsRepository
val multiChainQrSharingFactory: MultiChainQrSharingFactory
val sampledBlockTime: SampledBlockTimeStorage
val parachainInfoRepository: ParachainInfoRepository
val mortalityConstructor: MortalityConstructor
val extrinsicValidityUseCase: ExtrinsicValidityUseCase
val timestampRepository: TimestampRepository
val totalIssuanceRepository: TotalIssuanceRepository
val storageStorageSharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory
val multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi
val gasPriceProviderFactory: GasPriceProviderFactory
val extrinsicWalk: ExtrinsicWalk
val callTraversal: CallTraversal
val runtimeFilesCache: RuntimeFilesCache
val metadataShortenerService: MetadataShortenerService
val runtimeProviderPool: RuntimeProviderPool
val nodeHealthStateTesterFactory: NodeHealthStateTesterFactory
val chainNodeRepository: ChainNodeRepository
val nodeConnectionFactory: NodeConnectionFactory
val web3ApiFactory: Web3ApiFactory
val preConfiguredChainsRepository: PreConfiguredChainsRepository
val chainRepository: ChainRepository
val remoteToDomainChainMapperFacade: RemoteToDomainChainMapperFacade
val blockLimitsRepository: BlockLimitsRepository
}
@@ -0,0 +1,27 @@
package io.novafoundation.nova.runtime.di
import dagger.Component
import io.novafoundation.nova.common.di.CommonApi
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
@Component(
modules = [
RuntimeModule::class,
ChainRegistryModule::class
],
dependencies = [
RuntimeDependencies::class
]
)
@ApplicationScope
abstract class RuntimeComponent : RuntimeApi {
@Component(
dependencies = [
CommonApi::class,
DbApi::class,
]
)
interface RuntimeDependenciesComponent : RuntimeDependencies
}
@@ -0,0 +1,35 @@
package io.novafoundation.nova.runtime.di
import android.content.Context
import com.google.gson.Gson
import io.novafoundation.nova.common.data.network.NetworkApiCreator
import io.novafoundation.nova.common.data.repository.AssetsIconModeRepository
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.interfaces.FileProvider
import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.StorageDao
import io.novasama.substrate_sdk_android.wsrpc.SocketService
interface RuntimeDependencies {
fun networkApiCreator(): NetworkApiCreator
fun socketServiceCreator(): SocketService
fun gson(): Gson
fun preferences(): Preferences
fun fileProvider(): FileProvider
fun context(): Context
fun storageDao(): StorageDao
fun chainDao(): ChainDao
fun chainAssetDao(): ChainAssetDao
fun assetsIconModeService(): AssetsIconModeRepository
}
@@ -0,0 +1,23 @@
package io.novafoundation.nova.runtime.di
import io.novafoundation.nova.common.di.FeatureApiHolder
import io.novafoundation.nova.common.di.FeatureContainer
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core_db.di.DbApi
import javax.inject.Inject
@ApplicationScope
class RuntimeHolder @Inject constructor(
featureContainer: FeatureContainer
) : FeatureApiHolder(featureContainer) {
override fun initializeDependencies(): Any {
val dbDependencies = DaggerRuntimeComponent_RuntimeDependenciesComponent.builder()
.commonApi(commonApi())
.dbApi(getFeature(DbApi::class.java))
.build()
return DaggerRuntimeComponent.builder()
.runtimeDependencies(dbDependencies)
.build()
}
}
@@ -0,0 +1,283 @@
package io.novafoundation.nova.runtime.di
import com.google.gson.Gson
import dagger.Module
import dagger.Provides
import io.novafoundation.nova.common.data.network.rpc.BulkRetriever
import io.novafoundation.nova.common.data.storage.Preferences
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.core.storage.StorageCache
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.StorageDao
import io.novafoundation.nova.runtime.call.MultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.call.RealMultiChainRuntimeCallsApi
import io.novafoundation.nova.runtime.ethereum.StorageSharedRequestsBuilderFactory
import io.novafoundation.nova.runtime.ethereum.gas.GasPriceProviderFactory
import io.novafoundation.nova.runtime.ethereum.gas.RealGasPriceProviderFactory
import io.novafoundation.nova.runtime.extrinsic.ExtrinsicBuilderFactory
import io.novafoundation.nova.runtime.extrinsic.ExtrinsicSerializers
import io.novafoundation.nova.runtime.extrinsic.ExtrinsicValidityUseCase
import io.novafoundation.nova.runtime.extrinsic.MortalityConstructor
import io.novafoundation.nova.runtime.extrinsic.RealExtrinsicValidityUseCase
import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService
import io.novafoundation.nova.runtime.extrinsic.metadata.RealMetadataShortenerService
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.RealCallTraversal
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.RealExtrinsicWalk
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.RemoteToDomainChainMapperFacade
import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher
import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionSecrets
import io.novafoundation.nova.runtime.multiNetwork.connection.node.connection.NodeConnectionFactory
import io.novafoundation.nova.runtime.multiNetwork.qr.MultiChainQrSharingFactory
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.DbRuntimeVersionsRepository
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.EventsRepository
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RemoteEventsRepository
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.RuntimeVersionsRepository
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import io.novafoundation.nova.runtime.repository.BlockLimitsRepository
import io.novafoundation.nova.runtime.repository.ChainNodeRepository
import io.novafoundation.nova.runtime.repository.ChainRepository
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novafoundation.nova.runtime.repository.ParachainInfoRepository
import io.novafoundation.nova.runtime.repository.PreConfiguredChainsRepository
import io.novafoundation.nova.runtime.repository.RealBlockLimitsRepository
import io.novafoundation.nova.runtime.repository.RealChainNodeRepository
import io.novafoundation.nova.runtime.repository.RealChainRepository
import io.novafoundation.nova.runtime.repository.RealParachainInfoRepository
import io.novafoundation.nova.runtime.repository.RealPreConfiguredChainsRepository
import io.novafoundation.nova.runtime.repository.RealTotalIssuanceRepository
import io.novafoundation.nova.runtime.repository.RemoteTimestampRepository
import io.novafoundation.nova.runtime.repository.TimestampRepository
import io.novafoundation.nova.runtime.repository.TotalIssuanceRepository
import io.novafoundation.nova.runtime.storage.DbStorageCache
import io.novafoundation.nova.runtime.storage.PrefsSampledBlockTimeStorage
import io.novafoundation.nova.runtime.storage.SampledBlockTimeStorage
import io.novafoundation.nova.runtime.storage.source.LocalStorageSource
import io.novafoundation.nova.runtime.storage.source.RemoteStorageSource
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import javax.inject.Named
import javax.inject.Provider
const val LOCAL_STORAGE_SOURCE = "LOCAL_STORAGE_SOURCE"
const val REMOTE_STORAGE_SOURCE = "REMOTE_STORAGE_SOURCE"
const val BULK_RETRIEVER_PAGE_SIZE = 1000
@Module
class RuntimeModule {
@Provides
@ApplicationScope
fun provideExtrinsicBuilderFactory(
chainRegistry: ChainRegistry,
mortalityConstructor: MortalityConstructor,
metadataShortenerService: MetadataShortenerService
) = ExtrinsicBuilderFactory(
chainRegistry,
mortalityConstructor,
metadataShortenerService
)
@Provides
@ApplicationScope
fun provideStorageCache(
storageDao: StorageDao,
): StorageCache = DbStorageCache(storageDao)
@Provides
@Named(LOCAL_STORAGE_SOURCE)
@ApplicationScope
fun provideLocalStorageSource(
chainRegistry: ChainRegistry,
storageCache: StorageCache,
sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
): StorageDataSource = LocalStorageSource(chainRegistry, sharedRequestsBuilderFactory, storageCache)
@Provides
@ApplicationScope
fun provideBulkRetriever(): BulkRetriever {
return BulkRetriever(BULK_RETRIEVER_PAGE_SIZE)
}
@Provides
@Named(REMOTE_STORAGE_SOURCE)
@ApplicationScope
fun provideRemoteStorageSource(
chainRegistry: ChainRegistry,
sharedRequestsBuilderFactory: StorageSharedRequestsBuilderFactory,
bulkRetriever: BulkRetriever,
): StorageDataSource = RemoteStorageSource(chainRegistry, sharedRequestsBuilderFactory, bulkRetriever)
@Provides
@ApplicationScope
fun provideSampledBlockTimeStorage(
gson: Gson,
preferences: Preferences,
): SampledBlockTimeStorage = PrefsSampledBlockTimeStorage(gson, preferences)
@Provides
@ApplicationScope
fun provideChainStateRepository(
@Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource,
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
sampledBlockTimeStorage: SampledBlockTimeStorage,
chainRegistry: ChainRegistry
) = ChainStateRepository(localStorageSource, remoteStorageSource, sampledBlockTimeStorage, chainRegistry)
@Provides
@ApplicationScope
fun provideMortalityProvider(
chainStateRepository: ChainStateRepository,
rpcCalls: RpcCalls,
) = MortalityConstructor(rpcCalls, chainStateRepository)
@Provides
@ApplicationScope
fun provideSubstrateCalls(
chainRegistry: ChainRegistry,
multiChainRuntimeCallsApi: MultiChainRuntimeCallsApi
) = RpcCalls(chainRegistry, multiChainRuntimeCallsApi)
@Provides
@ApplicationScope
@ExtrinsicSerialization
fun provideExtrinsicGson() = ExtrinsicSerializers.gson()
@Provides
@ApplicationScope
fun provideRuntimeVersionsRepository(
chainDao: ChainDao
): RuntimeVersionsRepository = DbRuntimeVersionsRepository(chainDao)
@Provides
@ApplicationScope
fun provideEventsRepository(
rpcCalls: RpcCalls,
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource
): EventsRepository = RemoteEventsRepository(rpcCalls, remoteStorageSource)
@Provides
@ApplicationScope
fun provideMultiChainQrSharingFactory() = MultiChainQrSharingFactory()
@Provides
@ApplicationScope
fun provideParachainInfoRepository(
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource
): ParachainInfoRepository = RealParachainInfoRepository(remoteStorageSource)
@Provides
@ApplicationScope
fun provideExtrinsicValidityUseCase(
mortalityConstructor: MortalityConstructor
): ExtrinsicValidityUseCase = RealExtrinsicValidityUseCase(mortalityConstructor)
@Provides
@ApplicationScope
fun provideTimestampRepository(
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
): TimestampRepository = RemoteTimestampRepository(remoteStorageSource)
@Provides
@ApplicationScope
fun provideTotalIssuanceRepository(
@Named(LOCAL_STORAGE_SOURCE) localStorageSource: StorageDataSource,
): TotalIssuanceRepository = RealTotalIssuanceRepository(localStorageSource)
@Provides
@ApplicationScope
fun provideBlockLimitsRepository(
@Named(REMOTE_STORAGE_SOURCE) remoteStorageSource: StorageDataSource,
chainRegistry: ChainRegistry
): BlockLimitsRepository = RealBlockLimitsRepository(remoteStorageSource, chainRegistry)
@Provides
@ApplicationScope
fun provideStorageSharedRequestBuilderFactory(chainRegistry: ChainRegistry) = StorageSharedRequestsBuilderFactory(chainRegistry)
@Provides
@ApplicationScope
fun provideMultiChainRuntimeCallsApi(chainRegistry: ChainRegistry): MultiChainRuntimeCallsApi = RealMultiChainRuntimeCallsApi(chainRegistry)
@Provides
@ApplicationScope
fun provideGasPriceProviderFactory(
chainRegistry: ChainRegistry
): GasPriceProviderFactory = RealGasPriceProviderFactory(chainRegistry)
@Provides
@ApplicationScope
fun provideExtrinsicWalk(
chainRegistry: ChainRegistry,
): ExtrinsicWalk = RealExtrinsicWalk(chainRegistry)
@Provides
@ApplicationScope
fun provideCallTraversal(): CallTraversal = RealCallTraversal()
@Provides
@ApplicationScope
fun provideMetadataShortenerService(
chainRegistry: ChainRegistry,
rpcCalls: RpcCalls,
): MetadataShortenerService {
return RealMetadataShortenerService(chainRegistry, rpcCalls)
}
@Provides
@ApplicationScope
fun provideChainNodeRepository(
chainDao: ChainDao,
): ChainNodeRepository = RealChainNodeRepository(chainDao)
@Provides
@ApplicationScope
fun provideNodeConnectionFactory(
socketServiceProvider: Provider<SocketService>,
connectionSecrets: ConnectionSecrets
): NodeConnectionFactory {
return NodeConnectionFactory(
socketServiceProvider,
connectionSecrets
)
}
@Provides
@ApplicationScope
fun provideRemoteToDomainChainMapperFacade(
gson: Gson
): RemoteToDomainChainMapperFacade {
return RemoteToDomainChainMapperFacade(
gson
)
}
@Provides
@ApplicationScope
fun providePreConfiguredChainsRepository(
chainFetcher: ChainFetcher,
chainMapperFacade: RemoteToDomainChainMapperFacade
): PreConfiguredChainsRepository {
return RealPreConfiguredChainsRepository(
chainFetcher,
chainMapperFacade
)
}
@Provides
@ApplicationScope
fun provideChainRepository(
chainRegistry: ChainRegistry,
chainDao: ChainDao,
gson: Gson
): ChainRepository {
return RealChainRepository(
chainRegistry,
chainDao,
gson
)
}
}
@@ -0,0 +1,307 @@
package io.novafoundation.nova.runtime.ethereum
import android.util.Log
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.node.ArrayNode
import com.fasterxml.jackson.databind.node.ObjectNode
import io.novafoundation.nova.common.utils.requireException
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.connection.NodeWithSaturatedUrl
import io.novafoundation.nova.runtime.multiNetwork.connection.UpdatableNodes
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSequenceGenerator
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.generateNodeIterator
import io.novasama.substrate_sdk_android.extensions.tryFindNonNull
import io.reactivex.Flowable
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.web3j.protocol.ObjectMapperFactory
import org.web3j.protocol.Web3jService
import org.web3j.protocol.core.BatchRequest
import org.web3j.protocol.core.BatchResponse
import org.web3j.protocol.core.Request
import org.web3j.protocol.core.Response
import org.web3j.protocol.exceptions.ClientConnectionException
import org.web3j.protocol.http.HttpService
import org.web3j.protocol.websocket.events.Notification
import java.io.IOException
import java.util.concurrent.CompletableFuture
class BalancingHttpWeb3jService(
initialNodes: Chain.Nodes,
private val httpClient: OkHttpClient,
private val strategyProvider: NodeSelectionStrategyProvider,
private val objectMapper: ObjectMapper = ObjectMapperFactory.getObjectMapper(),
) : Web3jService, UpdatableNodes {
private val nodeSwitcher = NodeSwitcher(
initialStrategy = strategyProvider.createHttp(initialNodes),
)
override fun updateNodes(nodes: Chain.Nodes) {
val strategy = strategyProvider.createHttp(nodes)
nodeSwitcher.updateNodes(strategy)
}
override fun <T : Response<*>> send(request: Request<*, out Response<*>>, responseType: Class<T>): T {
val payload: String = objectMapper.writeValueAsString(request)
val result = nodeSwitcher.makeRetryingRequest { url ->
val call = createHttpCall(payload, url)
call.execute().parseSingleResponse(responseType)
}
return result.throwOnRpcError()
}
override fun <T : Response<*>> sendAsync(request: Request<*, out Response<*>>, responseType: Class<T>): CompletableFuture<T> {
val payload: String = objectMapper.writeValueAsString(request)
return enqueueRetryingRequest(
payload = payload,
retriableProcessResponse = { response -> response.parseSingleResponse(responseType) },
nonRetriableProcessResponse = { it.throwOnRpcError() }
)
}
override fun sendBatch(batchRequest: BatchRequest): BatchResponse {
if (batchRequest.requests.isEmpty()) {
return BatchResponse(emptyList(), emptyList())
}
val payload = objectMapper.writeValueAsString(batchRequest.requests)
val result = nodeSwitcher.makeRetryingRequest { url ->
val call = createHttpCall(payload, url)
call.execute().parseBatchResponse(batchRequest)
}
return result.throwOnRpcError()
}
override fun sendBatchAsync(batchRequest: BatchRequest): CompletableFuture<BatchResponse> {
val payload: String = objectMapper.writeValueAsString(batchRequest.requests)
return enqueueRetryingRequest(
payload = payload,
retriableProcessResponse = { response -> response.parseBatchResponse(batchRequest) },
nonRetriableProcessResponse = { it.throwOnRpcError() }
)
}
override fun <T : Notification<*>?> subscribe(
request: Request<*, out Response<*>>,
unsubscribeMethod: String,
responseType: Class<T>
): Flowable<T> {
throw UnsupportedOperationException("Http transport does not support subscriptions")
}
override fun close() {
// nothing to close
}
private fun <T> enqueueRetryingRequest(
payload: String,
retriableProcessResponse: (okhttp3.Response) -> T,
nonRetriableProcessResponse: (T) -> Unit
): CompletableFuture<T> {
val completableFuture = CallCancellableFuture<T>()
enqueueRetryingRequest(completableFuture, payload, retriableProcessResponse, nonRetriableProcessResponse)
return completableFuture
}
private fun <T> enqueueRetryingRequest(
future: CallCancellableFuture<T>,
payload: String,
retriableProcessResponse: (okhttp3.Response) -> T,
nonRetriableProcessResponse: (T) -> Unit
) {
val url = nodeSwitcher.getCurrentNodeUrl() ?: return
val call = createHttpCall(payload, url)
future.call = call
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
if (future.isCancelled) return
nodeSwitcher.markCurrentNodeNotAccessible()
enqueueRetryingRequest(future, payload, retriableProcessResponse, nonRetriableProcessResponse)
}
override fun onResponse(call: Call, response: okhttp3.Response) {
if (future.isCancelled) return
try {
val parsedResponse = retriableProcessResponse(response)
try {
nonRetriableProcessResponse(parsedResponse)
future.complete(parsedResponse)
} catch (e: Throwable) {
future.completeExceptionally(e)
}
} catch (_: Exception) {
nodeSwitcher.markCurrentNodeNotAccessible()
enqueueRetryingRequest(future, payload, retriableProcessResponse, nonRetriableProcessResponse)
}
}
})
}
private fun createHttpCall(request: String, url: String): Call {
val mediaType = HttpService.JSON_MEDIA_TYPE
val requestBody: RequestBody = request.toRequestBody(mediaType)
val httpRequest: okhttp3.Request = okhttp3.Request.Builder()
.url(url)
.post(requestBody)
.build()
return httpClient.newCall(httpRequest)
}
private fun <T : Response<*>> T.throwOnRpcError(): T {
val rpcError = error
if (rpcError != null) {
throw EvmRpcException(rpcError.code, rpcError.message)
}
return this
}
private fun BatchResponse.throwOnRpcError(): BatchResponse {
val rpcError = responses.tryFindNonNull { it.error }
if (rpcError != null) {
throw EvmRpcException(rpcError.code, rpcError.message)
}
return this
}
private fun <T : Response<*>> okhttp3.Response.parseSingleResponse(responseType: Class<T>): T {
val parsedResponse = runCatching {
body?.let {
objectMapper.readValue(it.bytes(), responseType)
}
}.getOrNull()
if (!isSuccessful || parsedResponse == null) {
throw ClientConnectionException("Invalid response received: $code; ${body?.string()}")
}
return parsedResponse
}
private fun okhttp3.Response.parseBatchResponse(origin: BatchRequest): BatchResponse {
val bodyContent = body?.string()
val parsedResponseResult = runCatching {
origin.parseResponse(bodyContent!!)
}
val parsedResponses = parsedResponseResult.getOrNull()
if (isSuccessful && parsedResponseResult.isFailure) {
throw parsedResponseResult.requireException()
}
if (!isSuccessful) {
throw ClientConnectionException("Invalid response received: $code; $bodyContent")
}
return BatchResponse(origin.requests, parsedResponses)
}
private fun BatchRequest.parseResponse(response: String): List<Response<*>> {
val requestsById = requests.associateBy { it.id }
val nodes = objectMapper.readTree(response) as ArrayNode
return nodes.map { node ->
val id = (node as ObjectNode).get("id").asLong()
val request = requestsById.getValue(id)
objectMapper.treeToValue(node, request.responseType)
}
}
}
private class NodeSwitcher(
initialStrategy: NodeSequenceGenerator,
) {
@Volatile
private var balanceStrategy: NodeSequenceGenerator = initialStrategy
@Volatile
private var nodeIterator: Iterator<NodeWithSaturatedUrl>? = null
@Volatile
private var currentNodeUrl: String? = null
init {
updateNodes(initialStrategy)
}
@Synchronized
fun updateNodes(strategy: NodeSequenceGenerator) {
balanceStrategy = strategy
nodeIterator = balanceStrategy.generateNodeIterator()
selectNextNode()
}
@Synchronized
fun getCurrentNodeUrl(): String? {
return currentNodeUrl
}
@Suppress
fun markCurrentNodeNotAccessible() {
selectNextNode()
}
private fun selectNextNode() {
val iterator = nodeIterator ?: return
if (iterator.hasNext()) {
currentNodeUrl = iterator.next().saturatedUrl
}
}
}
private fun <T> NodeSwitcher.makeRetryingRequest(request: (url: String) -> T): T {
val url = getCurrentNodeUrl() ?: error("No url present to make a request")
while (true) {
try {
return request(url)
} catch (e: Throwable) {
Log.w("Failed to execute request for url $url", e)
markCurrentNodeNotAccessible()
continue
}
}
}
private class CallCancellableFuture<T> : CompletableFuture<T>() {
var call: Call? = null
override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
call?.cancel()
return super.cancel(mayInterruptIfRunning)
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.runtime.ethereum
class EvmRpcException(val type: Type, message: String) : Throwable("${type.name}: $message") {
enum class Type(val code: Int?) {
EXECUTION_REVERTED(-32603),
INVALID_INPUT(-32000),
UNKNOWN(null);
companion object {
fun fromCode(code: Int): Type {
return values().firstOrNull { it.code == code } ?: UNKNOWN
}
}
}
}
fun EvmRpcException(code: Int, message: String): EvmRpcException {
return EvmRpcException(EvmRpcException.Type.fromCode(code), message)
}
@@ -0,0 +1,88 @@
package io.novafoundation.nova.runtime.ethereum
import io.reactivex.Observable
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import io.novasama.substrate_sdk_android.wsrpc.request.DeliveryType
import io.novasama.substrate_sdk_android.wsrpc.request.base.RpcRequest
import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse
import io.novasama.substrate_sdk_android.wsrpc.subscription.response.SubscriptionChange
import kotlinx.coroutines.future.await
import org.web3j.protocol.core.Request
import org.web3j.protocol.core.Response
import java.util.concurrent.CompletableFuture
fun SocketService.executeRequestAsFuture(
request: RpcRequest,
deliveryType: DeliveryType = DeliveryType.AT_LEAST_ONCE,
): CompletableFuture<RpcResponse> {
val future = RequestCancellableFuture<RpcResponse>()
val callback = object : SocketService.ResponseListener<RpcResponse> {
override fun onError(throwable: Throwable) {
future.completeExceptionally(throwable)
}
override fun onNext(response: RpcResponse) {
future.complete(response)
}
}
future.cancellable = executeRequest(request, deliveryType, callback)
return future
}
fun SocketService.executeBatchRequestAsFuture(
requests: List<RpcRequest>,
deliveryType: DeliveryType = DeliveryType.AT_LEAST_ONCE,
): CompletableFuture<List<RpcResponse>> {
val future = RequestCancellableFuture<List<RpcResponse>>()
val callback = object : SocketService.ResponseListener<List<RpcResponse>> {
override fun onError(throwable: Throwable) {
future.completeExceptionally(throwable)
}
override fun onNext(response: List<RpcResponse>) {
future.complete(response)
}
}
future.cancellable = executeAccumulatingBatchRequest(requests, deliveryType, callback)
return future
}
fun SocketService.subscribeAsObservable(
request: RpcRequest,
unsubscribeMethod: String
): Observable<SubscriptionChange> {
return Observable.create { emitter ->
val callback = object : SocketService.ResponseListener<SubscriptionChange> {
override fun onError(throwable: Throwable) {
emitter.tryOnError(throwable)
}
override fun onNext(response: SubscriptionChange) {
emitter.onNext(response)
}
}
val cancellable = subscribe(request, callback, unsubscribeMethod)
emitter.setCancellable(cancellable::cancel)
}
}
private class RequestCancellableFuture<T> : CompletableFuture<T>() {
var cancellable: SocketService.Cancellable? = null
override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
cancellable?.cancel()
return super.cancel(mayInterruptIfRunning)
}
}
suspend fun <S, T : Response<*>> Request<S, T>.sendSuspend(): T = sendAsync().await()
@@ -0,0 +1,96 @@
package io.novafoundation.nova.runtime.ethereum
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.invokeOnCompletion
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.core.ethereum.log.Topic
import io.novafoundation.nova.core.model.StorageChange
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
import io.novafoundation.nova.runtime.ethereum.subscribtion.EthereumRequestsAggregator
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApi
import io.novafoundation.nova.runtime.multiNetwork.getSocketOrNull
import io.novafoundation.nova.runtime.multiNetwork.getSubscriptionEthereumApi
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.StorageSubscriptionMultiplexer
import io.novasama.substrate_sdk_android.wsrpc.request.runtime.storage.subscribeUsing
import io.novasama.substrate_sdk_android.wsrpc.subscribe
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import org.web3j.protocol.core.Request
import org.web3j.protocol.core.Response
import org.web3j.protocol.websocket.events.LogNotification
import java.util.concurrent.CompletableFuture
import kotlin.coroutines.CoroutineContext
class StorageSharedRequestsBuilderFactory(
private val chainRegistry: ChainRegistry,
) {
suspend fun create(chainId: ChainId): StorageSharedRequestsBuilder {
val substrateProxy = StorageSubscriptionMultiplexer.Builder()
val ethereumProxy = EthereumRequestsAggregator.Builder()
val rpcSocket = chainRegistry.getSocketOrNull(chainId)
val subscriptionApi = chainRegistry.getSubscriptionEthereumApi(chainId)
val callApi = chainRegistry.getCallEthereumApi(chainId)
return StorageSharedRequestsBuilder(
socketService = rpcSocket,
substrateProxy = substrateProxy,
ethereumProxy = ethereumProxy,
subscriptionApi = subscriptionApi,
callApi = callApi
)
}
}
class StorageSharedRequestsBuilder(
override val socketService: SocketService?,
private val substrateProxy: StorageSubscriptionMultiplexer.Builder,
private val ethereumProxy: EthereumRequestsAggregator.Builder,
override val subscriptionApi: Web3Api?,
override val callApi: Web3Api?,
) : SharedRequestsBuilder {
override fun subscribe(key: String): Flow<StorageChange> {
return substrateProxy.subscribe(key)
.map { StorageChange(it.block, it.key, it.value) }
}
override fun <S, T : Response<*>> ethBatchRequestAsync(batchId: String, request: Request<S, T>): CompletableFuture<T> {
return ethereumProxy.batchRequest(batchId, request)
}
override fun subscribeEthLogs(address: String, topics: List<Topic>): Flow<LogNotification> {
return ethereumProxy.subscribeLogs(address, topics)
}
fun subscribe(coroutineScope: CoroutineScope) {
val ethereumRequestsAggregator = ethereumProxy.build()
subscriptionApi?.let { web3Api ->
ethereumRequestsAggregator.subscribeUsing(web3Api)
.inBackground()
.launchIn(coroutineScope)
}
callApi?.let { web3Api ->
ethereumRequestsAggregator.executeBatches(coroutineScope, web3Api)
}
val cancellable = socketService?.subscribeUsing(substrateProxy.build())
if (cancellable != null) {
coroutineScope.invokeOnCompletion { cancellable.cancel() }
}
}
}
fun StorageSharedRequestsBuilder.subscribe(coroutineContext: CoroutineContext) {
subscribe(CoroutineScope(coroutineContext))
}
@@ -0,0 +1,101 @@
package io.novafoundation.nova.runtime.ethereum
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.core.ethereum.log.Topic
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.connection.UpdatableNodes
import io.novafoundation.nova.runtime.multiNetwork.connection.autobalance.strategy.NodeSelectionStrategyProvider
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.reactive.asFlow
import okhttp3.OkHttpClient
import org.web3j.protocol.Web3j
import org.web3j.protocol.Web3jService
import org.web3j.protocol.core.JsonRpc2_0Web3j
import org.web3j.protocol.core.Request
import org.web3j.protocol.core.methods.response.EthSubscribe
import org.web3j.protocol.websocket.events.LogNotification
import org.web3j.protocol.websocket.events.NewHeadsNotification
import org.web3j.utils.Async
import java.util.concurrent.ScheduledExecutorService
class Web3ApiFactory(
private val requestExecutorService: ScheduledExecutorService = Async.defaultExecutorService(),
private val httpClient: OkHttpClient,
private val strategyProvider: NodeSelectionStrategyProvider,
) {
fun createWss(socketService: SocketService): Web3Api {
val web3jService = WebSocketWeb3jService(socketService)
return RealWeb3Api(
web3jService = web3jService,
delegate = Web3j.build(web3jService, JsonRpc2_0Web3j.DEFAULT_BLOCK_TIME.toLong(), requestExecutorService)
)
}
fun createHttps(chainNode: Chain.Node): Pair<Web3Api, UpdatableNodes> {
val nodes = Chain.Nodes(
autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance,
nodes = listOf(chainNode)
)
return createHttps(nodes)
}
fun createHttps(chainNodes: Chain.Nodes): Pair<Web3Api, UpdatableNodes> {
val service = BalancingHttpWeb3jService(
initialNodes = chainNodes,
httpClient = httpClient,
strategyProvider = strategyProvider,
)
val api = RealWeb3Api(
web3jService = service,
delegate = Web3j.build(service, JsonRpc2_0Web3j.DEFAULT_BLOCK_TIME.toLong(), requestExecutorService)
)
return api to service
}
}
internal class RealWeb3Api(
private val web3jService: Web3jService,
private val delegate: Web3j
) : Web3Api, Web3j by delegate {
override fun newHeadsFlow(): Flow<NewHeadsNotification> = newHeadsNotifications().asFlow()
override fun logsNotifications(addresses: List<String>, topics: List<Topic>): Flow<LogNotification> {
val logParams = createLogParams(addresses, topics)
val requestParams = listOf("logs", logParams)
val request = Request("eth_subscribe", requestParams, web3jService, EthSubscribe::class.java)
return web3jService.subscribe(request, "eth_unsubscribe", LogNotification::class.java)
.asFlow()
}
private fun createLogParams(addresses: List<String>, topics: List<Topic>): Map<String, Any> {
return buildMap {
if (addresses.isNotEmpty()) {
put("address", addresses)
}
if (topics.isNotEmpty()) {
put("topics", topics.unifyTopics())
}
}
}
private fun List<Topic>.unifyTopics(): List<Any?> {
return map { topic ->
when (topic) {
Topic.Any -> null
is Topic.AnyOf -> topic.values.map { it.requireHexPrefix() }
is Topic.Single -> topic.value.requireHexPrefix()
}
}
}
}
@@ -0,0 +1,106 @@
package io.novafoundation.nova.runtime.ethereum
import com.fasterxml.jackson.databind.ObjectMapper
import io.reactivex.BackpressureStrategy
import io.reactivex.Flowable
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import io.novasama.substrate_sdk_android.wsrpc.request.base.RpcRequest
import io.novasama.substrate_sdk_android.wsrpc.response.RpcResponse
import org.web3j.protocol.ObjectMapperFactory
import org.web3j.protocol.Web3jService
import org.web3j.protocol.core.BatchRequest
import org.web3j.protocol.core.BatchResponse
import org.web3j.protocol.core.Request
import org.web3j.protocol.core.Response
import org.web3j.protocol.websocket.WebSocketService
import org.web3j.protocol.websocket.events.Notification
import java.io.IOException
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ExecutionException
class WebSocketWeb3jService(
private val socketService: SocketService,
private val jsonMapper: ObjectMapper = ObjectMapperFactory.getObjectMapper()
) : Web3jService {
/**
* Implementation based on [WebSocketService.send]
*/
override fun <T : Response<*>?> send(request: Request<*, out Response<*>>, responseType: Class<T>): T {
return try {
sendAsync(request, responseType).get()
} catch (e: InterruptedException) {
Thread.interrupted()
throw IOException("Interrupted WebSocket request", e)
} catch (e: ExecutionException) {
if (e.cause is IOException) {
throw e.cause as IOException
}
throw RuntimeException("Unexpected exception", e.cause)
}
}
override fun <T : Response<*>?> sendAsync(request: Request<*, out Response<*>>, responseType: Class<T>): CompletableFuture<T> {
val rpcRequest = request.toRpcRequest()
return socketService.executeRequestAsFuture(rpcRequest).thenApply {
if (it.error != null) {
throw EvmRpcException(it.error!!.code, it.error!!.message)
}
jsonMapper.convertValue(it, responseType)
}
}
override fun <T : Notification<*>?> subscribe(
request: Request<*, out Response<*>>,
unsubscribeMethod: String,
responseType: Class<T>
): Flowable<T> {
val rpcRequest = request.toRpcRequest()
return socketService.subscribeAsObservable(rpcRequest, unsubscribeMethod).map {
jsonMapper.convertValue(it, responseType)
}.toFlowable(BackpressureStrategy.LATEST)
}
override fun sendBatch(batchRequest: BatchRequest): BatchResponse {
return try {
sendBatchAsync(batchRequest).get()
} catch (e: InterruptedException) {
Thread.interrupted()
throw IOException("Interrupted WebSocket batch request", e)
} catch (e: ExecutionException) {
if (e.cause is IOException) {
throw e.cause as IOException
}
throw RuntimeException("Unexpected exception", e.cause)
}
}
override fun sendBatchAsync(batchRequest: BatchRequest): CompletableFuture<BatchResponse> {
val rpcRequests = batchRequest.requests.map { it.toRpcRequest() }
return socketService.executeBatchRequestAsFuture(rpcRequests).thenApply { responses ->
val responsesById = responses.associateBy(RpcResponse::id)
val parsedResponses = batchRequest.requests.mapNotNull { request ->
responsesById[request.id.toInt()]?.let { rpcResponse ->
jsonMapper.convertValue(rpcResponse, request.responseType)
}
}
BatchResponse(batchRequest.requests, parsedResponses)
}
}
override fun close() {
// other components handle lifecycle of socketService
}
private fun Request<*, *>.toRpcRequest(): RpcRequest {
val raw = jsonMapper.writeValueAsString(this)
return RpcRequest.Raw(raw, id.toInt())
}
}
@@ -0,0 +1,69 @@
package io.novafoundation.nova.runtime.ethereum.contract.base
import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ContractCaller
import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ethCallSuspend
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.future.asDeferred
import org.web3j.abi.FunctionEncoder
import org.web3j.abi.FunctionReturnDecoder
import org.web3j.abi.datatypes.Function
import org.web3j.abi.datatypes.Type
import org.web3j.protocol.core.DefaultBlockParameter
import org.web3j.protocol.core.methods.request.Transaction
import org.web3j.protocol.core.methods.response.EthCall
import org.web3j.tx.TransactionManager
import org.web3j.tx.exceptions.ContractCallException
open class CallableContract(
protected val contractAddress: String,
protected val contractCaller: ContractCaller,
protected val defaultBlockParameter: DefaultBlockParameter,
) {
@Suppress("UNCHECKED_CAST")
protected fun <T : Type<*>?, R> executeCallSingleValueReturnAsync(
function: Function,
extractResult: (T) -> R,
): Deferred<R> {
val tx = createTx(function)
return contractCaller.ethCall(tx, defaultBlockParameter).thenApply { ethCall ->
processEthCallResponse(ethCall, function, extractResult)
}.asDeferred()
}
@Suppress("UNCHECKED_CAST")
protected suspend fun <T : Type<*>?, R> executeCallSingleValueReturnSuspend(
function: Function,
extractResult: (T) -> R,
): R {
val tx = createTx(function)
val ethCall = contractCaller.ethCallSuspend(tx, defaultBlockParameter)
return processEthCallResponse(ethCall, function, extractResult)
}
private fun <R, T : Type<*>?> processEthCallResponse(
ethCall: EthCall,
function: Function,
extractResult: (T) -> R
): R {
assertCallNotReverted(ethCall)
val args = FunctionReturnDecoder.decode(ethCall.value, function.outputParameters)
val type = args.first() as T
return extractResult(type)
}
private fun createTx(function: Function): Transaction {
val encodedFunction = FunctionEncoder.encode(function)
return Transaction.createEthCallTransaction(null, contractAddress, encodedFunction)
}
private fun assertCallNotReverted(ethCall: EthCall) {
if (ethCall.isReverted) {
throw ContractCallException(String.format(TransactionManager.REVERT_ERR_STR, ethCall.revertReason))
}
}
}
@@ -0,0 +1,36 @@
package io.novafoundation.nova.runtime.ethereum.contract.base
import io.novafoundation.nova.core.updater.EthereumSharedRequestsBuilder
import io.novafoundation.nova.runtime.ethereum.contract.base.caller.BatchContractCaller
import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ContractCaller
import io.novafoundation.nova.runtime.ethereum.contract.base.caller.SingleContractCaller
import io.novafoundation.nova.runtime.ethereum.subscribtion.BatchId
import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder
import org.web3j.protocol.Web3j
import org.web3j.protocol.core.DefaultBlockParameter
import org.web3j.protocol.core.DefaultBlockParameterName
interface ContractStandard<Q, T> {
fun query(
address: String,
caller: ContractCaller,
defaultBlockParameter: DefaultBlockParameter = DefaultBlockParameterName.LATEST
): Q
fun transact(
contractAddress: String,
transactionBuilder: EvmTransactionBuilder
): T
}
fun <C> ContractStandard<C, *>.queryBatched(
address: String,
batchId: BatchId,
ethereumSharedRequestsBuilder: EthereumSharedRequestsBuilder,
): C = query(address, BatchContractCaller(batchId, ethereumSharedRequestsBuilder))
fun <C> ContractStandard<C, *>.querySingle(
address: String,
web3j: Web3j,
): C = query(address, SingleContractCaller(web3j))
@@ -0,0 +1,21 @@
package io.novafoundation.nova.runtime.ethereum.contract.base.caller
import io.novafoundation.nova.core.updater.EthereumSharedRequestsBuilder
import io.novafoundation.nova.core.updater.callApiOrThrow
import io.novafoundation.nova.runtime.ethereum.subscribtion.BatchId
import org.web3j.protocol.core.DefaultBlockParameter
import org.web3j.protocol.core.methods.request.Transaction
import org.web3j.protocol.core.methods.response.EthCall
import java.util.concurrent.CompletableFuture
class BatchContractCaller(
private val batchId: BatchId,
private val ethereumSharedRequestsBuilder: EthereumSharedRequestsBuilder,
) : ContractCaller {
override fun ethCall(transaction: Transaction, defaultBlockParameter: DefaultBlockParameter): CompletableFuture<EthCall> {
val request = ethereumSharedRequestsBuilder.callApiOrThrow.ethCall(transaction, defaultBlockParameter)
return ethereumSharedRequestsBuilder.ethBatchRequestAsync(batchId, request)
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.runtime.ethereum.contract.base.caller
import kotlinx.coroutines.future.asDeferred
import org.web3j.protocol.core.DefaultBlockParameter
import org.web3j.protocol.core.methods.request.Transaction
import org.web3j.protocol.core.methods.response.EthCall
import java.util.concurrent.CompletableFuture
interface ContractCaller {
fun ethCall(
transaction: Transaction,
defaultBlockParameter: DefaultBlockParameter,
): CompletableFuture<EthCall>
}
suspend fun ContractCaller.ethCallSuspend(
transaction: Transaction,
defaultBlockParameter: DefaultBlockParameter,
): EthCall = ethCall(transaction, defaultBlockParameter).asDeferred().await()
@@ -0,0 +1,18 @@
package io.novafoundation.nova.runtime.ethereum.contract.base.caller
import org.web3j.protocol.Web3j
import org.web3j.protocol.core.DefaultBlockParameter
import org.web3j.protocol.core.methods.request.Transaction
import org.web3j.protocol.core.methods.response.EthCall
import java.util.concurrent.CompletableFuture
class SingleContractCaller(
private val web3j: Web3j
) : ContractCaller {
override fun ethCall(transaction: Transaction, defaultBlockParameter: DefaultBlockParameter): CompletableFuture<EthCall> {
val request = web3j.ethCall(transaction, defaultBlockParameter)
return request.sendAsync()
}
}
@@ -0,0 +1,94 @@
package io.novafoundation.nova.runtime.ethereum.contract.erc20
import io.novafoundation.nova.common.utils.ethereumAccountIdToAddress
import io.novafoundation.nova.runtime.ethereum.contract.base.CallableContract
import io.novafoundation.nova.runtime.ethereum.contract.base.ContractStandard
import io.novafoundation.nova.runtime.ethereum.contract.base.caller.ContractCaller
import io.novafoundation.nova.runtime.ethereum.transaction.builder.EvmTransactionBuilder
import io.novasama.substrate_sdk_android.runtime.AccountId
import kotlinx.coroutines.Deferred
import org.web3j.abi.TypeReference
import org.web3j.abi.datatypes.Address
import org.web3j.abi.datatypes.Function
import org.web3j.abi.datatypes.Utf8String
import org.web3j.abi.datatypes.generated.Uint256
import org.web3j.abi.datatypes.generated.Uint8
import org.web3j.protocol.core.DefaultBlockParameter
import java.math.BigInteger
class Erc20Standard : ContractStandard<Erc20Queries, Erc20Transactions> {
override fun query(address: String, caller: ContractCaller, defaultBlockParameter: DefaultBlockParameter): Erc20Queries {
return Erc20QueriesImpl(address, caller, defaultBlockParameter)
}
override fun transact(contractAddress: String, transactionBuilder: EvmTransactionBuilder): Erc20Transactions {
return Erc20TransactionsImpl(contractAddress, transactionBuilder)
}
}
private class Erc20TransactionsImpl(
private val contractAddress: String,
private val evmTransactionsBuilder: EvmTransactionBuilder,
) : Erc20Transactions {
override fun transfer(recipient: AccountId, amount: BigInteger) {
evmTransactionsBuilder.contractCall(contractAddress) {
function = "transfer"
inputParameter(Address(recipient.ethereumAccountIdToAddress(withChecksum = true)))
inputParameter(Uint256(amount))
}
}
}
private class Erc20QueriesImpl(
contractAddress: String,
caller: ContractCaller,
blockParameter: DefaultBlockParameter,
) : CallableContract(contractAddress, caller, blockParameter), Erc20Queries {
override suspend fun balanceOfAsync(account: String): Deferred<BigInteger> {
val function = Function(
/* name = */
"balanceOf",
/* inputParameters = */
listOf(
Address(account)
),
/* outputParameters = */
listOf(
object : TypeReference<Uint256>() {}
),
)
return executeCallSingleValueReturnAsync(function, Uint256::getValue)
}
override suspend fun symbol(): String {
val outputParams = listOf(
object : TypeReference<Utf8String>() {}
)
val function = Function("symbol", emptyList(), outputParams)
return executeCallSingleValueReturnSuspend(function, Utf8String::getValue)
}
override suspend fun decimals(): BigInteger {
val outputParams = listOf(
object : TypeReference<Uint8>() {}
)
val function = Function("decimals", emptyList(), outputParams)
return executeCallSingleValueReturnSuspend(function, Uint8::getValue)
}
override suspend fun totalSupply(): BigInteger {
val outputParams = listOf(
object : TypeReference<Uint256>() {}
)
val function = Function("totalSupply", emptyList(), outputParams)
return executeCallSingleValueReturnSuspend(function, Uint256::getValue)
}
}
@@ -0,0 +1,59 @@
package io.novafoundation.nova.runtime.ethereum.contract.erc20
import kotlinx.coroutines.Deferred
import org.web3j.abi.EventEncoder
import org.web3j.abi.TypeDecoder
import org.web3j.abi.TypeReference
import org.web3j.abi.datatypes.Address
import org.web3j.abi.datatypes.Event
import org.web3j.abi.datatypes.generated.Uint256
import org.web3j.protocol.websocket.events.Log
import java.math.BigInteger
interface Erc20Queries {
class Transfer(val from: Address, val to: Address, val amount: Uint256)
companion object {
val TRANSFER_EVENT = Event(
"Transfer",
listOf(
object : TypeReference<Address>(true) {},
object : TypeReference<Address>(true) {},
object : TypeReference<Uint256>(false) {}
)
)
fun transferEventSignature(): String {
return EventEncoder.encode(TRANSFER_EVENT)
}
fun parseTransferEvent(log: Log): Transfer {
return parseTransferEvent(
topic1 = log.topics[1],
topic2 = log.topics[2],
data = log.data
)
}
fun parseTransferEvent(
topic1: String,
topic2: String,
data: String
): Transfer {
return Transfer(
from = TypeDecoder.decodeAddress(topic1),
to = TypeDecoder.decodeAddress(topic2),
amount = TypeDecoder.decodeNumeric(data, Uint256::class.java)
)
}
}
suspend fun balanceOfAsync(account: String): Deferred<BigInteger>
suspend fun symbol(): String
suspend fun decimals(): BigInteger
suspend fun totalSupply(): BigInteger
}
@@ -0,0 +1,9 @@
package io.novafoundation.nova.runtime.ethereum.contract.erc20
import io.novasama.substrate_sdk_android.runtime.AccountId
import java.math.BigInteger
interface Erc20Transactions {
fun transfer(recipient: AccountId, amount: BigInteger)
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.runtime.ethereum.gas
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.common.utils.tryFindNonNull
import java.math.BigInteger
class CompoundGasPriceProvider(vararg val delegates: GasPriceProvider) : GasPriceProvider {
override suspend fun getGasPrice(): BigInteger {
return delegates.tryFindNonNull { delegate ->
runCatching { delegate.getGasPrice() }.getOrNull()
}.orZero()
}
}
@@ -0,0 +1,8 @@
package io.novafoundation.nova.runtime.ethereum.gas
import java.math.BigInteger
interface GasPriceProvider {
suspend fun getGasPrice(): BigInteger
}
@@ -0,0 +1,37 @@
package io.novafoundation.nova.runtime.ethereum.gas
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.getCallEthereumApiOrThrow
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import org.web3j.protocol.Web3j
interface GasPriceProviderFactory {
/**
* Creates gas provider for a [chainId] that is known to the app
*/
suspend fun createKnown(chainId: ChainId): GasPriceProvider
/**
* Creates gas provider for arbitrary EVM chain given instance of [Web3j]
*/
suspend fun create(web3j: Web3j): GasPriceProvider
}
class RealGasPriceProviderFactory(
private val chainRegistry: ChainRegistry
) : GasPriceProviderFactory {
override suspend fun createKnown(chainId: ChainId): GasPriceProvider {
val api = chainRegistry.getCallEthereumApiOrThrow(chainId)
return create(api)
}
override suspend fun create(web3j: Web3j): GasPriceProvider {
return CompoundGasPriceProvider(
MaxPriorityFeeGasProvider(web3j),
LegacyGasPriceProvider(web3j)
)
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.runtime.ethereum.gas
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import org.web3j.protocol.Web3j
import java.math.BigInteger
class LegacyGasPriceProvider(private val api: Web3j) : GasPriceProvider {
override suspend fun getGasPrice(): BigInteger {
return api.ethGasPrice().sendSuspend().gasPrice
}
}
@@ -0,0 +1,22 @@
package io.novafoundation.nova.runtime.ethereum.gas
import io.novafoundation.nova.runtime.ethereum.sendSuspend
import org.web3j.protocol.Web3j
import org.web3j.protocol.core.DefaultBlockParameterName
import java.math.BigInteger
class MaxPriorityFeeGasProvider(private val api: Web3j) : GasPriceProvider {
override suspend fun getGasPrice(): BigInteger {
val baseFeePerGas = api.getLatestBaseFeePerGas()
val maxPriorityFee = api.ethMaxPriorityFeePerGas().sendSuspend().maxPriorityFeePerGas
return baseFeePerGas + maxPriorityFee
}
private suspend fun Web3j.getLatestBaseFeePerGas(): BigInteger {
val block = ethGetBlockByNumber(DefaultBlockParameterName.LATEST, false).sendSuspend()
return block.block.baseFeePerGas
}
}
@@ -0,0 +1,13 @@
package io.novafoundation.nova.runtime.ethereum.log
sealed class Topic {
object Any : Topic()
class Single(val value: String) : Topic()
class AnyOf(val values: List<String>) : Topic() {
constructor(vararg values: String) : this(values.toList())
}
}
@@ -0,0 +1,244 @@
package io.novafoundation.nova.runtime.ethereum.subscribtion
import io.novafoundation.nova.common.data.network.runtime.binding.cast
import io.novafoundation.nova.common.utils.mergeIfMultiple
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.core.ethereum.log.Topic
import io.novasama.substrate_sdk_android.extensions.asEthereumAddress
import io.novasama.substrate_sdk_android.extensions.toAccountId
import io.novasama.substrate_sdk_android.wsrpc.SocketService.ResponseListener
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.future.asDeferred
import org.web3j.protocol.core.BatchRequest
import org.web3j.protocol.core.BatchResponse
import org.web3j.protocol.core.Request
import org.web3j.protocol.core.Response
import org.web3j.protocol.websocket.events.LogNotification
import java.util.UUID
import java.util.concurrent.CompletableFuture
typealias SubscriptionId = String
typealias BatchId = String
typealias RequestId = Int
sealed class EthereumSubscription<S>(val id: SubscriptionId) {
class Log(val addresses: List<String>, val topics: List<Topic>, id: SubscriptionId) : EthereumSubscription<LogNotification>(id)
}
class EthereumRequestsAggregator private constructor(
private val subscriptions: List<EthereumSubscription<*>>,
private val collectors: Map<SubscriptionId, List<ResponseListener<*>>>,
private val batches: List<PendingBatchRequest>
) {
fun subscribeUsing(web3Api: Web3Api): Flow<*> {
return subscriptions.map {
when (it) {
is EthereumSubscription.Log -> web3Api.subscribeLogs(it)
}
}.mergeIfMultiple()
}
fun executeBatches(scope: CoroutineScope, web3Api: Web3Api) {
batches.forEach { pendingBatchRequest ->
scope.async {
val batch = web3Api.newBatch().apply {
pendingBatchRequest.requests.forEach {
add(it)
}
}
executeBatch(batch, pendingBatchRequest)
}
}
}
private fun Web3Api.subscribeLogs(subscription: EthereumSubscription.Log): Flow<*> {
return logsNotifications(subscription.addresses, subscription.topics).onEach { logNotification ->
subscription.dispatchChange(logNotification)
}.catch {
subscription.dispatchError(it)
}
}
private suspend fun executeBatch(
batch: BatchRequest,
pendingBatchRequest: PendingBatchRequest
): Result<BatchResponse> {
return runCatching { batch.sendAsync().asDeferred().await() }
.onSuccess { batchResponse ->
batchResponse.responses.onEach { response ->
val callback = pendingBatchRequest.callbacks[response.id.toInt()] ?: return@onEach
callback.cast<BatchCallback<Any?>>().onNext(response)
}
}
.onFailure { error ->
pendingBatchRequest.callbacks.values.forEach {
it.onError(error)
}
}
}
private inline fun <reified S> EthereumSubscription<S>.dispatchChange(change: S) {
val collectors = collectors[id].orEmpty()
collectors.forEach {
it.cast<ResponseListener<S>>().onNext(change)
}
}
private fun EthereumSubscription<*>.dispatchError(error: Throwable) {
val collectors = collectors[id].orEmpty()
collectors.forEach { it.onError(error) }
}
class Builder {
// We do not initialize them by default to not to allocate arrays and maps when not needed
private var subscriptions: MutableList<EthereumSubscriptionBuilder<*>>? = null
private var collectors: MutableMap<SubscriptionId, MutableList<ResponseListener<*>>>? = null
private var batches: MutableMap<BatchId, PendingBatchRequestBuilder>? = null
fun subscribeLogs(address: String, topics: List<Topic>): Flow<LogNotification> {
val subscriptions = ensureSubscriptions()
val existingSubscription = subscriptions.firstOrNull { it is EthereumSubscriptionBuilder.Log && it.topics == topics }
val subscription = if (existingSubscription != null) {
require(existingSubscription is EthereumSubscriptionBuilder.Log)
existingSubscription.addresses.add(address)
existingSubscription
} else {
val newSubscription = EthereumSubscriptionBuilder.Log(topics = topics, addresses = mutableListOf(address))
subscriptions.add(newSubscription)
newSubscription
}
val collector = LogsCallback(address)
val subscriptionCollectors = ensureCollectors().getOrPut(subscription.id, ::mutableListOf)
subscriptionCollectors.add(collector)
return collector.inner.map { it.getOrThrow() }
}
fun <S, T : Response<*>> batchRequest(batchId: BatchId, request: Request<S, T>): CompletableFuture<T> {
val batches = ensureBatches()
val batch = batches.getOrPut(batchId, ::PendingBatchRequestBuilder)
val callback = BatchCallback<T>()
batch.requests += request
batch.callbacks[request.id.toInt()] = callback
return callback.future
}
fun build(): EthereumRequestsAggregator {
return EthereumRequestsAggregator(
subscriptions = subscriptions.orEmpty().map { it.build() },
collectors = collectors.orEmpty(),
batches = batches.orEmpty().values.map { it.build() }
)
}
private fun ensureSubscriptions(): MutableList<EthereumSubscriptionBuilder<*>> {
if (subscriptions == null) {
subscriptions = mutableListOf()
}
return subscriptions!!
}
private fun ensureCollectors(): MutableMap<SubscriptionId, MutableList<ResponseListener<*>>> {
if (collectors == null) {
collectors = mutableMapOf()
}
return collectors!!
}
private fun ensureBatches(): MutableMap<BatchId, PendingBatchRequestBuilder> {
if (batches == null) {
batches = mutableMapOf()
}
return batches!!
}
}
}
private sealed class EthereumSubscriptionBuilder<S> {
val id: SubscriptionId = UUID.randomUUID().toString()
abstract fun build(): EthereumSubscription<S>
class Log(val topics: List<Topic>, val addresses: MutableList<String> = mutableListOf()) : EthereumSubscriptionBuilder<LogNotification>() {
override fun build(): EthereumSubscription<LogNotification> {
return EthereumSubscription.Log(addresses, topics, id)
}
}
}
private class LogsCallback(contractAddress: String) : SubscribeCallback<LogNotification>() {
val contractAccountId = contractAddress.asEthereumAddress().toAccountId().value
override fun shouldHandle(change: LogNotification): Boolean {
val changeAddress = change.params.result.address
val changeAccountId = changeAddress.asEthereumAddress().toAccountId().value
return contractAccountId.contentEquals(changeAccountId)
}
}
private class PendingBatchRequest(val requests: List<Request<*, *>>, val callbacks: Map<RequestId, BatchCallback<*>>)
private class PendingBatchRequestBuilder(
val requests: MutableList<Request<*, *>> = mutableListOf(),
val callbacks: MutableMap<RequestId, BatchCallback<*>> = mutableMapOf()
) {
fun build(): PendingBatchRequest = PendingBatchRequest(requests, callbacks)
}
private class BatchCallback<R> : ResponseListener<R> {
val future = CompletableFuture<R>()
override fun onError(throwable: Throwable) {
future.completeExceptionally(throwable)
}
override fun onNext(response: R) {
future.complete(response)
}
}
private abstract class SubscribeCallback<R> : ResponseListener<R> {
val inner = MutableSharedFlow<Result<R>>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
abstract fun shouldHandle(change: R): Boolean
override fun onError(throwable: Throwable) {
inner.tryEmit(Result.failure(throwable))
}
override fun onNext(response: R) {
if (shouldHandle(response)) {
inner.tryEmit(Result.success(response))
}
}
}
@@ -0,0 +1,41 @@
package io.novafoundation.nova.runtime.ethereum.transaction.builder
import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf
import io.novafoundation.nova.runtime.ethereum.contract.base.ContractStandard
import io.novasama.substrate_sdk_android.runtime.AccountId
import org.web3j.abi.TypeReference
import org.web3j.abi.datatypes.Type as EvmType
interface EvmTransactionBuilder {
fun nativeTransfer(amount: BalanceOf, recipient: AccountId)
fun contractCall(contractAddress: String, builder: EvmContractCallBuilder.() -> Unit)
interface EvmContractCallBuilder {
var function: String
fun <T> inputParameter(value: EvmType<T>)
fun <T : EvmType<*>> outputParameter(typeReference: TypeReference<T>)
}
}
fun EvmTransactionBuilder() = RealEvmTransactionBuilder()
inline fun <reified T : EvmType<*>> EvmTransactionBuilder.EvmContractCallBuilder.outputParameter() {
val typeReference = object : TypeReference<T>() {}
outputParameter(typeReference)
}
fun <T> EvmTransactionBuilder.contractCall(
contractAddress: String,
contractStandard: ContractStandard<*, T>,
contractStandardAction: T.() -> Unit
) {
val contractTransactions = contractStandard.transact(contractAddress, this)
contractTransactions.contractStandardAction()
}
@@ -0,0 +1,124 @@
package io.novafoundation.nova.runtime.ethereum.transaction.builder
import io.novafoundation.nova.common.data.network.runtime.binding.BalanceOf
import io.novafoundation.nova.common.utils.ethereumAccountIdToAddress
import io.novasama.substrate_sdk_android.runtime.AccountId
import org.web3j.abi.FunctionEncoder
import org.web3j.abi.TypeReference
import org.web3j.abi.datatypes.Type
import org.web3j.crypto.RawTransaction
import org.web3j.protocol.core.methods.request.Transaction
import java.math.BigInteger
import org.web3j.abi.datatypes.Function as EvmFunction
class RealEvmTransactionBuilder : EvmTransactionBuilder {
private var transactionData: EvmTransactionData? = null
override fun nativeTransfer(amount: BalanceOf, recipient: AccountId) {
transactionData = EvmTransactionData.NativeTransfer(amount, recipient.ethereumAccountIdToAddress())
}
override fun contractCall(contractAddress: String, builder: EvmTransactionBuilder.EvmContractCallBuilder.() -> Unit) {
transactionData = EvmContractCallBuilder(contractAddress)
.apply(builder)
.build()
}
fun buildForFee(originAddress: String): Transaction {
return when (val txData = requireNotNull(transactionData)) {
is EvmTransactionData.ContractCall -> {
val data = FunctionEncoder.encode(txData.function)
Transaction.createFunctionCallTransaction(
originAddress,
null,
null,
null,
txData.contractAddress,
null,
data
)
}
is EvmTransactionData.NativeTransfer -> {
Transaction.createEtherTransaction(
originAddress,
null,
null,
null,
txData.recipientAddress,
txData.amount
)
}
}
}
fun buildForSign(
nonce: BigInteger,
gasPrice: BigInteger,
gasLimit: BigInteger
): RawTransaction {
return when (val txData = requireNotNull(transactionData)) {
is EvmTransactionData.ContractCall -> {
val data = FunctionEncoder.encode(txData.function)
RawTransaction.createTransaction(
nonce,
gasPrice,
gasLimit,
txData.contractAddress,
null,
data
)
}
is EvmTransactionData.NativeTransfer -> {
RawTransaction.createEtherTransaction(
nonce,
gasPrice,
gasLimit,
txData.recipientAddress,
txData.amount
)
}
}
}
}
private class EvmContractCallBuilder(
private val contractAddress: String
) : EvmTransactionBuilder.EvmContractCallBuilder {
private var _function: String? = null
private var input: MutableList<Type<*>> = mutableListOf()
private var outputParameters: MutableList<TypeReference<*>> = mutableListOf()
override var function: String
get() = requireNotNull(_function)
set(value) {
_function = value
}
override fun <T> inputParameter(value: Type<T>) {
input += value
}
override fun <T : Type<*>> outputParameter(typeReference: TypeReference<T>) {
outputParameters += typeReference
}
fun build(): EvmTransactionData.ContractCall {
return EvmTransactionData.ContractCall(
contractAddress = contractAddress,
function = EvmFunction(function, input, outputParameters)
)
}
}
private sealed class EvmTransactionData {
class NativeTransfer(val amount: BalanceOf, val recipientAddress: String) : EvmTransactionData()
class ContractCall(val contractAddress: String, val function: EvmFunction) : EvmTransactionData()
}
@@ -0,0 +1,77 @@
package io.novafoundation.nova.runtime.explorer
import io.novafoundation.nova.common.utils.Urls
class BlockExplorerLinks(
val account: String?,
val event: String?,
val extrinsic: String?
)
interface BlockExplorerLinkFormatter {
fun format(link: String): BlockExplorerLinks?
}
class CommonBlockExplorerLinkFormatter(
private val formatters: List<BlockExplorerLinkFormatter>
) : BlockExplorerLinkFormatter {
override fun format(link: String): BlockExplorerLinks? {
return formatters.firstNotNullOfOrNull { it.format(link) }
}
}
class SubscanBlockExplorerLinkFormatter() : BlockExplorerLinkFormatter {
override fun format(link: String): BlockExplorerLinks? {
return try {
require(link.contains("subscan.io"))
val normalizedUrl = Urls.normalizeUrl(link)
return BlockExplorerLinks(
account = "$normalizedUrl/account/{address}",
event = null,
extrinsic = "$normalizedUrl/account/{hash}",
)
} catch (e: Exception) {
null
}
}
}
class StatescanBlockExplorerLinkFormatter() : BlockExplorerLinkFormatter {
override fun format(link: String): BlockExplorerLinks? {
return try {
require(link.contains("statescan.io"))
val normalizedUrl = Urls.normalizeUrl(link)
return BlockExplorerLinks(
account = "$normalizedUrl/#/accounts/{address}",
event = "$normalizedUrl/#/events/{event}",
extrinsic = "$normalizedUrl/#/extrinsics/{hash}",
)
} catch (e: Exception) {
null
}
}
}
class EtherscanBlockExplorerLinkFormatter() : BlockExplorerLinkFormatter {
override fun format(link: String): BlockExplorerLinks? {
return try {
require(link.contains("etherscan.io"))
val normalizedUrl = Urls.normalizeUrl(link)
return BlockExplorerLinks(
account = "$normalizedUrl/address/{address}",
event = null,
extrinsic = "$normalizedUrl/tx/{hash}",
)
} catch (e: Exception) {
null
}
}
}
@@ -0,0 +1,660 @@
package io.novafoundation.nova.runtime.ext
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.format.AddressScheme
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.MultiAddress
import io.novafoundation.nova.common.data.network.runtime.binding.bindOrNull
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.TokenSymbol
import io.novafoundation.nova.common.utils.Urls
import io.novafoundation.nova.common.utils.asTokenSymbol
import io.novafoundation.nova.common.utils.emptyEthereumAccountId
import io.novafoundation.nova.common.utils.emptySubstrateAccountId
import io.novafoundation.nova.common.utils.findIsInstanceOrNull
import io.novafoundation.nova.common.utils.formatNamed
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.common.utils.substrateAccountId
import io.novafoundation.nova.core_db.model.AssetAndChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.ALEPH_ZERO
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.MYTHOS
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.NOMINATION_POOLS
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.PARACHAIN
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.RELAYCHAIN_AURA
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.TURING
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.StakingType.UNSUPPORTED
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Asset.Type
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ExplorerTemplateExtractor
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.NetworkType
import io.novafoundation.nova.runtime.multiNetwork.chain.model.StatemineAssetId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.TypesUsage
import io.novafoundation.nova.runtime.multiNetwork.chain.model.hasSameId
import io.novasama.substrate_sdk_android.encrypt.SignatureVerifier
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.encrypt.Signer
import io.novasama.substrate_sdk_android.extensions.asEthereumAccountId
import io.novasama.substrate_sdk_android.extensions.asEthereumAddress
import io.novasama.substrate_sdk_android.extensions.asEthereumPublicKey
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.extensions.isValid
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
import io.novasama.substrate_sdk_android.extensions.toAccountId
import io.novasama.substrate_sdk_android.extensions.toAddress
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull
import io.novasama.substrate_sdk_android.runtime.definitions.types.toHexUntyped
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.addressPrefix
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress
import java.math.BigInteger
const val EVM_DEFAULT_TOKEN_DECIMALS = 18
private const val EIP_155_PREFIX = "eip155"
val Chain.autoBalanceEnabled: Boolean
get() = nodes.wssNodeSelectionStrategy is Chain.Nodes.NodeSelectionStrategy.AutoBalance
val Chain.selectedUnformattedWssNodeUrlOrNull: String?
get() = if (nodes.wssNodeSelectionStrategy is Chain.Nodes.NodeSelectionStrategy.SelectedNode) {
nodes.wssNodeSelectionStrategy.unformattedNodeUrl
} else {
null
}
val Chain.isCustomNetwork: Boolean
get() = source == Chain.Source.CUSTOM
val Chain.typesUsage: TypesUsage
get() = when {
types == null -> TypesUsage.NONE
!types.overridesCommon && types.url != null -> TypesUsage.BOTH
!types.overridesCommon && types.url == null -> TypesUsage.BASE
else -> TypesUsage.OWN
}
val TypesUsage.requiresBaseTypes: Boolean
get() = this == TypesUsage.BASE || this == TypesUsage.BOTH
val Chain.utilityAsset
get() = assets.first(Chain.Asset::isUtilityAsset)
val Chain.isSubstrateBased
get() = !isEthereumBased
val Chain.commissionAsset
get() = utilityAsset
val Chain.isEnabled
get() = connectionState != Chain.ConnectionState.DISABLED
val Chain.isDisabled
get() = !isEnabled
fun Chain.getAssetOrThrow(assetId: ChainAssetId): Chain.Asset {
return assetsById.getValue(assetId)
}
fun Chain.Asset.supportedStakingOptions(): List<Chain.Asset.StakingType> {
if (staking.isEmpty()) return emptyList()
return staking.filter { it != UNSUPPORTED }
}
fun Chain.networkType(): NetworkType {
return if (hasSubstrateRuntime) {
NetworkType.SUBSTRATE
} else {
NetworkType.EVM
}
}
fun Chain.evmChainIdOrNull(): BigInteger? {
return if (id.startsWith(EIP_155_PREFIX)) {
id.removePrefix("$EIP_155_PREFIX:")
.toBigIntegerOrNull()
} else {
null
}
}
fun Chain.isSwapSupported(): Boolean = swap.isNotEmpty()
fun List<Chain.Swap>.assetConversionSupported(): Boolean {
return Chain.Swap.ASSET_CONVERSION in this
}
fun List<Chain.Swap>.hydraDxSupported(): Boolean {
return Chain.Swap.HYDRA_DX in this
}
val Chain.ConnectionState.isFullSync: Boolean
get() = this == Chain.ConnectionState.FULL_SYNC
val Chain.ConnectionState.isDisabled: Boolean
get() = this == Chain.ConnectionState.DISABLED
val Chain.ConnectionState.level: Int
get() = when (this) {
Chain.ConnectionState.FULL_SYNC -> 2
Chain.ConnectionState.LIGHT_SYNC -> 1
Chain.ConnectionState.DISABLED -> 0
}
fun Chain.Additional?.relaychainAsNative(): Boolean {
return this?.relaychainAsNative ?: false
}
fun Chain.Additional?.feeViaRuntimeCall(): Boolean {
return this?.feeViaRuntimeCall ?: false
}
fun Chain.Additional?.isGenericLedgerAppSupported(): Boolean {
return this?.supportLedgerGenericApp ?: false
}
fun Chain.Additional?.shouldDisableMetadataHashCheck(): Boolean {
return this?.disabledCheckMetadataHash ?: false
}
fun ChainId.chainIdHexPrefix16(): String {
return removeHexPrefix()
.take(32)
.requireHexPrefix()
}
enum class StakingTypeGroup {
RELAYCHAIN, PARACHAIN, NOMINATION_POOL, MYTHOS, UNSUPPORTED
}
fun Chain.Asset.StakingType.group(): StakingTypeGroup {
return when (this) {
UNSUPPORTED -> StakingTypeGroup.UNSUPPORTED
RELAYCHAIN, RELAYCHAIN_AURA, ALEPH_ZERO -> StakingTypeGroup.RELAYCHAIN
PARACHAIN, TURING -> StakingTypeGroup.PARACHAIN
MYTHOS -> StakingTypeGroup.MYTHOS
NOMINATION_POOLS -> StakingTypeGroup.NOMINATION_POOL
}
}
fun Chain.Asset.StakingType.isDirectStaking(): Boolean {
return when (group()) {
StakingTypeGroup.RELAYCHAIN, StakingTypeGroup.PARACHAIN -> true
else -> false
}
}
fun Chain.Asset.StakingType.isPoolStaking(): Boolean {
return group() == StakingTypeGroup.NOMINATION_POOL
}
inline fun <reified T : Chain.ExternalApi> Chain.allExternalApis(): List<T> {
return externalApis.filterIsInstance<T>()
}
inline fun <reified T : Chain.ExternalApi> Chain.externalApi(): T? {
return externalApis.findIsInstanceOrNull<T>()
}
inline fun <reified T : Chain.ExternalApi> Chain.hasExternalApi(): Boolean {
return externalApis.any { it is T }
}
const val UTILITY_ASSET_ID = 0
val Chain.Asset.isUtilityAsset: Boolean
get() = id == UTILITY_ASSET_ID
inline val Chain.Asset.isCommissionAsset: Boolean
get() = isUtilityAsset
inline val FullChainAssetId.isUtility: Boolean
get() = assetId == UTILITY_ASSET_ID
private const val XC_PREFIX = "xc"
fun Chain.Asset.normalizeSymbol(): String {
return normalizeTokenSymbol(this.symbol.value)
}
fun TokenSymbol.normalize(): TokenSymbol {
return normalizeTokenSymbol(value).asTokenSymbol()
}
fun normalizeTokenSymbol(symbol: String): String {
return symbol.removePrefix(XC_PREFIX)
}
val Chain.Node.isWss: Boolean
get() = connectionType == Chain.Node.ConnectionType.WSS
val Chain.Node.isHttps: Boolean
get() = connectionType == Chain.Node.ConnectionType.HTTPS
fun Chain.Nodes.wssNodes(): List<Chain.Node> {
return nodes.filter { it.isWss }
}
fun Chain.Nodes.httpNodes(): List<Chain.Node> {
return nodes.filter { it.isHttps }
}
fun Chain.Nodes.hasHttpNodes(): Boolean {
return nodes.any { it.isHttps }
}
val Chain.Asset.disabled: Boolean
get() = !enabled
val Chain.genesisHash: String?
get() = id.takeIf {
runCatching { it.fromHex() }.isSuccess
}
fun Chain.hasOnlyOneAddressFormat() = legacyAddressPrefix == null
fun Chain.supportsLegacyAddressFormat() = legacyAddressPrefix != null
fun Chain.requireGenesisHash() = requireNotNull(genesisHash)
fun Chain.addressOf(accountId: ByteArray): String {
return if (isEthereumBased) {
accountId.toEthereumAddress()
} else {
accountId.toAddress(addressPrefix.toShort())
}
}
fun Chain.addressOf(accountId: AccountIdKey): String {
return addressOf(accountId.value)
}
fun Chain.legacyAddressOfOrNull(accountId: ByteArray): String? {
return if (isEthereumBased) {
null
} else {
legacyAddressPrefix?.let { accountId.toAddress(it.toShort()) }
}
}
fun ByteArray.toEthereumAddress(): String {
return asEthereumAccountId().toAddress(withChecksum = true).value
}
fun Chain.accountIdOf(address: String): ByteArray {
return if (isEthereumBased) {
address.asEthereumAddress().toAccountId().value
} else {
address.toAccountId()
}
}
fun String.toAccountId(chain: Chain): ByteArray {
return chain.accountIdOf(this)
}
fun String.toAccountIdKey(chain: Chain): AccountIdKey {
return chain.accountIdKeyOf(this)
}
fun Chain.accountIdKeyOf(address: String): AccountIdKey {
return accountIdOf(address).intoKey()
}
fun String.anyAddressToAccountId(): ByteArray {
return runCatching {
// Substrate
toAccountId()
}.recoverCatching {
// Evm
asEthereumAddress().toAccountId().value
}.getOrThrow()
}
fun Chain.accountIdOrNull(address: String): ByteArray? {
return runCatching { accountIdOf(address) }.getOrNull()
}
fun Chain.emptyAccountId() = if (isEthereumBased) {
emptyEthereumAccountId()
} else {
emptySubstrateAccountId()
}
fun Chain.emptyAccountIdKey() = emptyAccountId().intoKey()
fun Chain.accountIdOrDefault(maybeAddress: String): ByteArray {
return accountIdOrNull(maybeAddress) ?: emptyAccountId()
}
fun Chain.accountIdOf(publicKey: ByteArray): ByteArray {
return if (isEthereumBased) {
publicKey.asEthereumPublicKey().toAccountId().value
} else {
publicKey.substrateAccountId()
}
}
fun Chain.hexAccountIdOf(address: String): String {
return accountIdOf(address).toHexString()
}
fun Chain.multiAddressOf(accountId: ByteArray): MultiAddress {
return if (isEthereumBased) {
MultiAddress.Address20(accountId)
} else {
MultiAddress.Id(accountId)
}
}
fun Chain.isValidAddress(address: String): Boolean {
return runCatching {
if (isEthereumBased) {
address.asEthereumAddress().isValid()
} else {
address.toAccountId() // verify supplied address can be converted to account id
addressPrefix.toShort() == address.addressPrefix() ||
legacyAddressPrefix?.toShort() == address.addressPrefix()
}
}.getOrDefault(false)
}
fun Chain.isValidEvmAddress(address: String): Boolean {
return runCatching {
if (isEthereumBased) {
address.asEthereumAddress().isValid()
} else {
false
}
}.getOrDefault(false)
}
val Chain.isParachain
get() = parentId != null
fun Chain.multiAddressOf(address: String): MultiAddress = multiAddressOf(accountIdOf(address))
fun Chain.availableExplorersFor(field: ExplorerTemplateExtractor) = explorers.filter { field(it) != null }
fun Chain.Explorer.accountUrlOf(address: String): String {
return format(Chain.Explorer::account, "address", address)
}
fun Chain.Explorer.extrinsicUrlOf(extrinsicHash: String): String {
return format(Chain.Explorer::extrinsic, "hash", extrinsicHash)
}
fun Chain.Explorer.eventUrlOf(eventId: String): String {
return format(Chain.Explorer::event, "event", eventId)
}
private inline fun Chain.Explorer.format(
templateExtractor: ExplorerTemplateExtractor,
argumentName: String,
argumentValue: String,
): String {
val template = templateExtractor(this) ?: throw Exception("Cannot find template in the chain explorer: $name")
return template.formatNamed(argumentName to argumentValue)
}
object ChainGeneses {
// Pezkuwi chains (priority)
const val PEZKUWI = "bb4a61ab0c4b8c12f5eab71d0c86c482e03a275ecdafee678dea712474d33d75"
const val PEZKUWI_ASSET_HUB = "00d0e1d0581c3cd5c5768652d52f4520184018b44f56a2ae1e0dc9d65c00c948"
const val PEZKUWI_PEOPLE = "58269e9c184f721e0309332d90cafc410df1519a5dc27a5fd9b3bf5fd2d129f8"
const val KUSAMA = "b0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe"
const val POLKADOT = "91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3"
// Westend constant now points to Zagros Testnet
const val WESTEND = "96eb58af1bb7288115b5e4ff1590422533e749293f231974536dc6672417d06f"
const val KUSAMA_ASSET_HUB = "48239ef607d7928874027a43a67689209727dfb3d3dc5e5b03a39bdc2eda771a"
const val ACALA = "fc41b9bd8ef8fe53d58c7ea67c794c7ec9a73daf05e6d54b14ff6342c99ba64c"
const val ROCOCO_ACALA = "a84b46a3e602245284bb9a72c4abd58ee979aa7a5d7f8c4dfdddfaaf0665a4ae"
const val STATEMINT = "68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f"
const val EDGEWARE = "742a2ca70c2fda6cee4f8df98d64c4c670a052d9568058982dad9d5a7a135c5b"
const val KARURA = "baf5aabe40646d11f0ee8abbdc64f4a4b7674925cba08e4a05ff9ebed6e2126b"
const val NODLE_PARACHAIN = "97da7ede98d7bad4e36b4d734b6055425a3be036da2a332ea5a7037656427a21"
const val MOONBEAM = "fe58ea77779b7abda7da4ec526d14db9b1e9cd40a217c34892af80a9b332b76d"
const val MOONRIVER = "401a1f9dca3da46f5c4091016c8a2f26dcea05865116b286f60f668207d1474b"
const val POLYMESH = "6fbd74e5e1d0a61d52ccfe9d4adaed16dd3a7caa37c6bc4d0c2fa12e8b2f4063"
const val XX_NETWORK = "50dd5d206917bf10502c68fb4d18a59fc8aa31586f4e8856b493e43544aa82aa"
const val KILT = "411f057b9107718c9624d6aa4a3f23c1653898297f3d4d529d9bb6511a39dd21"
const val ASTAR = "9eb76c5184c4ab8679d2d5d819fdf90b9c001403e9e17da2e14b6d8aec4029c6"
const val ALEPH_ZERO = "70255b4d28de0fc4e1a193d7e175ad1ccef431598211c55538f1018651a0344e"
const val TERNOA = "6859c81ca95ef624c9dfe4dc6e3381c33e5d6509e35e147092bfbc780f777c4e"
const val POLIMEC = "7eb9354488318e7549c722669dcbdcdc526f1fef1420e7944667212f3601fdbd"
const val POLKADEX = "3920bcb4960a1eef5580cd5367ff3f430eef052774f78468852f7b9cb39f8a3c"
const val CALAMARI = "4ac80c99289841dd946ef92765bf659a307d39189b3ce374a92b5f0415ee17a1"
const val TURING = "0f62b701fb12d02237a33b84818c11f621653d2b1614c777973babf4652b535d"
const val ZEITGEIST = "1bf2a2ecb4a868de66ea8610f2ce7c8c43706561b6476031315f6640fe38e060"
const val WESTMINT = "67f9723393ef76214df0118c34bbbd3dbebc8ed46a10973a8c969d48fe7598c9"
const val HYDRA_DX = "afdc188f45c71dacbaa0b62e16a91f726c7b8699a9748cdf715459de6b7f366d"
const val AVAIL_TURING_TESTNET = "d3d2f3a3495dc597434a99d7d449ebad6616db45e4e4f178f31cc6fa14378b70"
const val AVAIL = "b91746b45e0346cc2f815a520b9c6cb4d5c0902af848db0a80f85932d2e8276a"
const val VARA = "fe1b4c55fd4d668101126434206571a7838a8b6b93a6d1b95d607e78e6c53763"
const val POLKADOT_ASSET_HUB = "68d56f15f85d3136970ec16946040bc1752654e906147f7e43e9d539d7c3de2f"
const val UNIQUE_NETWORK = "84322d9cddbf35088f1e54e9a85c967a41a56a4f43445768125e61af166c7d31"
const val POLKADOT_PEOPLE = "67fa177a097bfa18f77ea95ab56e9bcdfeb0e5b8a40e46298bb93e16b6fc5008"
const val KUSAMA_PEOPLE = "c1af4cb4eb3918e5db15086c0cc5ec17fb334f728b7c65dd44bfe1e174ff8b3f"
}
object ChainIds {
const val ETHEREUM = "$EIP_155_PREFIX:1"
const val MOONBEAM = ChainGeneses.MOONBEAM
const val MOONRIVER = ChainGeneses.MOONRIVER
}
val Chain.Companion.Geneses
get() = ChainGeneses
val Chain.Companion.Ids
get() = ChainIds
fun Chain.Asset.requireStatemine(): Type.Statemine {
require(type is Type.Statemine)
return type
}
fun Chain.findStatemineAssets(): List<Chain.Asset> {
return assets.filter { it.type is Type.Statemine }
}
fun Chain.Asset.statemineOrNull(): Type.Statemine? {
return type as? Type.Statemine
}
fun Type.Statemine.palletNameOrDefault(): String {
return palletName ?: Modules.ASSETS
}
fun Chain.Asset.requireOrml(): Type.Orml {
require(type is Type.Orml)
return type
}
val Chain.addressScheme: AddressScheme
get() = if (isEthereumBased) AddressScheme.EVM else AddressScheme.SUBSTRATE
fun Chain.Asset.ormlOrNull(): Type.Orml? {
return type as? Type.Orml
}
fun Chain.Asset.requireErc20(): Type.EvmErc20 {
require(type is Type.EvmErc20)
return type
}
fun Chain.Asset.requireEquilibrium(): Type.Equilibrium {
require(type is Type.Equilibrium)
return type
}
fun Chain.Asset.ormlCurrencyId(runtime: RuntimeSnapshot): Any? {
return requireOrml().currencyId(runtime)
}
fun Type.Orml.currencyId(runtime: RuntimeSnapshot): Any? {
val currencyIdType = runtime.typeRegistry[currencyIdType]
?: error("Cannot find type $currencyIdType")
return currencyIdType.fromHex(runtime, currencyIdScale)
}
val Chain.Asset.fullId: FullChainAssetId
get() = FullChainAssetId(chainId, id)
fun Chain.enabledAssets(): List<Chain.Asset> = assets.filter { it.enabled }
fun Chain.disabledAssets(): List<Chain.Asset> = assets.filterNot { it.enabled }
fun evmChainIdFrom(chainId: Int) = "$EIP_155_PREFIX:$chainId"
fun evmChainIdFrom(chainId: BigInteger) = "$EIP_155_PREFIX:$chainId"
fun Chain.findAssetByOrmlCurrencyId(runtime: RuntimeSnapshot, currencyId: Any?): Chain.Asset? {
return assets.find { asset ->
if (asset.type !is Type.Orml) return@find false
val currencyType = runtime.typeRegistry[asset.type.currencyIdType] ?: return@find false
val currencyIdScale = bindOrNull { currencyType.toHexUntyped(runtime, currencyId) } ?: return@find false
currencyIdScale == asset.type.currencyIdScale
}
}
fun Chain.findAssetByStatemineAssetId(runtime: RuntimeSnapshot, assetId: Any?): Chain.Asset? {
return assets.find { asset ->
if (asset.type !is Type.Statemine) return@find false
asset.type.hasSameId(runtime, assetId)
}
}
fun Type.Orml.decodeOrNull(runtime: RuntimeSnapshot): Any? {
val currencyType = runtime.typeRegistry[currencyIdType] ?: return null
return currencyType.fromHexOrNull(runtime, currencyIdScale)
}
val Chain.Asset.localId: AssetAndChainId
get() = AssetAndChainId(chainId, id)
val Chain.Asset.onChainAssetId: String?
get() = when (this.type) {
is Type.Equilibrium -> this.type.toString()
is Type.Orml -> this.type.currencyIdScale
is Type.Statemine -> this.type.id.onChainAssetId()
is Type.EvmErc20 -> this.type.contractAddress
is Type.Native -> null
is Type.EvmNative -> null
Type.Unsupported -> error("Unsupported assetId type: ${this.type::class.simpleName}")
}
fun StatemineAssetId.onChainAssetId(): String {
return when (this) {
is StatemineAssetId.Number -> value.toString()
is StatemineAssetId.ScaleEncoded -> scaleHex
}
}
fun Chain.openGovIfSupported(): Chain.Governance? {
return Chain.Governance.V2.takeIf { it in governance }
}
fun Chain.Explorer.normalizedUrl(): String? {
val url = listOfNotNull(extrinsic, account, event).firstOrNull()
return url?.let { Urls.normalizeUrl(it) }
}
fun Chain.supportTinderGov(): Boolean {
return hasReferendaSummaryApi()
}
fun Chain.hasReferendaSummaryApi(): Boolean {
return externalApi<Chain.ExternalApi.ReferendumSummary>() != null
}
fun Chain.summaryApiOrNull(): Chain.ExternalApi.ReferendumSummary? {
return externalApi<Chain.ExternalApi.ReferendumSummary>()
}
fun Chain.timelineChainId(): ChainId? {
return additional?.timelineChain
}
fun Chain.timelineChainIdOrSelf(): ChainId {
return timelineChainId() ?: id
}
fun Chain.hasTimelineChain(): Boolean {
return additional?.timelineChain != null
}
fun FullChainAssetId.Companion.utilityAssetOf(chainId: ChainId) = FullChainAssetId(chainId, UTILITY_ASSET_ID)
fun SignatureVerifier.verifyMultiChain(
chain: Chain,
signature: SignatureWrapper,
message: ByteArray,
publicKey: ByteArray
): Boolean {
return if (chain.isEthereumBased) {
verify(signature, Signer.MessageHashing.ETHEREUM, message, publicKey)
} else {
verify(signature, Signer.MessageHashing.SUBSTRATE, message, publicKey)
}
}
/**
* Check if this chain is part of the Pezkuwi ecosystem.
* Pezkuwi chains use "bizinikiwi" signing context instead of "substrate".
*/
val Chain.isPezkuwiChain: Boolean
get() = id in PEZKUWI_CHAIN_IDS
private val PEZKUWI_CHAIN_IDS = setOf(
ChainGeneses.PEZKUWI,
ChainGeneses.PEZKUWI_ASSET_HUB,
ChainGeneses.PEZKUWI_PEOPLE
)
@@ -0,0 +1,35 @@
package io.novafoundation.nova.runtime.ext
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
val Chain.mainChainsFirstAscendingOrder
get() = when (genesisHash) {
// Pezkuwi ecosystem first
Chain.Geneses.PEZKUWI -> 0
Chain.Geneses.PEZKUWI_ASSET_HUB -> 1
Chain.Geneses.PEZKUWI_PEOPLE -> 2
// Then Polkadot ecosystem
Chain.Geneses.POLKADOT -> 3
Chain.Geneses.POLKADOT_ASSET_HUB -> 4
// Then Kusama ecosystem
Chain.Geneses.KUSAMA -> 5
Chain.Geneses.KUSAMA_ASSET_HUB -> 6
// Everything else
else -> 7
}
val Chain.testnetsLastAscendingOrder
get() = if (isTestNet) {
1
} else {
0
}
val Chain.alphabeticalOrder
get() = name
fun <K> Chain.Companion.defaultComparatorFrom(extractor: (K) -> Chain): Comparator<K> = Comparator.comparing(extractor, defaultComparator())
fun Chain.Companion.defaultComparator(): Comparator<Chain> = compareBy<Chain> { it.mainChainsFirstAscendingOrder }
.thenBy { it.testnetsLastAscendingOrder }
.thenBy { it.alphabeticalOrder }
@@ -0,0 +1,22 @@
package io.novafoundation.nova.runtime.ext
import io.novafoundation.nova.common.utils.TokenSymbol
val TokenSymbol.mainTokensFirstAscendingOrder
get() = when (this.value) {
"HEZ" -> 0
"PEZ" -> 1
"USDT" -> 2
"DOT" -> 3
"KSM" -> 4
"USDC" -> 5
else -> 6
}
val TokenSymbol.alphabeticalOrder
get() = value
fun <K> TokenSymbol.Companion.defaultComparatorFrom(extractor: (K) -> TokenSymbol): Comparator<K> = Comparator.comparing(extractor, defaultComparator())
fun TokenSymbol.Companion.defaultComparator(): Comparator<TokenSymbol> = compareBy<TokenSymbol> { it.mainTokensFirstAscendingOrder }
.thenBy { it.alphabeticalOrder }
@@ -0,0 +1,63 @@
package io.novafoundation.nova.runtime.extrinsic
import android.util.Log
import io.novafoundation.nova.runtime.extrinsic.extensions.AuthorizeCall
import io.novafoundation.nova.runtime.extrinsic.extensions.ChargeAssetTxPayment
import io.novafoundation.nova.runtime.extrinsic.extensions.CheckAppId
import io.novafoundation.nova.runtime.extrinsic.extensions.CheckNonZeroSender
import io.novafoundation.nova.runtime.extrinsic.extensions.CheckWeight
import io.novafoundation.nova.runtime.extrinsic.extensions.WeightReclaim
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.TransactionExtension
private const val TAG = "CustomTxExtensions"
object CustomTransactionExtensions {
fun applyDefaultValues(builder: ExtrinsicBuilder) {
defaultValues().forEach(builder::setTransactionExtension)
}
fun applyDefaultValues(builder: ExtrinsicBuilder, runtime: RuntimeSnapshot) {
defaultValues(runtime).forEach(builder::setTransactionExtension)
}
fun defaultValues(): List<TransactionExtension> {
return listOf(
ChargeAssetTxPayment(),
CheckAppId()
)
}
fun defaultValues(runtime: RuntimeSnapshot): List<TransactionExtension> {
val extensions = mutableListOf<TransactionExtension>()
val signedExtIds = runtime.metadata.extrinsic.signedExtensions.map { it.id }
Log.d(TAG, "Metadata signed extensions: $signedExtIds")
// Add extensions based on what the metadata requires
if ("AuthorizeCall" in signedExtIds) {
extensions.add(AuthorizeCall())
}
if ("CheckNonZeroSender" in signedExtIds) {
extensions.add(CheckNonZeroSender())
}
if ("CheckWeight" in signedExtIds) {
extensions.add(CheckWeight())
}
if ("WeightReclaim" in signedExtIds || "StorageWeightReclaim" in signedExtIds) {
extensions.add(WeightReclaim())
}
if ("ChargeAssetTxPayment" in signedExtIds) {
// Default to native fee payment (null assetId)
extensions.add(ChargeAssetTxPayment())
}
if ("CheckAppId" in signedExtIds) {
extensions.add(CheckAppId())
}
Log.d(TAG, "Extensions to add: ${extensions.map { it.name }}")
return extensions
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.runtime.extrinsic
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.call
fun ExtrinsicBuilder.systemRemark(remark: ByteArray): ExtrinsicBuilder {
return call(
moduleName = "System",
callName = "remark",
arguments = mapOf(
"remark" to remark
)
)
}
fun ExtrinsicBuilder.systemRemarkWithEvent(remark: ByteArray): ExtrinsicBuilder {
return call(
moduleName = "System",
callName = "remark_with_event",
arguments = mapOf(
"remark" to remark
)
)
}
fun ExtrinsicBuilder.systemRemarkWithEvent(remark: String): ExtrinsicBuilder {
return systemRemarkWithEvent(remark.encodeToByteArray())
}
@@ -0,0 +1,92 @@
package io.novafoundation.nova.runtime.extrinsic
import android.util.Log
import io.novafoundation.nova.common.utils.orZero
import io.novafoundation.nova.runtime.ext.requireGenesisHash
import io.novafoundation.nova.runtime.extrinsic.extensions.PezkuwiCheckMortality
import io.novafoundation.nova.runtime.extrinsic.metadata.MetadataShortenerService
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.extensions.fromHex
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.extrinsic.BatchMode
import io.novasama.substrate_sdk_android.runtime.extrinsic.ExtrinsicVersion
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.ChargeTransactionPayment
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckGenesis
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckMortality
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckSpecVersion
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.CheckTxVersion
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHash
private const val TAG = "ExtrinsicBuilderFactory"
class ExtrinsicBuilderFactory(
private val chainRegistry: ChainRegistry,
private val mortalityConstructor: MortalityConstructor,
private val metadataShortenerService: MetadataShortenerService,
) {
class Options(
val batchMode: BatchMode,
)
suspend fun create(
chain: Chain,
options: Options,
): ExtrinsicBuilder {
return createMulti(chain, options).first()
}
suspend fun createMulti(
chain: Chain,
options: Options,
): Sequence<ExtrinsicBuilder> {
val runtime = chainRegistry.getRuntime(chain.id)
// Log metadata extensions
val metadataExtensions = runtime.metadata.extrinsic.signedExtensions.map { it.id }
Log.d(TAG, "Chain: ${chain.name}, Metadata extensions: $metadataExtensions")
val mortality = mortalityConstructor.constructMortality(chain.id)
val metadataProof = metadataShortenerService.generateMetadataProof(chain.id)
// Log custom extensions
val customExtensions = CustomTransactionExtensions.defaultValues(runtime).map { it.name }
Log.d(TAG, "Custom extensions to add: $customExtensions")
val isPezkuwi = isPezkuwiChain(runtime)
Log.d(TAG, "isPezkuwiChain: $isPezkuwi")
return generateSequence {
ExtrinsicBuilder(
runtime = runtime,
extrinsicVersion = ExtrinsicVersion.V4,
batchMode = options.batchMode,
).apply {
// Use custom CheckMortality for Pezkuwi chains to avoid type lookup issues
if (isPezkuwi) {
Log.d(TAG, "Using PezkuwiCheckMortality for ${chain.name}")
setTransactionExtension(PezkuwiCheckMortality(mortality.era, mortality.blockHash.fromHex()))
} else {
setTransactionExtension(CheckMortality(mortality.era, mortality.blockHash.fromHex()))
}
setTransactionExtension(CheckGenesis(chain.requireGenesisHash().fromHex()))
setTransactionExtension(ChargeTransactionPayment(chain.additional?.defaultTip.orZero()))
setTransactionExtension(CheckMetadataHash(metadataProof.checkMetadataHash))
setTransactionExtension(CheckSpecVersion(metadataProof.usedVersion.specVersion))
setTransactionExtension(CheckTxVersion(metadataProof.usedVersion.transactionVersion))
CustomTransactionExtensions.defaultValues(runtime).forEach(::setTransactionExtension)
Log.d(TAG, "All extensions set for ${chain.name}")
}
}
}
private fun isPezkuwiChain(runtime: RuntimeSnapshot): Boolean {
val signedExtIds = runtime.metadata.extrinsic.signedExtensions.map { it.id }
return signedExtIds.any { it == "AuthorizeCall" }
}
}
@@ -0,0 +1,31 @@
package io.novafoundation.nova.runtime.extrinsic
import com.google.gson.GsonBuilder
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import com.google.gson.JsonPrimitive
import com.google.gson.JsonSerializationContext
import com.google.gson.JsonSerializer
import io.novafoundation.nova.common.utils.ByteArrayHexAdapter
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import java.lang.reflect.Type
private class GenericCallAdapter : JsonSerializer<GenericCall.Instance> {
override fun serialize(src: GenericCall.Instance, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
return JsonObject().apply {
add("module", JsonPrimitive(src.module.name))
add("function", JsonPrimitive(src.function.name))
add("args", context.serialize(src.arguments))
}
}
}
object ExtrinsicSerializers {
fun gson() = GsonBuilder()
.registerTypeHierarchyAdapter(ByteArray::class.java, ByteArrayHexAdapter())
.registerTypeHierarchyAdapter(GenericCall.Instance::class.java, GenericCallAdapter())
.setPrettyPrinting()
.create()
}
@@ -0,0 +1,43 @@
package io.novafoundation.nova.runtime.extrinsic
import io.novasama.substrate_sdk_android.wsrpc.subscription.response.SubscriptionChange
sealed class ExtrinsicStatus(val extrinsicHash: String, val terminal: Boolean) {
class Ready(extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false)
class Broadcast(extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false)
class InBlock(val blockHash: String, extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false)
class Finalized(val blockHash: String, extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = true)
class Other(extrinsicHash: String) : ExtrinsicStatus(extrinsicHash, terminal = false)
}
private const val STATUS_READY = "ready"
private const val STATUS_BROADCAST = "broadcast"
private const val STATUS_IN_BLOCK = "inBlock"
private const val STATUS_FINALIZED = "finalized"
private const val STATUS_FINALITY_TIMEOUT = "finalityTimeout"
fun SubscriptionChange.asExtrinsicStatus(extrinsicHash: String): ExtrinsicStatus {
return when (val result = params.result) {
STATUS_READY -> ExtrinsicStatus.Ready(extrinsicHash)
is Map<*, *> -> when {
STATUS_BROADCAST in result -> ExtrinsicStatus.Broadcast(extrinsicHash)
STATUS_IN_BLOCK in result -> ExtrinsicStatus.InBlock(extractBlockHash(result, STATUS_IN_BLOCK), extrinsicHash)
STATUS_FINALIZED in result -> ExtrinsicStatus.Finalized(extractBlockHash(result, STATUS_FINALIZED), extrinsicHash)
STATUS_FINALITY_TIMEOUT in result -> ExtrinsicStatus.Finalized(extractBlockHash(result, STATUS_FINALITY_TIMEOUT), extrinsicHash)
else -> ExtrinsicStatus.Other(extrinsicHash)
}
else -> ExtrinsicStatus.Other(extrinsicHash)
}
}
private fun extractBlockHash(map: Map<*, *>, key: String): String {
return map[key] as? String ?: unknownStructure()
}
private fun unknownStructure(): Nothing = throw IllegalArgumentException("Unknown extrinsic status structure")
@@ -0,0 +1,66 @@
package io.novafoundation.nova.runtime.extrinsic
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.lifecycle.LifecycleOwner
import io.novafoundation.nova.common.utils.formatting.TimerValue
import io.novafoundation.nova.common.utils.formatting.remainingTime
import io.novafoundation.nova.common.utils.setTextColorRes
import io.novafoundation.nova.common.view.startTimer
import io.novafoundation.nova.runtime.R
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes
class ValidityPeriod(val period: TimerValue)
fun ValidityPeriod.remainingTime(): Long {
return period.remainingTime()
}
fun ValidityPeriod.closeToExpire(): Boolean {
return remainingTime().milliseconds < 1.minutes
}
fun ValidityPeriod.ended(): Boolean {
return remainingTime() == 0L
}
interface ExtrinsicValidityUseCase {
suspend fun extrinsicValidityPeriod(payload: InheritedImplication): ValidityPeriod
}
internal class RealExtrinsicValidityUseCase(
private val mortalityConstructor: MortalityConstructor
) : ExtrinsicValidityUseCase {
override suspend fun extrinsicValidityPeriod(payload: InheritedImplication): ValidityPeriod {
// TODO this should calculate remaining time based on Era from payload
val timerValue = TimerValue(
millis = mortalityConstructor.mortalPeriodMillis(),
millisCalculatedAt = System.currentTimeMillis()
)
return ValidityPeriod(timerValue)
}
}
fun LifecycleOwner.startExtrinsicValidityTimer(
validityPeriod: ValidityPeriod,
@StringRes timerFormat: Int,
timerView: TextView,
onTimerFinished: () -> Unit
) {
timerView.startTimer(
value = validityPeriod.period,
customMessageFormat = timerFormat,
lifecycle = lifecycle,
onTick = { view, _ ->
val textColorRes = if (validityPeriod.closeToExpire()) R.color.text_negative else R.color.text_secondary
view.setTextColorRes(textColorRes)
},
onFinish = { onTimerFinished() }
)
}
@@ -0,0 +1,57 @@
package io.novafoundation.nova.runtime.extrinsic
import io.novafoundation.nova.common.utils.atLeastZero
import io.novafoundation.nova.common.utils.invoke
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import io.novafoundation.nova.runtime.repository.ChainStateRepository
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import java.lang.Integer.min
private const val FALLBACK_MAX_HASH_COUNT = 250
private const val MAX_FINALITY_LAG = 5
private const val MORTAL_PERIOD = 5 * 60 * 1000
class Mortality(val era: Era.Mortal, val blockHash: String)
class MortalityConstructor(
private val rpcCalls: RpcCalls,
private val chainStateRepository: ChainStateRepository,
) {
fun mortalPeriodMillis(): Long = MORTAL_PERIOD.toLong()
suspend fun constructMortality(chainId: ChainId): Mortality = withContext(Dispatchers.IO) {
val finalizedHash = async { rpcCalls.getFinalizedHead(chainId) }
val bestHeader = async { rpcCalls.getBlockHeader(chainId) }
val finalizedHeader = async { rpcCalls.getBlockHeader(chainId, finalizedHash()) }
val currentHeader = async { bestHeader().parentHash?.let { rpcCalls.getBlockHeader(chainId, it) } ?: bestHeader() }
val currentNumber = currentHeader().number
val finalizedNumber = finalizedHeader().number
val finalityLag = (currentNumber - finalizedNumber).atLeastZero()
val startBlockNumber = finalizedNumber
val blockHashCount = chainStateRepository.blockHashCount(chainId)?.toInt()
val blockTime = chainStateRepository.predictedBlockTime(chainId).toInt()
val mortalPeriod = MORTAL_PERIOD / blockTime + finalityLag
val unmappedPeriod = min(blockHashCount ?: FALLBACK_MAX_HASH_COUNT, mortalPeriod)
val era = Era.getEraFromBlockPeriod(startBlockNumber, unmappedPeriod)
val eraBlockNumber = ((startBlockNumber - era.phase) / era.period) * era.period + era.phase
val eraBlockHash = rpcCalls.getBlockHash(chainId, eraBlockNumber.toBigInteger())
Mortality(era, eraBlockHash)
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
/**
* Signed extension for PezkuwiChain that authorizes calls.
* This extension uses PhantomData internally, so it has no payload (empty encoding).
*
* In the runtime, AuthorizeCall is defined as:
* pub struct AuthorizeCall<T>(core::marker::PhantomData<T>);
*
* It's placed first in the TxExtension tuple for PezkuwiChain.
*/
class AuthorizeCall : FixedValueTransactionExtension(
name = "AuthorizeCall",
implicit = null,
explicit = null // PhantomData encodes to nothing
)
@@ -0,0 +1,32 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novafoundation.nova.common.utils.structOf
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
import java.math.BigInteger
class ChargeAssetTxPayment(
val assetId: Any? = null,
val tip: BigInteger = BigInteger.ZERO
) : FixedValueTransactionExtension(
name = ID,
implicit = null,
explicit = assetTxPaymentPayload(assetId, tip)
) {
companion object {
val ID = "ChargeAssetTxPayment"
private fun assetTxPaymentPayload(assetId: Any?, tip: BigInteger = BigInteger.ZERO): Any {
return structOf(
"tip" to tip,
"assetId" to assetId
)
}
fun ExtrinsicBuilder.chargeAssetTxPayment(assetId: Any?, tip: BigInteger = BigInteger.ZERO) {
setTransactionExtension(ChargeAssetTxPayment(assetId, tip))
}
}
}
@@ -0,0 +1,12 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
import java.math.BigInteger
// Signed extension for Avail related to Data Availability Transactions.
// We set it to 0 which is the default value provided by Avail team
class CheckAppId(appId: BigInteger = BigInteger.ZERO) : FixedValueTransactionExtension(
name = "CheckAppId",
implicit = null,
explicit = appId
)
@@ -0,0 +1,18 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
/**
* Signed extension for PezkuwiChain that checks for non-zero sender.
* This extension ensures the sender is not the zero address.
*
* In the runtime, CheckNonZeroSender is defined as:
* pub struct CheckNonZeroSender<T>(core::marker::PhantomData<T>);
*
* It uses PhantomData internally, so it has no payload (empty encoding).
*/
class CheckNonZeroSender : FixedValueTransactionExtension(
name = "CheckNonZeroSender",
implicit = null,
explicit = null // PhantomData encodes to nothing
)
@@ -0,0 +1,16 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
/**
* Signed extension that checks weight limits.
* This extension uses PhantomData internally, so it has no payload (empty encoding).
*
* In the runtime, CheckWeight is defined as:
* pub struct CheckWeight<T>(core::marker::PhantomData<T>);
*/
class CheckWeight : FixedValueTransactionExtension(
name = "CheckWeight",
implicit = null,
explicit = null // PhantomData encodes to nothing
)
@@ -0,0 +1,23 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
/**
* Custom CheckMortality extension for Pezkuwi chains using IMMORTAL era.
*
* Pezkuwi uses pezsp_runtime.generic.era.Era which is a DictEnum with variants:
* - Immortal (encoded as 0x00)
* - Mortal1(u8), Mortal2(u8), ..., Mortal255(u8)
*
* This extension uses Immortal era with genesis hash, which matches how @pezkuwi/api signs.
*
* @param genesisHash The chain's genesis hash (32 bytes) for the signer payload
*/
class PezkuwiCheckImmortal(
genesisHash: ByteArray
) : FixedValueTransactionExtension(
name = "CheckMortality",
implicit = genesisHash, // Genesis hash goes into signer payload for immortal transactions
explicit = DictEnum.Entry<Any?>("Immortal", null) // Immortal variant - unit type with no value
)
@@ -0,0 +1,68 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.definitions.types.composite.DictEnum
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.Era
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
import java.math.BigInteger
/**
* Custom CheckMortality extension for Pezkuwi chains.
*
* Pezkuwi uses pezsp_runtime.generic.era.Era which is a DictEnum with variants:
* - Immortal
* - Mortal1(u8), Mortal2(u8), ..., Mortal255(u8)
*
* The variant name is "MortalX" where X is the first byte of the encoded era,
* and the variant's value is the second byte (u8).
*
* @param era The mortal era from MortalityConstructor
* @param blockHash The block hash (32 bytes) for the signer payload
*/
class PezkuwiCheckMortality(
era: Era.Mortal,
blockHash: ByteArray
) : FixedValueTransactionExtension(
name = "CheckMortality",
implicit = blockHash, // blockHash goes into signer payload
explicit = createEraEntry(era) // Era as DictEnum.Entry
) {
companion object {
/**
* Creates a DictEnum.Entry for the Era.
*
* Standard Era encoding produces 2 bytes:
* - First byte determines the variant name (Mortal1, Mortal2, ..., Mortal255)
* - Second byte is the variant's value (u8)
*/
private fun createEraEntry(era: Era.Mortal): DictEnum.Entry<BigInteger> {
val period = era.period.toLong()
val phase = era.phase.toLong()
val quantizeFactor = maxOf(period shr 12, 1)
// Calculate the two-byte encoding
val encoded = ((countTrailingZeroBits(period) - 1).coerceIn(1, 15)) or
((phase / quantizeFactor).toInt() shl 4)
val firstByte = encoded and 0xFF
val secondByte = (encoded shr 8) and 0xFF
// DictEnum variant: "MortalX" where X is the first byte
// Variant value: second byte as u8 (BigInteger)
return DictEnum.Entry(
name = "Mortal$firstByte",
value = BigInteger.valueOf(secondByte.toLong())
)
}
private fun countTrailingZeroBits(value: Long): Int {
if (value == 0L) return 64
var n = 0
var x = value
while ((x and 1L) == 0L) {
n++
x = x shr 1
}
return n
}
}
}
@@ -0,0 +1,18 @@
package io.novafoundation.nova.runtime.extrinsic.extensions
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.FixedValueTransactionExtension
/**
* Signed extension for PezkuwiChain that handles weight reclamation.
* This extension reclaims unused weight after transaction execution.
*
* In the runtime, WeightReclaim is defined as:
* pub struct WeightReclaim<T>(core::marker::PhantomData<T>);
*
* It uses PhantomData internally, so it has no payload (empty encoding).
*/
class WeightReclaim : FixedValueTransactionExtension(
name = "WeightReclaim",
implicit = null,
explicit = null // PhantomData encodes to nothing
)
@@ -0,0 +1,4 @@
package io.novafoundation.nova.runtime.extrinsic.metadata
@JvmInline
value class ExtrinsicProof(val value: ByteArray)
@@ -0,0 +1,9 @@
package io.novafoundation.nova.runtime.extrinsic.metadata
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode
import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersion
class MetadataProof(
val checkMetadataHash: CheckMetadataHashMode,
val usedVersion: RuntimeVersion
)
@@ -0,0 +1,187 @@
package io.novafoundation.nova.runtime.extrinsic.metadata
import android.util.Log
import io.novafoundation.nova.common.utils.hasSignedExtension
import io.novafoundation.nova.metadata_shortener.MetadataShortener
import io.novafoundation.nova.runtime.ext.shouldDisableMetadataHashCheck
import io.novafoundation.nova.runtime.ext.utilityAsset
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.getRawMetadata
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.network.rpc.RpcCalls
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.DefaultSignedExtensions
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.checkMetadataHash.CheckMetadataHashMode
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.getGenesisHashOrThrow
import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata
import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersionFull
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface MetadataShortenerService {
suspend fun isCheckMetadataHashAvailable(chainId: ChainId): Boolean
suspend fun generateExtrinsicProof(inheritedImplication: InheritedImplication): ExtrinsicProof
suspend fun generateMetadataProof(chainId: ChainId): MetadataProof
suspend fun generateDisabledMetadataProof(chainId: ChainId): MetadataProof
}
private const val MINIMUM_METADATA_VERSION_TO_CALCULATE_HASH = 15
internal class RealMetadataShortenerService(
private val chainRegistry: ChainRegistry,
private val rpcCalls: RpcCalls,
) : MetadataShortenerService {
private val cache = MetadataProofCache()
override suspend fun isCheckMetadataHashAvailable(chainId: ChainId): Boolean {
val runtime = chainRegistry.getRuntime(chainId)
val chain = chainRegistry.getChain(chainId)
return shouldCalculateMetadataHash(runtime.metadata, chain)
}
override suspend fun generateExtrinsicProof(inheritedImplication: InheritedImplication): ExtrinsicProof {
val chainId = inheritedImplication.getGenesisHashOrThrow().toHexString(withPrefix = false)
val chain = chainRegistry.getChain(chainId)
val mainAsset = chain.utilityAsset
val call = inheritedImplication.encodedCall()
val signedExtras = inheritedImplication.encodedExplicits()
val additionalSigned = inheritedImplication.encodedImplicits()
val metadataVersion = rpcCalls.getRuntimeVersion(chainId)
val metadata = chainRegistry.getRawMetadata(chainId)
val proof = MetadataShortener.generate_extrinsic_proof(
call,
signedExtras,
additionalSigned,
metadata.metadataContent,
metadataVersion.specVersion,
metadataVersion.specName,
chain.addressPrefix,
mainAsset.precision.value,
mainAsset.symbol.value
)
return ExtrinsicProof(proof)
}
override suspend fun generateMetadataProof(chainId: ChainId): MetadataProof {
val runtimeVersion = rpcCalls.getRuntimeVersion(chainId)
val chain = chainRegistry.getChain(chainId)
return cache.getOrCompute(chainId, expectedSpecVersion = runtimeVersion.specVersion) {
val runtimeMetadata = chainRegistry.getRuntime(chainId).metadata
val shouldIncludeHash = shouldCalculateMetadataHash(runtimeMetadata, chain)
if (shouldIncludeHash) {
generateMetadataProofOrDisabled(chain, runtimeVersion)
} else {
MetadataProof(
checkMetadataHash = CheckMetadataHashMode.Disabled,
usedVersion = runtimeVersion
)
}
}
}
override suspend fun generateDisabledMetadataProof(chainId: ChainId): MetadataProof {
val runtimeVersion = rpcCalls.getRuntimeVersion(chainId)
return MetadataProof(
checkMetadataHash = CheckMetadataHashMode.Disabled,
usedVersion = runtimeVersion,
)
}
private suspend fun generateMetadataProofOrDisabled(
chain: Chain,
runtimeVersion: RuntimeVersionFull
): MetadataProof {
return runCatching {
val hash = generateMetadataHash(chain, runtimeVersion)
MetadataProof(
checkMetadataHash = CheckMetadataHashMode.Enabled(hash),
usedVersion = runtimeVersion
)
}.getOrElse { // Fallback to disabled in case something went wrong during hash generation
Log.w("MetadataShortenerService", "Failed to generate metadata hash", it)
MetadataProof(
checkMetadataHash = CheckMetadataHashMode.Disabled,
usedVersion = runtimeVersion
)
}
}
private suspend fun generateMetadataHash(
chain: Chain,
runtimeVersion: RuntimeVersionFull
): ByteArray {
val rawMetadata = chainRegistry.getRawMetadata(chain.id)
val mainAsset = chain.utilityAsset
return MetadataShortener.generate_metadata_digest(
rawMetadata.metadataContent,
runtimeVersion.specVersion,
runtimeVersion.specName,
chain.addressPrefix,
mainAsset.precision.value,
mainAsset.symbol.value
)
}
private class MetadataProofCache {
// Quote simple synchronization - we block all chains
// Shouldn't be a problem at the moment since we don't expect a lot of multi-chain txs/fee estimations to happen at once
private val cacheAccessMutex: Mutex = Mutex()
private val cache = mutableMapOf<ChainId, MetadataProof>()
suspend fun getOrCompute(
chainId: ChainId,
expectedSpecVersion: Int,
compute: suspend () -> MetadataProof
): MetadataProof = cacheAccessMutex.withLock {
val cachedProof = cache[chainId]
if (cachedProof == null || cachedProof.usedVersion.specVersion != expectedSpecVersion) {
val newValue = compute()
cache[chainId] = newValue
newValue
} else {
cachedProof
}
}
}
private fun shouldCalculateMetadataHash(runtimeMetadata: RuntimeMetadata, chain: Chain): Boolean {
val disabledByConfig = chain.additional.shouldDisableMetadataHashCheck()
val canBeEnabled = disabledByConfig.not()
val atLeastMinimumVersion = runtimeMetadata.metadataVersion >= MINIMUM_METADATA_VERSION_TO_CALCULATE_HASH
val hasSignedExtension = runtimeMetadata.extrinsic.hasSignedExtension(DefaultSignedExtensions.CHECK_METADATA_HASH)
Log.d(
"MetadataShortenerService",
"Chain: ${chain.name}, disabledByConfig=$disabledByConfig, canBeEnabled=$canBeEnabled, " +
"atLeastMinimumVersion=$atLeastMinimumVersion, hasSignedExtension=$hasSignedExtension"
)
Log.d("MetadataShortenerService", "chain.additional: ${chain.additional}, disabledCheckMetadataHash=${chain.additional?.disabledCheckMetadataHash}")
val result = canBeEnabled && atLeastMinimumVersion && hasSignedExtension
Log.d("MetadataShortenerService", "shouldCalculateMetadataHash result: $result (will use ${if (result) "ENABLED" else "DISABLED"} mode)")
return result
}
}
@@ -0,0 +1,33 @@
package io.novafoundation.nova.runtime.extrinsic.multi
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.metadata.call
import io.novasama.substrate_sdk_android.runtime.metadata.module
interface CallBuilder {
val runtime: RuntimeSnapshot
val calls: List<GenericCall.Instance>
fun addCall(
moduleName: String,
callName: String,
arguments: Map<String, Any?>
): CallBuilder
}
class SimpleCallBuilder(override val runtime: RuntimeSnapshot) : CallBuilder {
override val calls = mutableListOf<GenericCall.Instance>()
override fun addCall(moduleName: String, callName: String, arguments: Map<String, Any?>): CallBuilder {
val module = runtime.metadata.module(moduleName)
val function = module.call(callName)
calls.add(GenericCall.Instance(module, function, arguments))
return this
}
}
@@ -0,0 +1,315 @@
package io.novafoundation.nova.runtime.extrinsic.signer
import java.math.BigInteger
import java.nio.ByteBuffer
import java.nio.ByteOrder
/**
* SR25519 signing implementation for PezkuwiChain using "bizinikiwi" signing context.
*
* This is a port of @pezkuwi/scure-sr25519 which uses Merlin transcripts
* (built on Strobe128) with a custom signing context.
*
* Standard Substrate uses "substrate" context, but Pezkuwi uses "bizinikiwi".
*/
object BizinikiwSr25519Signer {
private val BIZINIKIWI_CONTEXT = "bizinikiwi".toByteArray(Charsets.UTF_8)
// Ed25519 curve order
private val CURVE_ORDER = BigInteger("7237005577332262213973186563042994240857116359379907606001950938285454250989")
// Strobe128 constants
private const val STROBE_R = 166
/**
* Sign a message using SR25519 with "bizinikiwi" context.
*
* @param secretKey 64-byte secret key (32-byte scalar + 32-byte nonce)
* @param message The message to sign
* @return 64-byte signature with Schnorrkel marker
*/
fun sign(secretKey: ByteArray, message: ByteArray): ByteArray {
require(secretKey.size == 64) { "Secret key must be 64 bytes" }
// Create signing transcript
val transcript = SigningContext("SigningContext")
transcript.label(BIZINIKIWI_CONTEXT)
transcript.bytes(message)
// Extract key components
val keyScalar = decodeScalar(secretKey.copyOfRange(0, 32))
val nonce = secretKey.copyOfRange(32, 64)
val publicKey = getPublicKey(secretKey)
val pubPoint = RistrettoPoint.fromBytes(publicKey)
// Schnorrkel signing protocol
transcript.protoName("Schnorr-sig")
transcript.commitPoint("sign:pk", pubPoint)
val r = transcript.witnessScalar("signing", nonce)
val R = RistrettoPoint.BASE.multiply(r)
transcript.commitPoint("sign:R", R)
val k = transcript.challengeScalar("sign:c")
val s = (k.multiply(keyScalar).add(r)).mod(CURVE_ORDER)
// Build signature
val signature = ByteArray(64)
System.arraycopy(R.toBytes(), 0, signature, 0, 32)
System.arraycopy(scalarToBytes(s), 0, signature, 32, 32)
// Add Schnorrkel marker
signature[63] = (signature[63].toInt() or 0x80).toByte()
return signature
}
/**
* Get public key from secret key.
*/
fun getPublicKey(secretKey: ByteArray): ByteArray {
require(secretKey.size == 64) { "Secret key must be 64 bytes" }
val scalar = decodeScalar(secretKey.copyOfRange(0, 32))
return RistrettoPoint.BASE.multiply(scalar).toBytes()
}
/**
* Verify a signature.
*/
fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean {
require(signature.size == 64) { "Signature must be 64 bytes" }
require(publicKey.size == 32) { "Public key must be 32 bytes" }
// Check Schnorrkel marker
if ((signature[63].toInt() and 0x80) == 0) {
return false
}
// Extract R and s from signature
val sBytes = signature.copyOfRange(32, 64)
sBytes[31] = (sBytes[31].toInt() and 0x7F).toByte() // Remove marker
val R = RistrettoPoint.fromBytes(signature.copyOfRange(0, 32))
val s = bytesToScalar(sBytes)
// Reconstruct transcript
val transcript = SigningContext("SigningContext")
transcript.label(BIZINIKIWI_CONTEXT)
transcript.bytes(message)
val pubPoint = RistrettoPoint.fromBytes(publicKey)
if (pubPoint.isZero()) return false
transcript.protoName("Schnorr-sig")
transcript.commitPoint("sign:pk", pubPoint)
transcript.commitPoint("sign:R", R)
val k = transcript.challengeScalar("sign:c")
// Verify: R + k*P == s*G
val left = R.add(pubPoint.multiply(k))
val right = RistrettoPoint.BASE.multiply(s)
return left == right
}
private fun decodeScalar(bytes: ByteArray): BigInteger {
require(bytes.size == 32) { "Scalar must be 32 bytes" }
// Little-endian
val reversed = bytes.reversedArray()
return BigInteger(1, reversed).mod(CURVE_ORDER)
}
private fun bytesToScalar(bytes: ByteArray): BigInteger {
val reversed = bytes.reversedArray()
return BigInteger(1, reversed).mod(CURVE_ORDER)
}
private fun scalarToBytes(scalar: BigInteger): ByteArray {
val bytes = scalar.toByteArray()
val result = ByteArray(32)
// Handle sign byte and padding
val start = if (bytes[0] == 0.toByte() && bytes.size > 32) 1 else 0
val length = minOf(bytes.size - start, 32)
val offset = 32 - length
System.arraycopy(bytes, start, result, offset, length)
// Convert to little-endian
return result.reversedArray()
}
/**
* Strobe128 implementation for Merlin transcripts.
*/
private class Strobe128(protocolLabel: String) {
private val state = ByteArray(200)
private var pos = 0
private var posBegin = 0
private var curFlags = 0
init {
// Initialize state
state[0] = 1
state[1] = (STROBE_R + 2).toByte()
state[2] = 1
state[3] = 0
state[4] = 1
state[5] = 96
val strobeVersion = "STROBEv1.0.2".toByteArray(Charsets.UTF_8)
System.arraycopy(strobeVersion, 0, state, 6, strobeVersion.size)
keccakF1600()
metaAD(protocolLabel.toByteArray(Charsets.UTF_8), false)
}
private fun keccakF1600() {
// Keccak-f[1600] permutation
// Using BouncyCastle's Keccak implementation would be more efficient,
// but for now we'll use a simplified version
val keccak = org.bouncycastle.crypto.digests.KeccakDigest(1600)
keccak.update(state, 0, state.size)
// This is a placeholder - need proper Keccak-p implementation
}
fun metaAD(data: ByteArray, more: Boolean) {
absorb(data)
}
fun AD(data: ByteArray, more: Boolean) {
absorb(data)
}
fun PRF(length: Int): ByteArray {
val result = ByteArray(length)
squeeze(result)
return result
}
private fun absorb(data: ByteArray) {
for (byte in data) {
state[pos] = (state[pos].toInt() xor byte.toInt()).toByte()
pos++
if (pos == STROBE_R) {
runF()
}
}
}
private fun squeeze(out: ByteArray) {
for (i in out.indices) {
out[i] = state[pos]
state[pos] = 0
pos++
if (pos == STROBE_R) {
runF()
}
}
}
private fun runF() {
state[pos] = (state[pos].toInt() xor posBegin).toByte()
state[pos + 1] = (state[pos + 1].toInt() xor 0x04).toByte()
state[STROBE_R + 1] = (state[STROBE_R + 1].toInt() xor 0x80).toByte()
keccakF1600()
pos = 0
posBegin = 0
}
}
/**
* Merlin signing context/transcript.
*/
private class SigningContext(label: String) {
private val strobe = Strobe128(label)
fun label(data: ByteArray) {
val lengthBytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(data.size).array()
strobe.metaAD(lengthBytes, false)
strobe.metaAD(data, true)
}
fun bytes(data: ByteArray) {
val lengthBytes = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(data.size).array()
strobe.metaAD(lengthBytes, false)
strobe.AD(data, false)
}
fun protoName(name: String) {
val data = name.toByteArray(Charsets.UTF_8)
strobe.metaAD(data, false)
}
fun commitPoint(label: String, point: RistrettoPoint) {
strobe.metaAD(label.toByteArray(Charsets.UTF_8), false)
strobe.AD(point.toBytes(), false)
}
fun witnessScalar(label: String, nonce: ByteArray): BigInteger {
strobe.metaAD(label.toByteArray(Charsets.UTF_8), false)
strobe.AD(nonce, false)
val bytes = strobe.PRF(64)
return bytesToWideScalar(bytes)
}
fun challengeScalar(label: String): BigInteger {
strobe.metaAD(label.toByteArray(Charsets.UTF_8), false)
val bytes = strobe.PRF(64)
return bytesToWideScalar(bytes)
}
private fun bytesToWideScalar(bytes: ByteArray): BigInteger {
// Reduce 64 bytes to a scalar modulo curve order
val reversed = bytes.reversedArray()
return BigInteger(1, reversed).mod(CURVE_ORDER)
}
}
/**
* Ristretto255 point operations.
* This is a placeholder - full implementation requires Ed25519 curve math.
*/
private class RistrettoPoint private constructor(private val bytes: ByteArray) {
companion object {
val BASE: RistrettoPoint by lazy {
// Ed25519 base point in Ristretto encoding
val baseBytes = ByteArray(32)
// TODO: Set proper base point bytes
RistrettoPoint(baseBytes)
}
fun fromBytes(bytes: ByteArray): RistrettoPoint {
require(bytes.size == 32) { "Point must be 32 bytes" }
return RistrettoPoint(bytes.copyOf())
}
}
fun toBytes(): ByteArray = bytes.copyOf()
fun multiply(scalar: BigInteger): RistrettoPoint {
// TODO: Implement scalar multiplication
return this
}
fun add(other: RistrettoPoint): RistrettoPoint {
// TODO: Implement point addition
return this
}
fun isZero(): Boolean {
return bytes.all { it == 0.toByte() }
}
override fun equals(other: Any?): Boolean {
if (other !is RistrettoPoint) return false
return bytes.contentEquals(other.bytes)
}
override fun hashCode(): Int = bytes.contentHashCode()
}
}
@@ -0,0 +1,90 @@
package io.novafoundation.nova.runtime.extrinsic.signer
import android.util.Log
import io.novafoundation.nova.sr25519.BizinikiwSr25519
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.InheritedImplication
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.extensions.verifySignature.GeneralTransactionSigner
import io.novasama.substrate_sdk_android.runtime.extrinsic.v5.transactionExtension.signingPayload
/**
* SR25519 signer for Pezkuwi chains using "bizinikiwi" signing context.
*
* This signer is used instead of the standard KeyPairSigner for Pezkuwi ecosystem chains
* (Pezkuwi, Pezkuwi Asset Hub, Pezkuwi People) which require signatures with "bizinikiwi"
* context instead of the standard "substrate" context used by Polkadot ecosystem chains.
*/
class PezkuwiKeyPairSigner private constructor(
private val secretKey: ByteArray,
private val publicKey: ByteArray
) : GeneralTransactionSigner {
companion object {
/**
* Create a PezkuwiKeyPairSigner from a 32-byte seed.
* The seed is expanded to a full keypair using BizinikiwSr25519.
*/
fun fromSeed(seed: ByteArray): PezkuwiKeyPairSigner {
require(seed.size == 32) { "Seed must be 32 bytes, got ${seed.size}" }
Log.d("PezkuwiSigner", "Creating signer from seed")
// Expand seed to 96-byte keypair
val expandedKeypair = BizinikiwSr25519.keypairFromSeed(seed)
Log.d("PezkuwiSigner", "Expanded keypair size: ${expandedKeypair.size}")
// Extract 64-byte secret key and 32-byte public key
val secretKey = BizinikiwSr25519.secretKeyFromKeypair(expandedKeypair)
val publicKey = BizinikiwSr25519.publicKeyFromKeypair(expandedKeypair)
Log.d("PezkuwiSigner", "Secret key size: ${secretKey.size}")
Log.d("PezkuwiSigner", "Public key: ${publicKey.toHex()}")
return PezkuwiKeyPairSigner(secretKey, publicKey)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
}
override suspend fun signInheritedImplication(
inheritedImplication: InheritedImplication,
accountId: AccountId
): SignatureWrapper {
val payload = inheritedImplication.signingPayload()
Log.d("PezkuwiSigner", "=== SIGNING WITH BIZINIKIWI ===")
Log.d("PezkuwiSigner", "Payload size: ${payload.size}")
Log.d("PezkuwiSigner", "Payload: ${payload.toHex()}")
// Use BizinikiwSr25519 native library with "bizinikiwi" signing context
val signature = BizinikiwSr25519.sign(
publicKey = publicKey,
secretKey = secretKey,
message = payload
)
Log.d("PezkuwiSigner", "Signature: ${signature.toHex()}")
// Verify locally
val verified = BizinikiwSr25519.verify(signature, payload, publicKey)
Log.d("PezkuwiSigner", "Local verification: $verified")
return SignatureWrapper.Sr25519(signature)
}
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
suspend fun signRaw(payload: SignerPayloadRaw): SignedRaw {
// Use BizinikiwSr25519 native library with "bizinikiwi" signing context
val signature = BizinikiwSr25519.sign(
publicKey = publicKey,
secretKey = secretKey,
message = payload.message
)
return SignedRaw(payload, SignatureWrapper.Sr25519(signature))
}
}
@@ -0,0 +1,19 @@
package io.novafoundation.nova.runtime.extrinsic.signer
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
class SignerPayloadRawWithChain(
val chainId: ChainId,
val message: ByteArray,
val accountId: AccountId,
)
fun SignerPayloadRawWithChain.withoutChain(): SignerPayloadRaw {
return SignerPayloadRaw(message, accountId)
}
fun SignerPayloadRaw.withChain(chainId: ChainId): SignerPayloadRawWithChain {
return SignerPayloadRawWithChain(chainId = chainId, message = message, accountId = accountId)
}
@@ -0,0 +1,26 @@
package io.novafoundation.nova.runtime.extrinsic.visitor
import android.util.Log
internal interface ExtrinsicVisitorLogger {
fun info(message: String)
fun error(message: String)
}
internal class IndentVisitorLogger(
private val tag: String = "ExtrinsicVisitor",
private val indent: Int = 0
) : ExtrinsicVisitorLogger {
private val indentPrefix = " ".repeat(indent)
override fun info(message: String) {
Log.d(tag, indentPrefix + message)
}
override fun error(message: String) {
Log.e(tag, indentPrefix + message)
}
}
@@ -0,0 +1,29 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.api
import io.novafoundation.nova.common.address.AccountIdKey
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
interface CallTraversal {
fun traverse(
source: GenericCall.Instance,
initialOrigin: AccountIdKey,
visitor: CallVisitor
)
}
fun interface CallVisitor {
fun visit(visit: CallVisit)
}
fun CallTraversal.collect(
source: GenericCall.Instance,
initialOrigin: AccountIdKey,
): List<CallVisit> {
return buildList {
traverse(source, initialOrigin) {
add(it)
}
}
}
@@ -0,0 +1,57 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.api
import io.novafoundation.nova.common.address.AccountIdKey
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
interface CallVisit {
/**
* Call that is currently visiting
*/
val call: GenericCall.Instance
/**
* Origin's account id that this call has been dispatched with
*/
val callOrigin: AccountIdKey
}
class LeafCallVisit(
override val call: GenericCall.Instance,
override val callOrigin: AccountIdKey
) : CallVisit
val CallVisit.isLeaf: Boolean
get() = this is LeafCallVisit
interface BatchCallVisit : CallVisit {
val batchedCalls: List<GenericCall.Instance>
}
interface MultisigCallVisit : CallVisit {
val signatory: AccountIdKey
get() = callOrigin
val otherSignatories: List<AccountIdKey>
val threshold: Int
val multisig: AccountIdKey
val nestedCall: GenericCall.Instance
}
interface ProxyCallVisit : CallVisit {
val proxy: AccountIdKey
get() = callOrigin
val proxied: AccountIdKey
val nestedCall: GenericCall.Instance
}
fun CallVisit.requireLeafOrNull(): LeafCallVisit? {
return this as? LeafCallVisit
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal interface NestedCallVisitNode {
fun canVisit(call: GenericCall.Instance): Boolean
fun visit(call: GenericCall.Instance, context: CallVisitingContext)
}
internal interface CallVisitingContext {
val origin: AccountIdKey
val logger: ExtrinsicVisitorLogger
/**
* Request parent to perform recursive visit of the given call
*/
fun nestedVisit(visit: NestedCallVisit)
/**
* Call the supplied visitor with the given argument
*/
fun visit(visit: CallVisit)
}
internal fun CallVisitingContext.nestedVisit(call: GenericCall.Instance, origin: AccountIdKey) {
nestedVisit(NestedCallVisit(call, origin))
}
/**
* Version of [CallVisit] intended for nested usage
*
* @see [CallVisit]
*/
internal class NestedCallVisit(
val call: GenericCall.Instance,
val origin: AccountIdKey
)
@@ -0,0 +1,100 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger
import io.novafoundation.nova.runtime.extrinsic.visitor.IndentVisitorLogger
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallTraversal
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.CallVisitor
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.LeafCallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch.BatchAllCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch.BatchCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch.ForceBatchCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.multisig.MultisigCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.proxy.ProxyCallNode
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress
internal class RealCallTraversal(
private val knownNodes: List<NestedCallVisitNode> = defaultNodes(),
) : CallTraversal {
companion object {
fun defaultNodes(): List<NestedCallVisitNode> = listOf(
BatchCallNode(),
BatchAllCallNode(),
ForceBatchCallNode(),
ProxyCallNode(),
MultisigCallNode()
)
}
override fun traverse(
source: GenericCall.Instance,
initialOrigin: AccountIdKey,
visitor: CallVisitor
) {
val rootVisit = NestedCallVisit(
call = source,
origin = initialOrigin
)
nestedVisit(visitor, rootVisit)
}
private fun nestedVisit(
visitor: CallVisitor,
visitedCall: NestedCallVisit
) {
val nestedNode = findNestedNode(visitedCall.call)
if (nestedNode == null) {
val publicVisit = visitedCall.toLeafVisit()
val call = visitedCall.call
val display = "${call.module.name}.${call.function.name}"
val origin = visitedCall.origin
val newLogger = IndentVisitorLogger()
newLogger.info("Visited leaf: $display, origin: ${origin.value.toAddress(42)}")
visitor.visit(publicVisit)
} else {
val newLogger = IndentVisitorLogger()
val context = RealCallVisitingContext(
origin = visitedCall.origin,
visitor = visitor,
logger = newLogger,
)
nestedNode.visit(visitedCall.call, context)
}
}
private fun findNestedNode(call: GenericCall.Instance): NestedCallVisitNode? {
return knownNodes.find { it.canVisit(call) }
}
private fun NestedCallVisit.toLeafVisit(): CallVisit {
return LeafCallVisit(call, origin)
}
private inner class RealCallVisitingContext(
override val origin: AccountIdKey,
override val logger: ExtrinsicVisitorLogger,
private val visitor: CallVisitor
) : CallVisitingContext {
override fun nestedVisit(visit: NestedCallVisit) {
return this@RealCallTraversal.nestedVisit(visitor, visit)
}
override fun visit(visit: CallVisit) {
visitor.visit(visit)
}
}
}
@@ -0,0 +1,32 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCallList
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.BatchCallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.CallVisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisitNode
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal abstract class BaseBatchNode : NestedCallVisitNode {
override fun visit(call: GenericCall.Instance, context: CallVisitingContext) {
val innerCalls = bindGenericCallList(call.arguments["calls"])
context.logger.info("Visiting ${this::class.simpleName} with ${innerCalls.size} inner calls")
val batchVisit = RealBatchCallVisit(call, innerCalls, context.origin)
context.visit(batchVisit)
innerCalls.forEach { inner ->
val nestedVisit = NestedCallVisit(call = inner, origin = context.origin)
context.nestedVisit(nestedVisit)
}
}
private class RealBatchCallVisit(
override val call: GenericCall.Instance,
override val batchedCalls: List<GenericCall.Instance>,
override val callOrigin: AccountIdKey
) : BatchCallVisit
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch
import io.novafoundation.nova.common.utils.Modules
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class BatchAllCallNode : BaseBatchNode() {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.UTILITY && call.function.name == "batch_all"
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch
import io.novafoundation.nova.common.utils.Modules
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class BatchCallNode : BaseBatchNode() {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.UTILITY && call.function.name == "batch"
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.batch
import io.novafoundation.nova.common.utils.Modules
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class ForceBatchCallNode : BaseBatchNode() {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.UTILITY && call.function.name == "force_batch"
}
}
@@ -0,0 +1,70 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.multisig
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall
import io.novafoundation.nova.common.data.network.runtime.binding.bindInt
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.MultisigCallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.CallVisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisitNode
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nestedVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.generateMultisigAddress
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class MultisigCallNode : NestedCallVisitNode {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.MULTISIG && call.function.name == "as_multi"
}
override fun visit(call: GenericCall.Instance, context: CallVisitingContext) {
context.logger.info("Visiting multisig")
val innerOriginInfo = extractMultisigOriginInfo(call, context.origin)
val innerCall = extractInnerMultisigCall(call)
val multisigVisit = RealMultisigCallVisit(
call = call,
callOrigin = context.origin,
otherSignatories = innerOriginInfo.otherSignatories,
threshold = innerOriginInfo.threshold,
nestedCall = innerCall
)
context.visit(multisigVisit)
context.nestedVisit(multisigVisit.nestedCall, multisigVisit.multisig)
}
private fun extractInnerMultisigCall(multisigCall: GenericCall.Instance): GenericCall.Instance {
return bindGenericCall(multisigCall.arguments["call"])
}
private fun extractMultisigOriginInfo(call: GenericCall.Instance, parentOrigin: AccountIdKey): MultisigOriginInfo {
val threshold = bindInt(call.arguments["threshold"])
val otherSignatories = bindList(call.arguments["other_signatories"], ::bindAccountIdKey)
return MultisigOriginInfo(threshold, otherSignatories)
}
private class MultisigOriginInfo(
val threshold: Int,
val otherSignatories: List<AccountIdKey>,
)
private class RealMultisigCallVisit(
override val call: GenericCall.Instance,
override val callOrigin: AccountIdKey,
override val otherSignatories: List<AccountIdKey>,
override val threshold: Int,
override val nestedCall: GenericCall.Instance,
) : MultisigCallVisit {
override val multisig: AccountIdKey = generateMultisigAddress(
signatory = callOrigin,
otherSignatories = otherSignatories,
threshold = threshold
)
}
}
@@ -0,0 +1,50 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nodes.proxy
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.runtime.extrinsic.visitor.call.api.ProxyCallVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.CallVisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.NestedCallVisitNode
import io.novafoundation.nova.runtime.extrinsic.visitor.call.impl.nestedVisit
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class ProxyCallNode : NestedCallVisitNode {
private val proxyCalls = arrayOf("proxy", "proxyAnnounced")
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.PROXY && call.function.name in proxyCalls
}
override fun visit(call: GenericCall.Instance, context: CallVisitingContext) {
context.logger.info("Visiting proxy")
val proxyVisit = RealProxyVisit(
call = call,
proxied = innerOrigin(call),
nestedCall = innerCall(call),
callOrigin = context.origin
)
context.visit(proxyVisit)
context.nestedVisit(proxyVisit.nestedCall, proxyVisit.proxied)
}
private fun innerOrigin(proxyCall: GenericCall.Instance): AccountIdKey {
return bindAccountIdentifier(proxyCall.arguments["real"]).intoKey()
}
private fun innerCall(proxyCall: GenericCall.Instance): GenericCall.Instance {
return bindGenericCall(proxyCall.arguments["call"])
}
private class RealProxyVisit(
override val call: GenericCall.Instance,
override val proxied: AccountIdKey,
override val nestedCall: GenericCall.Instance,
override val callOrigin: AccountIdKey
) : ProxyCallVisit
}
@@ -0,0 +1,55 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
interface ExtrinsicWalk {
suspend fun walk(
source: ExtrinsicWithEvents,
chainId: ChainId,
visitor: ExtrinsicVisitor
)
}
fun interface ExtrinsicVisitor {
fun visit(visit: ExtrinsicVisit)
}
class ExtrinsicVisit(
/**
* Whole extrinsic object. Useful for accessing data outside if the current visit scope, e.g. some top-level events
*/
val rootExtrinsic: ExtrinsicWithEvents,
/**
* Call that is currently visiting
*/
val call: GenericCall.Instance,
/**
* Whether call succeeded or not.
* Call is considered successful when it succeeds itself as well as its outer parents succeeds
*/
val success: Boolean,
/**
* All events that are related to this specific call
*/
val events: List<GenericEvent.Instance>,
/**
* Origin's account id that this call has been dispatched with
*/
val origin: AccountId,
/**
* Whether this visit is related to a registered node or not
*/
val hasRegisteredNode: Boolean = false
)
@@ -0,0 +1,12 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents
suspend fun ExtrinsicWalk.walkToList(source: ExtrinsicWithEvents, chainId: ChainId): List<ExtrinsicVisit> {
return buildList {
walk(source, chainId) { visitedCall ->
add(visitedCall)
}
}
}
@@ -0,0 +1,140 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl
import io.novafoundation.nova.common.utils.instanceOf
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.metadata.module.Event
internal interface MutableEventQueue : EventQueue {
/**
* Removes last event matching one of eventTypes
*/
fun popFromEnd(vararg eventTypes: Event)
/**
* Takes and removes all events that go after last event matching one of eventTypes. If no matched event found,
* all available events are returned
*/
fun takeTail(vararg eventTypes: Event): List<GenericEvent.Instance>
/**
* Takes and removes all events that go after specified inclusive index
* @param endInclusive
*/
fun takeAllAfterInclusive(endInclusive: Int): List<GenericEvent.Instance>
/**
* Takes and removes last event matching one of eventTypes
*/
fun takeFromEnd(vararg eventTypes: Event): GenericEvent.Instance?
}
internal interface EventQueue {
fun all(): List<GenericEvent.Instance>
fun peekItemFromEnd(vararg eventTypes: Event, endExclusive: Int): EventWithIndex?
fun indexOfLast(vararg eventTypes: Event, endExclusive: Int): Int?
}
@Suppress("NOTHING_TO_INLINE")
internal inline fun EventQueue.peekItemFromEndOrThrow(vararg eventTypes: Event, endExclusive: Int): EventWithIndex {
return requireNotNull(peekItemFromEnd(*eventTypes, endExclusive = endExclusive)) {
"No required event found for types ${eventTypes.joinToString { it.name }}"
}
}
@Suppress("NOTHING_TO_INLINE")
internal inline fun MutableEventQueue.takeFromEndOrThrow(vararg eventTypes: Event): GenericEvent.Instance {
return requireNotNull(takeFromEnd(*eventTypes)) {
"No required event found for types ${eventTypes.joinToString { it.name }}"
}
}
@Suppress("NOTHING_TO_INLINE")
internal inline fun EventQueue.indexOfLastOrThrow(vararg eventTypes: Event, endExclusive: Int): Int {
return requireNotNull(indexOfLast(*eventTypes, endExclusive = endExclusive)) {
"No required event found for types ${eventTypes.joinToString { it.name }}"
}
}
data class EventWithIndex(val event: GenericEvent.Instance, val eventIndex: Int)
class RealEventQueue(event: List<GenericEvent.Instance>) : MutableEventQueue {
private val events: MutableList<GenericEvent.Instance> = event.toMutableList()
override fun all(): List<GenericEvent.Instance> {
return events
}
override fun peekItemFromEnd(vararg eventTypes: Event, endExclusive: Int): EventWithIndex? {
return findEventAndIndex(eventTypes, endExclusive)
}
override fun indexOfLast(vararg eventTypes: Event, endExclusive: Int): Int? {
return findEventAndIndex(eventTypes, endExclusive)?.eventIndex
}
override fun popFromEnd(vararg eventTypes: Event) {
takeFromEnd(*eventTypes)
}
override fun takeTail(vararg eventTypes: Event): List<GenericEvent.Instance> {
val eventWithIndex = this.findEventAndIndex(eventTypes)
return if (eventWithIndex != null) {
this.removeAllAfterExclusive(eventWithIndex.eventIndex)
} else {
this.removeAllAfterInclusive(0)
}
}
override fun takeAllAfterInclusive(endInclusive: Int): List<GenericEvent.Instance> {
return removeAllAfterInclusive(endInclusive)
}
override fun takeFromEnd(vararg eventTypes: Event): GenericEvent.Instance? {
return findEventAndIndex(eventTypes)?.let { (event, index) ->
removeAllAfterInclusive(index)
event
}
}
private fun findEventAndIndex(eventTypes: Array<out Event>, endExclusive: Int = this.events.size): EventWithIndex? {
val eventsQueue = this.events
val limit = endExclusive.coerceAtMost(eventsQueue.size)
for (i in (limit - 1) downTo 0) {
val nextEvent = eventsQueue[i]
eventTypes.forEach { event ->
if (nextEvent.instanceOf(event)) return EventWithIndex(nextEvent, i)
}
}
return null
}
private fun removeAllAfterInclusive(index: Int): List<GenericEvent.Instance> {
if (index > this.events.size) return emptyList()
val subList = this.events.subList(index, this.events.size)
val subListCopy = subList.toList()
subList.clear()
return subListCopy
}
private fun removeAllAfterExclusive(index: Int): List<GenericEvent.Instance> {
val subList = this.events.subList(index + 1, this.events.size)
val subListCopy = subList.toList()
subList.clear()
return subListCopy
}
}
@@ -0,0 +1,68 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl
import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
internal interface NestedCallNode {
fun canVisit(call: GenericCall.Instance): Boolean
/**
* Calculates exclusive end index that is needed to skip all internal events related to this nested call
* For example, utility.batch supposed to skip BatchCompleted/BatchInterrupted and all ItemCompleted events
* This function is used by `visit` to skip internal events of nested nodes of the same type (batch inside batch or proxy inside proxy)
* so they wont interfere
* Should not be called on failed nested calls since they emit no events and its trivial to proceed
*/
fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int
fun visit(call: GenericCall.Instance, context: VisitingContext)
}
internal interface VisitingContext {
val rootExtrinsic: ExtrinsicWithEvents
val runtime: RuntimeSnapshot
val origin: AccountId
val callSucceeded: Boolean
val logger: ExtrinsicVisitorLogger
val eventQueue: MutableEventQueue
fun nestedVisit(visit: NestedExtrinsicVisit)
fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance): Int
}
/**
* Version of [ExtrinsicVisit] intended for nested usage
*
* @see [ExtrinsicVisit]
*/
internal class NestedExtrinsicVisit(
val rootExtrinsic: ExtrinsicWithEvents,
val call: GenericCall.Instance,
val success: Boolean,
val events: List<GenericEvent.Instance>,
val origin: AccountId,
)
internal interface EventCountingContext {
val runtime: RuntimeSnapshot
val eventQueue: EventQueue
val endExclusive: Int
fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, endExclusive: Int): Int
}
@@ -0,0 +1,153 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl
import io.novafoundation.nova.runtime.extrinsic.visitor.ExtrinsicVisitorLogger
import io.novafoundation.nova.runtime.extrinsic.visitor.IndentVisitorLogger
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicVisitor
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.api.ExtrinsicWalk
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch.BatchAllNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch.ForceBatchNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig.MultisigNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.proxy.ProxyNode
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.ExtrinsicWithEvents
import io.novafoundation.nova.runtime.multiNetwork.runtime.repository.isSuccess
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.signer
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAddress
internal class RealExtrinsicWalk(
private val chainRegistry: ChainRegistry,
private val knownNodes: List<NestedCallNode> = defaultNodes(),
) : ExtrinsicWalk {
companion object {
fun defaultNodes() = listOf(
ProxyNode(),
BatchAllNode(),
ForceBatchNode(),
MultisigNode()
)
}
override suspend fun walk(
source: ExtrinsicWithEvents,
chainId: ChainId,
visitor: ExtrinsicVisitor
) {
val runtime = chainRegistry.getRuntime(chainId)
val rootVisit = NestedExtrinsicVisit(
rootExtrinsic = source,
call = source.extrinsic.call,
success = source.isSuccess(),
events = source.events,
origin = source.extrinsic.signer() ?: error("Unsigned extrinsic"),
)
nestedVisit(runtime, visitor, rootVisit, depth = 0)
}
private fun nestedVisit(
runtime: RuntimeSnapshot,
visitor: ExtrinsicVisitor,
visitedCall: NestedExtrinsicVisit,
depth: Int,
) {
val nestedNode = findNestedNode(visitedCall.call)
val publicVisit = visitedCall.toVisit(hasRegisteredNode = nestedNode != null)
visitor.visit(publicVisit)
if (nestedNode == null) {
val call = visitedCall.call
val display = "${call.module.name}.${call.function.name}"
val origin = visitedCall.origin
val newLogger = IndentVisitorLogger(indent = depth + 1)
newLogger.info("Visited leaf: $display, success: ${visitedCall.success}, origin: ${origin.toAddress(42)}")
} else {
val eventQueue = RealEventQueue(visitedCall.events)
val newLogger = IndentVisitorLogger(indent = depth)
val context = RealVisitingContext(
rootExtrinsic = visitedCall.rootExtrinsic,
eventsSize = visitedCall.events.size,
depth = depth,
runtime = runtime,
origin = visitedCall.origin,
callSucceeded = visitedCall.success,
visitor = visitor,
logger = newLogger,
eventQueue = eventQueue
)
nestedNode.visit(visitedCall.call, context)
}
}
private fun endExclusiveToSkipInternalEvents(
runtime: RuntimeSnapshot,
call: GenericCall.Instance,
eventQueue: EventQueue,
endExclusive: Int,
): Int {
val nestedNode = this.findNestedNode(call)
return if (nestedNode != null) {
val context: EventCountingContext = RealEventCountingContext(runtime, eventQueue, endExclusive)
nestedNode.endExclusiveToSkipInternalEvents(call, context)
} else {
// no internal events to skip since its a leaf
endExclusive
}
}
private fun findNestedNode(call: GenericCall.Instance): NestedCallNode? {
return knownNodes.find { it.canVisit(call) }
}
private fun NestedExtrinsicVisit.toVisit(hasRegisteredNode: Boolean): ExtrinsicVisit {
return ExtrinsicVisit(rootExtrinsic, call, success, events, origin, hasRegisteredNode)
}
private inner class RealVisitingContext(
private val eventsSize: Int,
private val depth: Int,
override val rootExtrinsic: ExtrinsicWithEvents,
override val runtime: RuntimeSnapshot,
override val origin: AccountId,
override val callSucceeded: Boolean,
override val logger: ExtrinsicVisitorLogger,
override val eventQueue: MutableEventQueue,
private val visitor: ExtrinsicVisitor
) : VisitingContext {
override fun nestedVisit(visit: NestedExtrinsicVisit) {
return this@RealExtrinsicWalk.nestedVisit(runtime, visitor, visit, depth + 1)
}
override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance): Int {
return this@RealExtrinsicWalk.endExclusiveToSkipInternalEvents(runtime, call, eventQueue, endExclusive = eventsSize)
}
}
private inner class RealEventCountingContext(
override val runtime: RuntimeSnapshot,
override val eventQueue: EventQueue,
override val endExclusive: Int
) : EventCountingContext {
override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, endExclusive: Int): Int {
return this@RealExtrinsicWalk.endExclusiveToSkipInternalEvents(runtime, call, eventQueue, endExclusive)
}
}
}
@@ -0,0 +1,77 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCallList
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.indexOfLastOrThrow
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class BatchAllNode : NestedCallNode {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.UTILITY && call.function.name == "batch_all"
}
override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int {
val innerCalls = bindGenericCallList(call.arguments["calls"])
val batchCompletedEventType = context.runtime.batchCompletedEvent()
val itemCompletedEventType = context.runtime.itemCompletedEvent()
var endExclusive = context.endExclusive
// Safe since `endExclusiveToSkipInternalEvents` should not be called on failed items
val indexOfCompletedEvent = context.eventQueue.indexOfLastOrThrow(batchCompletedEventType, endExclusive = endExclusive)
endExclusive = indexOfCompletedEvent
innerCalls.reversed().forEach { innerCall ->
val itemIdx = context.eventQueue.indexOfLastOrThrow(itemCompletedEventType, endExclusive = endExclusive)
endExclusive = context.endExclusiveToSkipInternalEvents(innerCall, itemIdx)
}
return endExclusive
}
override fun visit(call: GenericCall.Instance, context: VisitingContext) {
val innerCalls = bindGenericCallList(call.arguments["calls"])
val itemCompletedEventType = context.runtime.itemCompletedEvent()
context.logger.info("Visiting utility.batchAll with ${innerCalls.size} inner calls")
if (context.callSucceeded) {
context.logger.info("BatchAll succeeded")
} else {
context.logger.info("BatchAll failed")
}
val subItemsToVisit = innerCalls.reversed().map { innerCall ->
if (context.callSucceeded) {
context.eventQueue.popFromEnd(itemCompletedEventType)
val alNestedEvents = context.takeCompletedBatchItemEvents(innerCall)
NestedExtrinsicVisit(
rootExtrinsic = context.rootExtrinsic,
call = innerCall,
success = true,
events = alNestedEvents,
origin = context.origin
)
} else {
NestedExtrinsicVisit(
rootExtrinsic = context.rootExtrinsic,
call = innerCall,
success = false,
events = emptyList(),
origin = context.origin
)
}
}
subItemsToVisit.forEach { subItem ->
context.nestedVisit(subItem)
}
}
}
@@ -0,0 +1,38 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch
import io.novafoundation.nova.common.utils.utility
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.metadata.event
import io.novasama.substrate_sdk_android.runtime.metadata.module.Event
internal fun RuntimeSnapshot.batchCompletedEvent(): Event {
return metadata.utility().event("BatchCompleted")
}
internal fun RuntimeSnapshot.batchCompletedWithErrorsEvent(): Event {
return metadata.utility().event("BatchCompletedWithErrors")
}
internal fun RuntimeSnapshot.itemCompletedEvent(): Event {
return metadata.utility().event("ItemCompleted")
}
internal fun RuntimeSnapshot.itemFailedEvent(): Event {
return metadata.utility().event("ItemFailed")
}
internal fun VisitingContext.takeCompletedBatchItemEvents(call: GenericCall.Instance): List<GenericEvent.Instance> {
val internalEventsEndExclusive = endExclusiveToSkipInternalEvents(call)
// internalEnd is exclusive => it holds index of last internal event
// thus, we delete them inclusively
val someOfNestedEvents = eventQueue.takeAllAfterInclusive(internalEventsEndExclusive)
// now it is safe to go until ItemCompleted\ItemFailed since we removed all potential nested events above
val remainingNestedEvents = eventQueue.takeTail(runtime.itemCompletedEvent(), runtime.itemFailedEvent())
return remainingNestedEvents + someOfNestedEvents
}
@@ -0,0 +1,99 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.batch
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCallList
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.indexOfLastOrThrow
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.peekItemFromEndOrThrow
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.takeFromEndOrThrow
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
internal class ForceBatchNode : NestedCallNode {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.UTILITY && call.function.name == "force_batch"
}
override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int {
val innerCalls = bindGenericCallList(call.arguments["calls"])
val batchCompletedEventType = context.runtime.batchCompletedEvent()
val batchCompletedWithErrorsType = context.runtime.batchCompletedWithErrorsEvent()
val itemCompletedEventType = context.runtime.itemCompletedEvent()
val itemFailedEventType = context.runtime.itemFailedEvent()
var endExclusive = context.endExclusive
// Safe since batch all always completes
val indexOfCompletedEvent = context.eventQueue.indexOfLastOrThrow(batchCompletedEventType, batchCompletedWithErrorsType, endExclusive = endExclusive)
endExclusive = indexOfCompletedEvent
innerCalls.reversed().forEach { innerCall ->
val (itemEvent, itemEventIdx) = context.eventQueue.peekItemFromEndOrThrow(itemCompletedEventType, itemFailedEventType, endExclusive = endExclusive)
endExclusive = if (itemEvent.instanceOf(itemCompletedEventType)) {
// only completed items emit nested events
context.endExclusiveToSkipInternalEvents(innerCall, itemEventIdx)
} else {
itemEventIdx
}
}
return endExclusive
}
override fun visit(call: GenericCall.Instance, context: VisitingContext) {
val innerCalls = bindGenericCallList(call.arguments["calls"])
val batchCompletedEventType = context.runtime.batchCompletedEvent()
val batchCompletedWithErrorsType = context.runtime.batchCompletedWithErrorsEvent()
val itemCompletedEventType = context.runtime.itemCompletedEvent()
val itemFailedEventType = context.runtime.itemFailedEvent()
context.logger.info("Visiting utility.forceBatch with ${innerCalls.size} inner calls")
if (context.callSucceeded) {
context.logger.info("ForceBatch succeeded")
context.eventQueue.popFromEnd(batchCompletedEventType, batchCompletedWithErrorsType)
} else {
context.logger.info("ForceBatch failed")
}
val subItemsToVisit = innerCalls.reversed().map { innerCall ->
if (context.callSucceeded) {
val itemEvent = context.eventQueue.takeFromEndOrThrow(itemCompletedEventType, itemFailedEventType)
if (itemEvent.instanceOf(itemCompletedEventType)) {
val allEvents = context.takeCompletedBatchItemEvents(innerCall)
return@map NestedExtrinsicVisit(
rootExtrinsic = context.rootExtrinsic,
call = innerCall,
success = true,
events = allEvents,
origin = context.origin
)
}
}
NestedExtrinsicVisit(
rootExtrinsic = context.rootExtrinsic,
call = innerCall,
success = false,
events = emptyList(),
origin = context.origin
)
}
subItemsToVisit.forEach { subItem ->
context.nestedVisit(subItem)
}
}
}
@@ -0,0 +1,45 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.utils.compareTo
import io.novafoundation.nova.common.utils.padEnd
import io.novasama.substrate_sdk_android.hash.Hasher.blake2b256
import io.novasama.substrate_sdk_android.runtime.definitions.types.useScaleWriter
import io.novasama.substrate_sdk_android.scale.utils.directWrite
private val PREFIX = "modlpy/utilisuba".encodeToByteArray()
fun generateMultisigAddress(
signatory: AccountIdKey,
otherSignatories: List<AccountIdKey>,
threshold: Int
) = generateMultisigAddress(otherSignatories + signatory, threshold)
fun generateMultisigAddress(
signatories: List<AccountIdKey>,
threshold: Int
): AccountIdKey {
val accountIdSize = signatories.first().value.size
val sortedAccounts = signatories.sortedWith { a, b -> a.value.compareTo(b.value, unsigned = true) }
val entropy = useScaleWriter {
directWrite(PREFIX)
writeCompact(sortedAccounts.size)
sortedAccounts.forEach {
directWrite(it.value)
}
writeUint16(threshold)
}.blake2b256()
val result = when {
entropy.size == accountIdSize -> entropy
entropy.size < accountIdSize -> entropy.padEnd(accountIdSize, 0)
else -> entropy.copyOf(accountIdSize)
}
return result.intoKey()
}
@@ -0,0 +1,140 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.multisig
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall
import io.novafoundation.nova.common.data.network.runtime.binding.bindInt
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.instanceOf
import io.novafoundation.nova.common.utils.multisig
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.peekItemFromEndOrThrow
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.takeFromEndOrThrow
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.metadata.event
import io.novasama.substrate_sdk_android.runtime.metadata.module.Event
internal class MultisigNode : NestedCallNode {
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.MULTISIG && call.function.name == "as_multi"
}
override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int {
var endExclusive = context.endExclusive
val completionEventTypes = context.runtime.multisigCompletionEvents()
val (completionEvent, completionIdx) = context.eventQueue.peekItemFromEndOrThrow(eventTypes = completionEventTypes, endExclusive = endExclusive)
endExclusive = completionIdx
if (completionEvent.isMultisigExecutionSucceeded()) {
val innerCall = this.extractInnerMultisigCall(call)
endExclusive = context.endExclusiveToSkipInternalEvents(innerCall, endExclusive)
}
return endExclusive
}
override fun visit(call: GenericCall.Instance, context: VisitingContext) {
if (!context.callSucceeded) {
visitFailedMultisigCall(call, context)
context.logger.info("asMulti - reverted by outer parent")
return
}
val completionEventTypes = context.runtime.multisigCompletionEvents()
val completionEvent = context.eventQueue.takeFromEndOrThrow(*completionEventTypes)
// Not visiting pending mst's
if (!completionEvent.isMultisigExecuted()) return
if (completionEvent.isMultisigExecutedOk()) {
context.logger.info("asMulti: execution succeeded")
visitSucceededMultisigCall(call, context)
} else {
context.logger.info("asMulti: execution failed")
this.visitFailedMultisigCall(call, context)
}
}
private fun visitFailedMultisigCall(call: GenericCall.Instance, context: VisitingContext) {
this.visitMultisigCall(call, context, success = false, events = emptyList())
}
private fun visitSucceededMultisigCall(call: GenericCall.Instance, context: VisitingContext) {
this.visitMultisigCall(call, context, success = true, events = context.eventQueue.all())
}
private fun visitMultisigCall(
call: GenericCall.Instance,
context: VisitingContext,
success: Boolean,
events: List<GenericEvent.Instance>
) {
val innerOrigin = extractMultisigOrigin(call, context.origin.intoKey())
val innerCall = extractInnerMultisigCall(call)
val visit = NestedExtrinsicVisit(
rootExtrinsic = context.rootExtrinsic,
call = innerCall,
success = success,
events = events,
origin = innerOrigin.value
)
context.nestedVisit(visit)
}
private fun GenericEvent.Instance.isMultisigExecutionSucceeded(): Boolean {
if (!isMultisigExecuted()) {
// not final execution
return false
}
return isMultisigExecutedOk()
}
private fun GenericEvent.Instance.isMultisigExecuted(): Boolean {
return instanceOf(Modules.MULTISIG, "MultisigExecuted")
}
private fun GenericEvent.Instance.isMultisigExecutedOk(): Boolean {
// dispatch_result in https://github.com/paritytech/polkadot-sdk/blob/fdf3d65e4c2d9094915c7fd7927e651339171edd/substrate/frame/multisig/src/lib.rs#L260
return arguments[4].castToDictEnum().name == "Ok"
}
private fun extractInnerMultisigCall(multisigCall: GenericCall.Instance): GenericCall.Instance {
return bindGenericCall(multisigCall.arguments["call"])
}
private fun RuntimeSnapshot.multisigCompletionEvents(): Array<Event> {
val multisig = metadata.multisig()
return arrayOf(
multisig.event("MultisigExecuted"),
multisig.event("MultisigApproval"),
multisig.event("NewMultisig"),
)
}
private fun extractMultisigOrigin(call: GenericCall.Instance, parentOrigin: AccountIdKey): AccountIdKey {
val threshold = bindInt(call.arguments["threshold"])
val otherSignatories = bindList(call.arguments["other_signatories"], ::bindAccountIdKey)
return generateMultisigAddress(
otherSignatories = otherSignatories,
signatory = parentOrigin,
threshold = threshold
)
}
}
@@ -0,0 +1,103 @@
package io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.nodes.proxy
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdentifier
import io.novafoundation.nova.common.data.network.runtime.binding.bindGenericCall
import io.novafoundation.nova.common.data.network.runtime.binding.castToDictEnum
import io.novafoundation.nova.common.utils.Modules
import io.novafoundation.nova.common.utils.proxy
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.EventCountingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedCallNode
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.NestedExtrinsicVisit
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.VisitingContext
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.peekItemFromEndOrThrow
import io.novafoundation.nova.runtime.extrinsic.visitor.extrinsic.impl.takeFromEndOrThrow
import io.novasama.substrate_sdk_android.runtime.AccountId
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericEvent
import io.novasama.substrate_sdk_android.runtime.metadata.event
import io.novasama.substrate_sdk_android.runtime.metadata.module.Event
internal class ProxyNode : NestedCallNode {
private val proxyCalls = arrayOf("proxy", "proxyAnnounced")
override fun canVisit(call: GenericCall.Instance): Boolean {
return call.module.name == Modules.PROXY && call.function.name in proxyCalls
}
override fun endExclusiveToSkipInternalEvents(call: GenericCall.Instance, context: EventCountingContext): Int {
var endExclusive = context.endExclusive
val completionEventType = context.runtime.proxyCompletionEvent()
val (completionEvent, completionIdx) = context.eventQueue.peekItemFromEndOrThrow(completionEventType, endExclusive = endExclusive)
endExclusive = completionIdx
if (completionEvent.isProxySucceeded()) {
val (innerCall) = this.callAndOriginFromProxy(call)
endExclusive = context.endExclusiveToSkipInternalEvents(innerCall, endExclusive)
}
return endExclusive
}
override fun visit(call: GenericCall.Instance, context: VisitingContext) {
if (!context.callSucceeded) {
this.visitFailedProxyCall(call, context)
context.logger.info("Proxy: reverted by outer parent")
return
}
val completionEventType = context.runtime.proxyCompletionEvent()
val completionEvent = context.eventQueue.takeFromEndOrThrow(completionEventType)
if (completionEvent.isProxySucceeded()) {
context.logger.info("Proxy: execution succeeded")
this.visitSucceededProxyCall(call, context)
} else {
context.logger.info("Proxy: execution failed")
this.visitFailedProxyCall(call, context)
}
}
private fun visitFailedProxyCall(call: GenericCall.Instance, context: VisitingContext) {
this.visitProxyCall(call, context, success = false, events = emptyList())
}
private fun visitSucceededProxyCall(call: GenericCall.Instance, context: VisitingContext) {
this.visitProxyCall(call, context, success = true, events = context.eventQueue.all())
}
private fun visitProxyCall(
call: GenericCall.Instance,
context: VisitingContext,
success: Boolean,
events: List<GenericEvent.Instance>
) {
val (innerCall, innerOrigin) = this.callAndOriginFromProxy(call)
val visit = NestedExtrinsicVisit(
rootExtrinsic = context.rootExtrinsic,
call = innerCall,
success = success,
events = events,
origin = innerOrigin
)
context.nestedVisit(visit)
}
private fun GenericEvent.Instance.isProxySucceeded(): Boolean {
return arguments.first().castToDictEnum().name == "Ok"
}
private fun callAndOriginFromProxy(proxyCall: GenericCall.Instance): Pair<GenericCall.Instance, AccountId> {
return bindGenericCall(proxyCall.arguments["call"]) to bindAccountIdentifier(proxyCall.arguments["real"])
}
private fun RuntimeSnapshot.proxyCompletionEvent(): Event {
return metadata.proxy().event("ProxyExecuted")
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.runtime.mapper
import io.novafoundation.nova.core_db.model.chain.ChainRuntimeInfoLocal
import io.novasama.substrate_sdk_android.wsrpc.request.runtime.chain.RuntimeVersion
fun ChainRuntimeInfoLocal.toRuntimeVersion(): RuntimeVersion? {
return RuntimeVersion(
specVersion = this.remoteVersion,
transactionVersion = this.transactionVersion ?: return null
)
}
@@ -0,0 +1,397 @@
package io.novafoundation.nova.runtime.multiNetwork
import android.util.Log
import com.google.gson.Gson
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.common.utils.RuntimeContext
import io.novafoundation.nova.common.utils.diffed
import io.novafoundation.nova.common.utils.filterList
import io.novafoundation.nova.common.utils.inBackground
import io.novafoundation.nova.common.utils.mapList
import io.novafoundation.nova.common.utils.mapNotNullToSet
import io.novafoundation.nova.common.utils.provideContext
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.core.ethereum.Web3Api
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal
import io.novafoundation.nova.runtime.ext.isDisabled
import io.novafoundation.nova.runtime.ext.isEnabled
import io.novafoundation.nova.runtime.ext.isFullSync
import io.novafoundation.nova.runtime.ext.level
import io.novafoundation.nova.runtime.ext.requiresBaseTypes
import io.novafoundation.nova.runtime.ext.typesUsage
import io.novafoundation.nova.runtime.multiNetwork.asset.EvmAssetsSyncService
import io.novafoundation.nova.runtime.multiNetwork.chain.ChainSyncService
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapChainLocalToChain
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapConnectionStateToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.ConnectionState
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain.Node.ConnectionType
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
import io.novafoundation.nova.runtime.multiNetwork.connection.ChainConnection
import io.novafoundation.nova.runtime.multiNetwork.connection.ConnectionPool
import io.novafoundation.nova.runtime.multiNetwork.connection.Web3ApiPool
import io.novafoundation.nova.runtime.multiNetwork.exception.DisabledChainException
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProvider
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeProviderPool
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSubscriptionPool
import io.novafoundation.nova.runtime.multiNetwork.runtime.RuntimeSyncService
import io.novafoundation.nova.runtime.multiNetwork.runtime.types.BaseTypeSynchronizer
import io.novasama.substrate_sdk_android.wsrpc.SocketService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch
data class ChainWithAsset(
val chain: Chain,
val asset: Chain.Asset
)
class ChainRegistry(
private val runtimeProviderPool: RuntimeProviderPool,
private val connectionPool: ConnectionPool,
private val runtimeSubscriptionPool: RuntimeSubscriptionPool,
private val chainDao: ChainDao,
private val chainSyncService: ChainSyncService,
private val evmAssetsSyncService: EvmAssetsSyncService,
private val baseTypeSynchronizer: BaseTypeSynchronizer,
private val runtimeSyncService: RuntimeSyncService,
private val web3ApiPool: Web3ApiPool,
private val gson: Gson
) : CoroutineScope by CoroutineScope(Dispatchers.Default) {
val currentChains = chainDao.joinChainInfoFlow()
.mapList { mapChainLocalToChain(it, gson) }
.diffed()
.map { diff ->
diff.removed.forEach { unregisterChain(it) }
diff.newOrUpdated.forEach { chain -> registerChain(chain) }
diff.all
}
.filter { it.isNotEmpty() }
.distinctUntilChanged()
.inBackground()
.shareIn(this, SharingStarted.Eagerly, replay = 1)
val chainsById = currentChains.map { chains -> chains.associateBy { it.id } }
.inBackground()
.shareIn(this, SharingStarted.Eagerly, replay = 1)
init {
syncChainsAndAssets()
syncBaseTypesIfNeeded()
}
fun getConnectionOrNull(chainId: String): ChainConnection? {
return connectionPool.getConnectionOrNull(chainId.removeHexPrefix())
}
@Deprecated("Use getActiveConnectionOrNull, since this method may throw an exception if Chain is disabled")
suspend fun getActiveConnection(chainId: String): ChainConnection {
requireConnectionStateAtLeast(chainId, ConnectionState.LIGHT_SYNC)
return connectionPool.getConnection(chainId.removeHexPrefix())
}
suspend fun getActiveConnectionOrNull(chainId: String): ChainConnection? {
return runCatching {
requireConnectionStateAtLeast(chainId, ConnectionState.LIGHT_SYNC)
return connectionPool.getConnectionOrNull(chainId.removeHexPrefix())
}.getOrNull()
}
suspend fun getEthereumApi(chainId: String, connectionType: ConnectionType): Web3Api? {
return runCatching {
requireConnectionStateAtLeast(chainId, ConnectionState.LIGHT_SYNC)
web3ApiPool.getWeb3Api(chainId, connectionType)
}.getOrNull()
}
suspend fun getRuntimeProvider(chainId: String): RuntimeProvider {
requireConnectionStateAtLeast(chainId, ConnectionState.FULL_SYNC)
return runtimeProviderPool.getRuntimeProvider(chainId.removeHexPrefix())
}
suspend fun getChain(chainId: String): Chain = chainsById.first().getValue(chainId.removeHexPrefix())
suspend fun enableFullSync(chainId: ChainId) {
changeChainConnectionState(chainId, ConnectionState.FULL_SYNC)
}
suspend fun changeChainConnectionState(chainId: ChainId, state: ConnectionState) {
val connectionState = mapConnectionStateToLocal(state)
chainDao.setConnectionState(chainId, connectionState)
}
suspend fun setWssNodeSelectionStrategy(chainId: String, strategy: Chain.Nodes.NodeSelectionStrategy) {
return when (strategy) {
Chain.Nodes.NodeSelectionStrategy.AutoBalance -> enableAutoBalance(chainId)
is Chain.Nodes.NodeSelectionStrategy.SelectedNode -> setSelectedNode(chainId, strategy.unformattedNodeUrl)
}
}
private suspend fun enableAutoBalance(chainId: ChainId) {
chainDao.setNodePreferences(NodeSelectionPreferencesLocal(chainId, autoBalanceEnabled = false, null))
}
private suspend fun setSelectedNode(chainId: ChainId, unformattedNodeUrl: String) {
val chain = getChain(chainId)
val chainSupportsNode = chain.nodes.nodes.any { it.unformattedUrl == unformattedNodeUrl }
require(chainSupportsNode) { "Node with url $unformattedNodeUrl is not found for chain $chainId" }
chainDao.setNodePreferences(NodeSelectionPreferencesLocal(chainId, false, unformattedNodeUrl))
}
private suspend fun requireConnectionStateAtLeast(chainId: ChainId, state: ConnectionState) {
val chain = getChain(chainId)
if (chain.isDisabled) throw DisabledChainException()
if (chain.connectionState.level >= state.level) return
Log.d("ConnectionState", "Requested state $state for ${chain.name}, current is ${chain.connectionState}. Triggering state change to $state")
chainDao.setConnectionState(chainId, mapConnectionStateToLocal(state))
awaitConnectionStateIsAtLeast(chainId, state)
}
private fun syncChainsAndAssets() {
launch {
runCatching {
chainSyncService.syncUp()
evmAssetsSyncService.syncUp()
}.onFailure {
Log.e(LOG_TAG, "Failed to sync chains or assets", it)
}
}
}
private suspend fun awaitConnectionStateIsAtLeast(chainId: ChainId, state: ConnectionState) {
chainsById
.mapNotNull { chainsById -> chainsById[chainId] }
.first { it.connectionState.level >= state.level }
}
private fun unregisterChain(chain: Chain) {
unregisterSubstrateServices(chain)
unregisterConnections(chain.id)
}
private suspend fun registerChain(chain: Chain) {
return when (chain.connectionState) {
ConnectionState.FULL_SYNC -> registerFullSyncChain(chain)
ConnectionState.LIGHT_SYNC -> registerLightSyncChain(chain)
ConnectionState.DISABLED -> registerDisabledChain(chain)
}
}
private fun registerDisabledChain(chain: Chain) {
unregisterSubstrateServices(chain)
unregisterConnections(chain.id)
}
private suspend fun registerLightSyncChain(chain: Chain) {
registerConnection(chain)
unregisterSubstrateServices(chain)
}
private suspend fun registerFullSyncChain(chain: Chain) {
val connection = registerConnection(chain)
if (chain.hasSubstrateRuntime) {
runtimeProviderPool.setupRuntimeProvider(chain)
runtimeSyncService.registerChain(chain, connection)
runtimeSubscriptionPool.setupRuntimeSubscription(chain, connection)
}
}
private suspend fun registerConnection(chain: Chain): ChainConnection {
val connection = connectionPool.setupConnection(chain)
if (chain.isEthereumBased) {
web3ApiPool.setupWssApi(chain.id, connection.socketService)
web3ApiPool.setupHttpsApi(chain)
}
return connection
}
private fun syncBaseTypesIfNeeded() = launch {
val chains = currentChains.first()
val needToSyncBaseTypes = chains.any { it.typesUsage.requiresBaseTypes && it.connectionState.shouldSyncRuntime() }
if (needToSyncBaseTypes) {
baseTypeSynchronizer.sync()
}
}
private fun unregisterSubstrateServices(chain: Chain) {
if (chain.hasSubstrateRuntime) {
runtimeProviderPool.removeRuntimeProvider(chain.id)
runtimeSubscriptionPool.removeSubscription(chain.id)
runtimeSyncService.unregisterChain(chain.id)
}
}
private fun unregisterConnections(chainId: ChainId) {
connectionPool.removeConnection(chainId)
web3ApiPool.removeApis(chainId)
}
private fun ConnectionState.shouldSyncRuntime(): Boolean {
return isFullSync
}
}
suspend fun ChainRegistry.getChainOrNull(chainId: String): Chain? {
return chainsById.first()[chainId.removeHexPrefix()]
}
suspend fun ChainRegistry.chainWithAssetOrNull(chainId: String, assetId: Int): ChainWithAsset? {
val chain = getChainOrNull(chainId) ?: return null
val chainAsset = chain.assetsById[assetId] ?: return null
return ChainWithAsset(chain, chainAsset)
}
suspend fun ChainRegistry.enabledChainWithAssetOrNull(chainId: String, assetId: Int): ChainWithAsset? {
val chain = getChainOrNull(chainId).takeIf { it?.isEnabled == true } ?: return null
val chainAsset = chain.assetsById[assetId] ?: return null
return ChainWithAsset(chain, chainAsset)
}
suspend fun ChainRegistry.assetOrNull(fullChainAssetId: FullChainAssetId): Chain.Asset? {
val chain = getChainOrNull(fullChainAssetId.chainId) ?: return null
return chain.assetsById[fullChainAssetId.assetId]
}
suspend fun ChainRegistry.chainWithAsset(chainId: String, assetId: Int): ChainWithAsset {
val chain = chainsById.first().getValue(chainId)
return ChainWithAsset(chain, chain.assetsById.getValue(assetId))
}
suspend fun ChainRegistry.chainWithAsset(fullChainAssetId: FullChainAssetId): ChainWithAsset {
return chainWithAsset(fullChainAssetId.chainId, fullChainAssetId.assetId)
}
suspend fun ChainRegistry.asset(chainId: String, assetId: Int): Chain.Asset {
val chain = chainsById.first().getValue(chainId)
return chain.assetsById.getValue(assetId)
}
suspend fun ChainRegistry.asset(fullChainAssetId: FullChainAssetId): Chain.Asset {
return asset(fullChainAssetId.chainId, fullChainAssetId.assetId)
}
fun ChainsById.assets(ids: Collection<FullChainAssetId>): List<Chain.Asset> {
return ids.map { (chainId, assetId) ->
getValue(chainId).assetsById.getValue(assetId)
}
}
suspend inline fun <R> ChainRegistry.withRuntime(chainId: ChainId, action: RuntimeContext.() -> R): R {
return getRuntime(chainId).provideContext(action)
}
suspend inline fun ChainRegistry.findChain(predicate: (Chain) -> Boolean): Chain? = currentChains.first().firstOrNull(predicate)
suspend inline fun ChainRegistry.findChains(predicate: (Chain) -> Boolean): List<Chain> = currentChains.first().filter(predicate)
suspend inline fun ChainRegistry.findEnabledChains(predicate: (Chain) -> Boolean): List<Chain> = enabledChains().filter(predicate)
suspend inline fun ChainRegistry.findChainIds(predicate: (Chain) -> Boolean): Set<ChainId> = currentChains.first().mapNotNullToSet { chain ->
chain.id.takeIf { predicate(chain) }
}
suspend inline fun ChainRegistry.findChainsById(predicate: (Chain) -> Boolean): ChainsById {
return chainsById().filterValues { chain -> predicate(chain) }.asChainsById()
}
suspend fun ChainRegistry.getRuntime(chainId: String) = getRuntimeProvider(chainId).get()
suspend fun ChainRegistry.getRawMetadata(chainId: String) = getRuntimeProvider(chainId).getRaw()
suspend fun ChainRegistry.getSocket(chainId: String): SocketService = getActiveConnection(chainId).socketService
suspend fun ChainRegistry.getSocketOrNull(chainId: String): SocketService? = getActiveConnectionOrNull(chainId)?.socketService
suspend fun ChainRegistry.getEthereumApiOrThrow(chainId: String, connectionType: ConnectionType): Web3Api {
return requireNotNull(getEthereumApi(chainId, connectionType)) {
"Ethereum Api is not found for chain $chainId and connection type ${connectionType.name}"
}
}
suspend fun ChainRegistry.getSubscriptionEthereumApiOrThrow(chainId: String): Web3Api {
return getEthereumApiOrThrow(chainId, ConnectionType.WSS)
}
suspend fun ChainRegistry.getSubscriptionEthereumApi(chainId: String): Web3Api? {
return getEthereumApi(chainId, ConnectionType.WSS)
}
suspend fun ChainRegistry.getCallEthereumApiOrThrow(chainId: String): Web3Api {
return getEthereumApi(chainId, ConnectionType.HTTPS)
?: getEthereumApiOrThrow(chainId, ConnectionType.WSS)
}
suspend fun ChainRegistry.getCallEthereumApi(chainId: String): Web3Api? {
return getEthereumApi(chainId, ConnectionType.HTTPS)
?: getEthereumApi(chainId, ConnectionType.WSS)
}
suspend fun ChainRegistry.chainsById(): ChainsById = ChainsById(chainsById.first())
suspend fun ChainRegistry.findEvmChain(evmChainId: Int): Chain? {
return findChain { it.isEthereumBased && it.addressPrefix == evmChainId }
}
suspend fun ChainRegistry.findEvmCallApi(evmChainId: Int): Web3Api? {
return findEvmChain(evmChainId)?.let {
getCallEthereumApi(it.id)
}
}
suspend fun ChainRegistry.findEvmChainFromHexId(evmChainIdHex: String): Chain? {
val addressPrefix = evmChainIdHex.removeHexPrefix().toIntOrNull(radix = 16) ?: return null
return findEvmChain(addressPrefix)
}
suspend fun ChainRegistry.findRelayChainOrThrow(chainId: ChainId): ChainId {
val chain = getChain(chainId)
return chain.parentId ?: chainId
}
fun ChainRegistry.chainFlow(chainId: ChainId): Flow<Chain> {
return chainsById.mapNotNull { it[chainId] }
}
fun ChainRegistry.enabledChainsFlow() = currentChains
.filterList { it.isEnabled }
suspend fun ChainRegistry.enabledChains() = enabledChainsFlow().first()
suspend fun ChainRegistry.disabledChains() = currentChains.filterList { it.isDisabled }
.first()
fun ChainRegistry.enabledChainByIdFlow() = enabledChainsFlow().map { chains -> chains.associateBy { it.id } }
suspend fun ChainRegistry.enabledChainById() = ChainsById(enabledChainByIdFlow().first())
@@ -0,0 +1,30 @@
package io.novafoundation.nova.runtime.multiNetwork
import io.novafoundation.nova.common.utils.removeHexPrefix
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.chain.model.FullChainAssetId
@JvmInline
value class ChainsById(val value: Map<ChainId, Chain>) : Map<ChainId, Chain> by value {
override operator fun get(key: ChainId): Chain? {
return value[key.removeHexPrefix()]
}
}
@Suppress("NOTHING_TO_INLINE")
inline fun Map<ChainId, Chain>.asChainsById(): ChainsById {
return ChainsById(this)
}
fun ChainsById.assetOrNull(id: FullChainAssetId): Chain.Asset? {
return get(id.chainId)?.assetsById?.get(id.assetId)
}
fun ChainsById.chainWithAssetOrNull(id: FullChainAssetId): ChainWithAsset? {
val chain = get(id.chainId) ?: return null
val asset = chain.assetsById[id.assetId] ?: return null
return ChainWithAsset(chain, asset)
}
@@ -0,0 +1,46 @@
package io.novafoundation.nova.runtime.multiNetwork.asset
import com.google.gson.Gson
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.ChainAssetDao
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.ext.fullId
import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal
import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal.Companion.ENABLED_DEFAULT_BOOL
import io.novafoundation.nova.runtime.multiNetwork.asset.remote.AssetFetcher
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapEVMAssetRemoteToLocalAssets
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class EvmAssetsSyncService(
private val chainDao: ChainDao,
private val chainAssetDao: ChainAssetDao,
private val chainFetcher: AssetFetcher,
private val gson: Gson,
) {
suspend fun syncUp() = withContext(Dispatchers.Default) {
syncEVMAssets()
}
private suspend fun syncEVMAssets() {
val availableChainIds = chainDao.getAllChainIds().toSet()
val oldAssets = chainAssetDao.getAssetsBySource(AssetSourceLocal.ERC20)
val associatedOldAssets = oldAssets.associateBy { it.fullId() }
val newAssets = retryUntilDone { chainFetcher.getEVMAssets() }
.flatMap { mapEVMAssetRemoteToLocalAssets(it, gson) }
.mapNotNull { new ->
// handle misconfiguration between chains.json and assets.json when assets contains asset for chain that is not present in chain
if (new.chainId !in availableChainIds) return@mapNotNull null
val old = associatedOldAssets[new.fullId()]
new.copy(enabled = old?.enabled ?: ENABLED_DEFAULT_BOOL)
}
val diff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false)
chainAssetDao.updateAssets(diff)
}
}
@@ -0,0 +1,11 @@
package io.novafoundation.nova.runtime.multiNetwork.asset.remote
import io.novafoundation.nova.runtime.BuildConfig
import io.novafoundation.nova.runtime.multiNetwork.asset.remote.model.EVMAssetRemote
import retrofit2.http.GET
interface AssetFetcher {
@GET(BuildConfig.EVM_ASSETS_URL)
suspend fun getEVMAssets(): List<EVMAssetRemote>
}
@@ -0,0 +1,10 @@
package io.novafoundation.nova.runtime.multiNetwork.asset.remote.model
data class EVMAssetRemote(
val symbol: String,
val precision: Int,
val priceId: String?,
val name: String,
val icon: String?,
val instances: List<EVMInstanceRemote>
)
@@ -0,0 +1,8 @@
package io.novafoundation.nova.runtime.multiNetwork.asset.remote.model
class EVMInstanceRemote(
val chainId: String,
val contractAddress: String,
val buyProviders: Map<String, Map<String, Any>>?,
val sellProviders: Map<String, Map<String, Any>>?
)
@@ -0,0 +1,87 @@
package io.novafoundation.nova.runtime.multiNetwork.chain
import com.google.gson.Gson
import io.novafoundation.nova.common.utils.CollectionDiffer
import io.novafoundation.nova.common.utils.retryUntilDone
import io.novafoundation.nova.core_db.dao.ChainDao
import io.novafoundation.nova.core_db.dao.FullAssetIdLocal
import io.novafoundation.nova.core_db.ext.fullId
import io.novafoundation.nova.core_db.model.chain.AssetSourceLocal
import io.novafoundation.nova.core_db.model.chain.ChainAssetLocal.Companion.ENABLED_DEFAULT_BOOL
import io.novafoundation.nova.core_db.model.chain.ChainLocal
import io.novafoundation.nova.core_db.model.chain.ChainNodeLocal
import io.novafoundation.nova.core_db.model.chain.NodeSelectionPreferencesLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapExternalApisToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteAssetToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteChainToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteExplorersToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.mappers.mapRemoteNodesToLocal
import io.novafoundation.nova.runtime.multiNetwork.chain.remote.ChainFetcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class ChainSyncService(
private val chainDao: ChainDao,
private val chainFetcher: ChainFetcher,
private val gson: Gson
) {
suspend fun syncUp() = withContext(Dispatchers.Default) {
val localChainsJoinedInfo = chainDao.getJoinChainInfo().filter { it.chain.source != ChainLocal.Source.CUSTOM }
val oldChains = localChainsJoinedInfo.map { it.chain }
val oldAssets = localChainsJoinedInfo.flatMap { it.assets }.filter { it.source == AssetSourceLocal.DEFAULT }
val oldNodes = localChainsJoinedInfo.flatMap { it.nodes }.filter { it.source != ChainNodeLocal.Source.CUSTOM }
val oldExplorers = localChainsJoinedInfo.flatMap { it.explorers }
val oldExternalApis = localChainsJoinedInfo.flatMap { it.externalApis }
val oldNodeSelectionPreferences = localChainsJoinedInfo.mapNotNull { it.nodeSelectionPreferences }
val oldChainsById = oldChains.associateBy { it.id }
val associatedOldAssets = oldAssets.associateBy { it.fullId() }
val remoteChains = retryUntilDone { chainFetcher.getChains() }
val newChains = remoteChains.map { mapRemoteChainToLocal(it, oldChainsById[it.chainId], source = ChainLocal.Source.DEFAULT, gson) }
val newAssets = remoteChains.flatMap { chain ->
chain.assets.map {
val fullAssetId = FullAssetIdLocal(chain.chainId, it.assetId)
val oldAsset = associatedOldAssets[fullAssetId]
mapRemoteAssetToLocal(chain, it, gson, oldAsset?.enabled ?: ENABLED_DEFAULT_BOOL)
}
}
val newNodes = remoteChains.flatMap(::mapRemoteNodesToLocal)
val newExplorers = remoteChains.flatMap(::mapRemoteExplorersToLocal)
val newExternalApis = remoteChains.flatMap(::mapExternalApisToLocal)
val newNodeSelectionPreferences = nodeSelectionPreferencesFor(newChains, oldNodeSelectionPreferences)
val chainsDiff = CollectionDiffer.findDiff(newChains, oldChains, forceUseNewItems = false)
val assetDiff = CollectionDiffer.findDiff(newAssets, oldAssets, forceUseNewItems = false)
val nodesDiff = CollectionDiffer.findDiff(newNodes, oldNodes, forceUseNewItems = false)
val explorersDiff = CollectionDiffer.findDiff(newExplorers, oldExplorers, forceUseNewItems = false)
val externalApisDiff = CollectionDiffer.findDiff(newExternalApis, oldExternalApis, forceUseNewItems = false)
val nodeSelectionPreferencesDiff = CollectionDiffer.findDiff(newNodeSelectionPreferences, oldNodeSelectionPreferences, forceUseNewItems = false)
chainDao.applyDiff(
chainDiff = chainsDiff,
assetsDiff = assetDiff,
nodesDiff = nodesDiff,
explorersDiff = explorersDiff,
externalApisDiff = externalApisDiff,
nodeSelectionPreferencesDiff = nodeSelectionPreferencesDiff
)
}
private fun nodeSelectionPreferencesFor(
newChains: List<ChainLocal>,
oldNodeSelectionPreferences: List<NodeSelectionPreferencesLocal>
): List<NodeSelectionPreferencesLocal> {
val preferencesById = oldNodeSelectionPreferences.associateBy { it.chainId }
return newChains.map {
preferencesById[it.id]
?: NodeSelectionPreferencesLocal(
chainId = it.id,
autoBalanceEnabled = NodeSelectionPreferencesLocal.DEFAULT_AUTO_BALANCE_BOOLEAN,
selectedNodeUrl = null
)
}
}
}

Some files were not shown because too many files have changed in this diff Show More