mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 09:05:48 +00:00
Add native Solana balance and transfer support
- SolanaTransaction: hand-rolled compact-u16/legacy-Message/signed-tx wire-format primitives, cross-validated byte-for-byte against the solders Python library's own serialization of an identical System Program transfer message. - SolanaApi: JSON-RPC client (getBalance/getLatestBlockhash/ getFeeForMessage/sendTransaction) over a single POST endpoint, unlike Tron/Bitcoin's path-per-resource REST style. - SolanaNativeAssetBalance: getBalance polling, same design as Bitcoin's mempool.space polling (no push/subscription mechanism available). - RealSolanaTransactionService: builds a single-instruction transfer message, signs it raw via the existing Ed25519 signer dispatch (skipMessageHashing - Solana signs the message directly, no external hashing), broadcasts via sendTransaction. Fee is Solana's actual getFeeForMessage answer, not a client-side estimate. - TransactionExecution.Solana(hash) variant; wired into AssetsModule via a new SolanaAssetsModule, following the Bitcoin/Tron precedent exactly.
This commit is contained in:
@@ -0,0 +1,112 @@
|
|||||||
|
package io.novafoundation.nova.common.utils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solana's "shortvec"/compact-u16 length-prefix encoding used throughout the wire format below - LEB128-style,
|
||||||
|
* 7 payload bits per byte with a continuation bit, matching https://solana.com/docs/rpc/http's message layout.
|
||||||
|
* Cross-validated byte-for-byte against the `solders` Python library's own serialization of a real System
|
||||||
|
* Program transfer message before being ported here (values used in this file never exceed 127, so this always
|
||||||
|
* emits a single byte in practice, but the general algorithm is implemented for correctness/robustness).
|
||||||
|
*/
|
||||||
|
object SolanaCompactU16 {
|
||||||
|
|
||||||
|
fun encode(value: Int): ByteArray {
|
||||||
|
require(value >= 0) { "compact-u16 cannot encode a negative value" }
|
||||||
|
|
||||||
|
var remaining = value
|
||||||
|
val bytes = mutableListOf<Byte>()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
var elem = remaining and 0x7f
|
||||||
|
remaining = remaining ushr 7
|
||||||
|
|
||||||
|
if (remaining == 0) {
|
||||||
|
bytes.add(elem.toByte())
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
elem = elem or 0x80
|
||||||
|
bytes.add(elem.toByte())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The System Program's id is the all-zero 32-byte pubkey (base58: 32 `1` characters) - not a derived/hashed value. */
|
||||||
|
private val SOLANA_SYSTEM_PROGRAM_ID: ByteArray = ByteArray(32)
|
||||||
|
|
||||||
|
/** `Transfer` is variant index 2 of the System Program's instruction enum. */
|
||||||
|
private const val SOLANA_SYSTEM_INSTRUCTION_TRANSFER = 2
|
||||||
|
|
||||||
|
object SolanaTransaction {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A legacy (unversioned) Message containing a single System Program `Transfer` instruction - the simplest,
|
||||||
|
* maximally-compatible shape for a native SOL transfer (no address-lookup-tables/versioned-transaction
|
||||||
|
* features needed). Layout: MessageHeader(3 bytes) + compact-u16 account count + accounts(32B each) +
|
||||||
|
* recentBlockhash(32B) + compact-u16 instruction count + CompiledInstruction. Verified byte-for-byte
|
||||||
|
* against `solders`' own serialization of an identical transfer message - see the class doc.
|
||||||
|
*
|
||||||
|
* [recentBlockhash] must be 32 raw bytes (Base58-decode the RPC's `blockhash` string before calling this).
|
||||||
|
*/
|
||||||
|
fun buildTransferMessage(
|
||||||
|
senderPublicKey: ByteArray,
|
||||||
|
recipientPublicKey: ByteArray,
|
||||||
|
lamports: Long,
|
||||||
|
recentBlockhash: ByteArray,
|
||||||
|
): ByteArray {
|
||||||
|
require(senderPublicKey.size == 32) { "senderPublicKey must be 32 bytes, got ${senderPublicKey.size}" }
|
||||||
|
require(recipientPublicKey.size == 32) { "recipientPublicKey must be 32 bytes, got ${recipientPublicKey.size}" }
|
||||||
|
require(recentBlockhash.size == 32) { "recentBlockhash must be 32 bytes, got ${recentBlockhash.size}" }
|
||||||
|
require(lamports >= 0) { "lamports cannot be negative" }
|
||||||
|
|
||||||
|
val accountKeys = listOf(senderPublicKey, recipientPublicKey, SOLANA_SYSTEM_PROGRAM_ID)
|
||||||
|
|
||||||
|
val instructionData = ByteArray(4 + 8).also {
|
||||||
|
writeUInt32LE(it, 0, SOLANA_SYSTEM_INSTRUCTION_TRANSFER)
|
||||||
|
writeUInt64LE(it, 4, lamports)
|
||||||
|
}
|
||||||
|
|
||||||
|
val out = mutableListOf<Byte>()
|
||||||
|
|
||||||
|
// MessageHeader: 1 required signature (sender), 0 readonly-signed, 1 readonly-unsigned (System Program)
|
||||||
|
out.add(1)
|
||||||
|
out.add(0)
|
||||||
|
out.add(1)
|
||||||
|
|
||||||
|
out.addAll(SolanaCompactU16.encode(accountKeys.size).asList())
|
||||||
|
accountKeys.forEach { out.addAll(it.asList()) }
|
||||||
|
|
||||||
|
out.addAll(recentBlockhash.asList())
|
||||||
|
|
||||||
|
// Single instruction: System Program Transfer
|
||||||
|
out.addAll(SolanaCompactU16.encode(1).asList())
|
||||||
|
out.add(2) // program_id_index -> accountKeys[2] (System Program)
|
||||||
|
out.addAll(SolanaCompactU16.encode(2).asList())
|
||||||
|
out.add(0) // sender account index
|
||||||
|
out.add(1) // recipient account index
|
||||||
|
out.addAll(SolanaCompactU16.encode(instructionData.size).asList())
|
||||||
|
out.addAll(instructionData.asList())
|
||||||
|
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single-signer signed transaction: compact-u16(1) + the 64-byte Ed25519 signature + the message bytes. */
|
||||||
|
fun serializeSigned(message: ByteArray, signature: ByteArray): ByteArray {
|
||||||
|
require(signature.size == 64) { "Ed25519 signature must be 64 bytes, got ${signature.size}" }
|
||||||
|
|
||||||
|
return SolanaCompactU16.encode(1) + signature + message
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUInt32LE(buffer: ByteArray, offset: Int, value: Int) {
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
buffer[offset + i] = (value ushr (8 * i)).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeUInt64LE(buffer: ByteArray, offset: Int, value: Long) {
|
||||||
|
for (i in 0 until 8) {
|
||||||
|
buffer[offset + i] = (value ushr (8 * i)).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package io.novafoundation.nova.common.utils
|
||||||
|
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.toHexString
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class SolanaTransactionTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Independently built via the `solders` Python library (a real, widely-used Solana SDK, run standalone
|
||||||
|
* outside this codebase): a System Program `Transfer` message from a fixed sender to a fixed recipient, a
|
||||||
|
* fixed 123456789-lamport amount, and an all-zero 32-byte "recent blockhash" (chosen for a reproducible
|
||||||
|
* fixed vector, not a real network value). This test asserts our hand-rolled message builder produces the
|
||||||
|
* exact same bytes solders did for the identical inputs.
|
||||||
|
*/
|
||||||
|
private val senderPublicKey = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5".fromHex()
|
||||||
|
private val recipientPublicKey = "c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3".fromHex()
|
||||||
|
private val recentBlockhash = ByteArray(32)
|
||||||
|
private val lamports = 123456789L
|
||||||
|
|
||||||
|
private val expectedMessageHex = "010001038a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5" +
|
||||||
|
"c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b39400000000000000000000000000000000" +
|
||||||
|
"00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" +
|
||||||
|
"1020200010c0200000015cd5b0700000000"
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `buildTransferMessage should match the independently-built solders reference vector`() {
|
||||||
|
val message = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
|
||||||
|
|
||||||
|
assertEquals(expectedMessageHex, message.toHexString(withPrefix = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `serializeSigned should prefix a single-signature count byte and the signature before the message`() {
|
||||||
|
val message = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
|
||||||
|
val signature = ByteArray(64) { it.toByte() }
|
||||||
|
|
||||||
|
val signedTx = SolanaTransaction.serializeSigned(message, signature)
|
||||||
|
|
||||||
|
assertEquals(1, signedTx[0].toInt())
|
||||||
|
assertEquals(signature.toList(), signedTx.copyOfRange(1, 65).toList())
|
||||||
|
assertEquals(message.toList(), signedTx.copyOfRange(65, signedTx.size).toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun `serializeSigned should reject a non-64-byte signature`() {
|
||||||
|
val message = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
|
||||||
|
|
||||||
|
SolanaTransaction.serializeSigned(message, ByteArray(63))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact-u16 should single-byte-encode every value used by a simple transfer message`() {
|
||||||
|
assertEquals(listOf<Byte>(3), SolanaCompactU16.encode(3).toList())
|
||||||
|
assertEquals(listOf<Byte>(1), SolanaCompactU16.encode(1).toList())
|
||||||
|
assertEquals(listOf<Byte>(2), SolanaCompactU16.encode(2).toList())
|
||||||
|
assertEquals(listOf<Byte>(12), SolanaCompactU16.encode(12).toList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact-u16 should multi-byte-encode a value at and above 128`() {
|
||||||
|
// 128 = 0b1_0000000 -> low 7 bits (0) with continuation bit set, then high bit (1)
|
||||||
|
assertEquals(listOf(0x80.toByte(), 0x01.toByte()), SolanaCompactU16.encode(128).toList())
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -88,6 +88,18 @@ class TronFee(
|
|||||||
override val asset: Chain.Asset
|
override val asset: Chain.Asset
|
||||||
) : Fee
|
) : Fee
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fee for a Solana transaction, denominated in lamports, always paid in native SOL regardless of which asset is
|
||||||
|
* being sent (Solana has no separate "gas token" concept, same as Tron). [amount] is `getFeeForMessage`'s
|
||||||
|
* authoritative, exact answer for this specific compiled message (not an estimate the way Bitcoin's vsize-based
|
||||||
|
* fee is) - see `RealSolanaTransactionService`.
|
||||||
|
*/
|
||||||
|
class SolanaFee(
|
||||||
|
override val amount: BigInteger,
|
||||||
|
override val submissionOrigin: SubmissionOrigin,
|
||||||
|
override val asset: Chain.Asset
|
||||||
|
) : Fee
|
||||||
|
|
||||||
class SubstrateFeeBase(
|
class SubstrateFeeBase(
|
||||||
override val amount: BigInteger,
|
override val amount: BigInteger,
|
||||||
override val asset: Chain.Asset
|
override val asset: Chain.Asset
|
||||||
|
|||||||
+2
@@ -12,4 +12,6 @@ sealed interface TransactionExecution {
|
|||||||
class Bitcoin(val hash: String) : TransactionExecution
|
class Bitcoin(val hash: String) : TransactionExecution
|
||||||
|
|
||||||
class Tron(val hash: String) : TransactionExecution
|
class Tron(val hash: String) : TransactionExecution
|
||||||
|
|
||||||
|
class Solana(val hash: String) : TransactionExecution
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -32,6 +32,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
private val tronNativeSource: Lazy<AssetSource>,
|
private val tronNativeSource: Lazy<AssetSource>,
|
||||||
private val trc20Source: Lazy<AssetSource>,
|
private val trc20Source: Lazy<AssetSource>,
|
||||||
private val bitcoinNativeSource: Lazy<AssetSource>,
|
private val bitcoinNativeSource: Lazy<AssetSource>,
|
||||||
|
private val solanaNativeSource: Lazy<AssetSource>,
|
||||||
private val unsupportedBalanceSource: AssetSource,
|
private val unsupportedBalanceSource: AssetSource,
|
||||||
|
|
||||||
private val nativeAssetEventDetector: NativeAssetEventDetector,
|
private val nativeAssetEventDetector: NativeAssetEventDetector,
|
||||||
@@ -51,6 +52,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
is Chain.Asset.Type.TronNative -> tronNativeSource.get()
|
is Chain.Asset.Type.TronNative -> tronNativeSource.get()
|
||||||
is Chain.Asset.Type.Trc20 -> trc20Source.get()
|
is Chain.Asset.Type.Trc20 -> trc20Source.get()
|
||||||
is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get()
|
is Chain.Asset.Type.BitcoinNative -> bitcoinNativeSource.get()
|
||||||
|
is Chain.Asset.Type.SolanaNative -> solanaNativeSource.get()
|
||||||
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
|
Chain.Asset.Type.Unsupported -> unsupportedBalanceSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,6 +68,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
add(tronNativeSource.get())
|
add(tronNativeSource.get())
|
||||||
add(trc20Source.get())
|
add(trc20Source.get())
|
||||||
add(bitcoinNativeSource.get())
|
add(bitcoinNativeSource.get())
|
||||||
|
add(solanaNativeSource.get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +79,7 @@ class TypeBasedAssetSourceRegistry(
|
|||||||
is Chain.Asset.Type.TronNative,
|
is Chain.Asset.Type.TronNative,
|
||||||
is Chain.Asset.Type.Trc20,
|
is Chain.Asset.Type.Trc20,
|
||||||
is Chain.Asset.Type.BitcoinNative,
|
is Chain.Asset.Type.BitcoinNative,
|
||||||
|
is Chain.Asset.Type.SolanaNative,
|
||||||
|
|
||||||
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector()
|
Chain.Asset.Type.Unsupported -> UnsupportedEventDetector()
|
||||||
|
|
||||||
|
|||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.solanaNative
|
||||||
|
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.flow
|
||||||
|
|
||||||
|
private const val SOLANA_BALANCE_POLLING_INTERVAL_MS = 30_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solana's JSON-RPC has no push/subscription mechanism usable here (its websocket `accountSubscribe` would
|
||||||
|
* need a persistent per-node WS connection this app's Bitcoin/Tron-style REST clients don't otherwise use) -
|
||||||
|
* mirrors [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.pollingBalanceFlow]'s
|
||||||
|
* exact polling design instead.
|
||||||
|
*/
|
||||||
|
internal fun pollingBalanceFlow(
|
||||||
|
intervalMs: Long = SOLANA_BALANCE_POLLING_INTERVAL_MS,
|
||||||
|
fetch: suspend () -> Balance
|
||||||
|
): Flow<Balance> = flow {
|
||||||
|
var lastEmitted: Balance? = null
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val latest = fetch()
|
||||||
|
|
||||||
|
if (latest != lastEmitted) {
|
||||||
|
lastEmitted = latest
|
||||||
|
emit(latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(intervalMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.solanaNative
|
||||||
|
|
||||||
|
import io.novafoundation.nova.core.updater.SharedRequestsBuilder
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.cache.updateNonLockableAsset
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.AssetBalance
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.BalanceSyncUpdate
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.ChainAssetBalance
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.balances.model.TransferableBalanceUpdatePoint
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
|
||||||
|
import io.novafoundation.nova.runtime.ext.addressOf
|
||||||
|
import io.novafoundation.nova.runtime.ext.requireSolanaRpcBaseUrl
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native SOL balance on a Solana-based chain. Read-only balance (Phase 1, mirrors
|
||||||
|
* [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.bitcoinNative.BitcoinNativeAssetBalance]'s
|
||||||
|
* exact design): fetches via Solana's `getBalance` JSON-RPC method and polls for updates, since that method has
|
||||||
|
* no push/subscription counterpart usable here.
|
||||||
|
*/
|
||||||
|
class SolanaNativeAssetBalance(
|
||||||
|
private val assetCache: AssetCache,
|
||||||
|
private val solanaApi: SolanaApi,
|
||||||
|
) : AssetBalance {
|
||||||
|
|
||||||
|
override suspend fun startSyncingBalanceLocks(
|
||||||
|
metaAccount: MetaAccount,
|
||||||
|
chain: Chain,
|
||||||
|
chainAsset: Chain.Asset,
|
||||||
|
accountId: AccountId,
|
||||||
|
subscriptionBuilder: SharedRequestsBuilder
|
||||||
|
): Flow<*> {
|
||||||
|
// Solana native balance does not support locks
|
||||||
|
return emptyFlow<Nothing>()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSelfSufficient(chainAsset: Chain.Asset): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun existentialDeposit(chainAsset: Chain.Asset): BigInteger {
|
||||||
|
// Solana's closest analogue is per-account rent-exemption, which is a function of account *size*, not
|
||||||
|
// a fixed protocol constant the way Substrate's ED is - not modeled yet (Phase 1, same open item as
|
||||||
|
// Bitcoin's dust limit not being enforced at this layer).
|
||||||
|
return BigInteger.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun queryAccountBalance(chain: Chain, chainAsset: Chain.Asset, accountId: AccountId): ChainAssetBalance {
|
||||||
|
val balance = solanaApi.fetchNativeBalance(chain.requireSolanaRpcBaseUrl(), chain.addressOf(accountId))
|
||||||
|
|
||||||
|
return ChainAssetBalance.fromFree(chainAsset, balance)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun subscribeAccountBalanceUpdatePoint(
|
||||||
|
chain: Chain,
|
||||||
|
chainAsset: Chain.Asset,
|
||||||
|
accountId: AccountId,
|
||||||
|
): Flow<TransferableBalanceUpdatePoint> {
|
||||||
|
// Only ever invoked from RealCrossChainTransactor (XCM arrival detection), which is Substrate-only -
|
||||||
|
// Solana can never be an XCM cross-chain destination, so this is intentionally never reachable.
|
||||||
|
throw UnsupportedOperationException("Solana does not support XCM-style balance update points")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun startSyncingBalance(
|
||||||
|
chain: Chain,
|
||||||
|
chainAsset: Chain.Asset,
|
||||||
|
metaAccount: MetaAccount,
|
||||||
|
accountId: AccountId,
|
||||||
|
subscriptionBuilder: SharedRequestsBuilder
|
||||||
|
): Flow<BalanceSyncUpdate> {
|
||||||
|
val baseUrl = chain.requireSolanaRpcBaseUrl()
|
||||||
|
val address = chain.addressOf(accountId)
|
||||||
|
|
||||||
|
return pollingBalanceFlow { solanaApi.fetchNativeBalance(baseUrl, address) }
|
||||||
|
.map { balance ->
|
||||||
|
assetCache.updateNonLockableAsset(metaAccount.id, chainAsset, balance)
|
||||||
|
|
||||||
|
BalanceSyncUpdate.NoCause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.solanaNative
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.validation.ValidationSystem
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.intoOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.model.Fee
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfer
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.AssetTransfers
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.amountInPlanks
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.model.TransferParsedFromCall
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.checkForFeeChanges
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.positiveAmount
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.recipientIsNotSystemAccount
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientBalanceInUsedAsset
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.sufficientTransferableBalanceToPayOriginFee
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.validations.validAddress
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction.SolanaTransactionService
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.domain.validaiton.recipientCanAcceptTransfer
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Native SOL transfer. Mirrors
|
||||||
|
* [io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.bitcoinNative.BitcoinNativeAssetTransfers]'s
|
||||||
|
* exact shape - see `RealSolanaTransactionService` for the message-build/sign/broadcast detail.
|
||||||
|
*/
|
||||||
|
class SolanaNativeAssetTransfers(
|
||||||
|
private val solanaTransactionService: SolanaTransactionService,
|
||||||
|
private val assetSourceRegistry: AssetSourceRegistry,
|
||||||
|
) : AssetTransfers {
|
||||||
|
|
||||||
|
override fun getValidationSystem(coroutineScope: CoroutineScope) = ValidationSystem {
|
||||||
|
validAddress()
|
||||||
|
recipientIsNotSystemAccount()
|
||||||
|
|
||||||
|
positiveAmount()
|
||||||
|
|
||||||
|
sufficientBalanceInUsedAsset()
|
||||||
|
sufficientTransferableBalanceToPayOriginFee()
|
||||||
|
|
||||||
|
recipientCanAcceptTransfer(assetSourceRegistry)
|
||||||
|
|
||||||
|
checkForFeeChanges(assetSourceRegistry, coroutineScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun calculateFee(transfer: AssetTransfer, coroutineScope: CoroutineScope): Fee {
|
||||||
|
return solanaTransactionService.calculateFee(
|
||||||
|
chain = transfer.originChain,
|
||||||
|
origin = transfer.sender.intoOrigin(),
|
||||||
|
recipientAddress = transfer.recipient,
|
||||||
|
amountLamports = transfer.amountInPlanks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun performTransfer(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<ExtrinsicSubmission> {
|
||||||
|
return solanaTransactionService.transact(
|
||||||
|
chain = transfer.originChain,
|
||||||
|
origin = transfer.sender.intoOrigin(),
|
||||||
|
recipientAddress = transfer.recipient,
|
||||||
|
presetFee = transfer.fee.submissionFee,
|
||||||
|
amountLamports = transfer.amountInPlanks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun performTransferAndAwaitExecution(transfer: WeightedAssetTransfer, coroutineScope: CoroutineScope): Result<TransactionExecution> {
|
||||||
|
return solanaTransactionService.transactAndAwaitExecution(
|
||||||
|
chain = transfer.originChain,
|
||||||
|
origin = transfer.sender.intoOrigin(),
|
||||||
|
recipientAddress = transfer.recipient,
|
||||||
|
presetFee = transfer.fee.submissionFee,
|
||||||
|
amountLamports = transfer.amountInPlanks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun areTransfersEnabled(chainAsset: Chain.Asset): Boolean {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun parseTransfer(call: GenericCall.Instance, chain: Chain): TransferParsedFromCall? {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -122,6 +122,7 @@ private fun Chain.Asset.existentialDepositError(amount: BigDecimal): WillRemoveA
|
|||||||
is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
|
is Type.EvmErc20, is Type.EvmNative -> WillRemoveAccount.WillBurnDust
|
||||||
is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust
|
is Type.Trc20, Type.TronNative -> WillRemoveAccount.WillBurnDust
|
||||||
Type.BitcoinNative -> WillRemoveAccount.WillBurnDust
|
Type.BitcoinNative -> WillRemoveAccount.WillBurnDust
|
||||||
|
Type.SolanaNative -> WillRemoveAccount.WillBurnDust
|
||||||
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
|
is Type.Equilibrium -> WillRemoveAccount.WillBurnDust
|
||||||
Type.Unsupported -> throw IllegalArgumentException("Unsupported")
|
Type.Unsupported -> throw IllegalArgumentException("Unsupported")
|
||||||
}
|
}
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.solana
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.data.network.UserAgent
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaGetBalanceResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaGetFeeForMessageResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaGetLatestBlockhashResponse
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaRpcRequest
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaSendTransactionResponse
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.Headers
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Url
|
||||||
|
|
||||||
|
interface RetrofitSolanaApi {
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun getBalance(@Url url: String, @Body body: SolanaRpcRequest): SolanaGetBalanceResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun getLatestBlockhash(@Url url: String, @Body body: SolanaRpcRequest): SolanaGetLatestBlockhashResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun getFeeForMessage(@Url url: String, @Body body: SolanaRpcRequest): SolanaGetFeeForMessageResponse
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Headers(UserAgent.NOVA)
|
||||||
|
suspend fun sendTransaction(@Url url: String, @Body body: SolanaRpcRequest): SolanaSendTransactionResponse
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.solana
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.utils.Base58
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.types.Balance
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.model.SolanaRpcRequest
|
||||||
|
import java.math.BigInteger
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
|
class SolanaApiException(code: Int, message: String) : Exception("Solana RPC error $code: $message")
|
||||||
|
|
||||||
|
interface SolanaApi {
|
||||||
|
|
||||||
|
suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance
|
||||||
|
|
||||||
|
/** @return the 32 raw bytes of the cluster's most recent blockhash, ready to embed in a [io.novafoundation.nova.common.utils.SolanaTransaction] message. */
|
||||||
|
suspend fun fetchLatestBlockhash(baseUrl: String): ByteArray
|
||||||
|
|
||||||
|
/** @return the exact fee (lamports) the cluster will charge for this specific compiled [message], per `getFeeForMessage`. */
|
||||||
|
suspend fun calculateFeeForMessage(baseUrl: String, message: ByteArray): BigInteger
|
||||||
|
|
||||||
|
/** @param signedTransaction a fully-signed, serialized transaction (see [io.novafoundation.nova.common.utils.SolanaTransaction.serializeSigned]). @return the broadcast transaction's signature (its id/hash). */
|
||||||
|
suspend fun broadcastTransaction(baseUrl: String, signedTransaction: ByteArray): String
|
||||||
|
}
|
||||||
|
|
||||||
|
class RealSolanaApi(
|
||||||
|
private val retrofitApi: RetrofitSolanaApi
|
||||||
|
) : SolanaApi {
|
||||||
|
|
||||||
|
override suspend fun fetchNativeBalance(baseUrl: String, address: String): Balance {
|
||||||
|
val request = SolanaRpcRequest(method = "getBalance", params = listOf(address))
|
||||||
|
val response = retrofitApi.getBalance(baseUrl, request)
|
||||||
|
|
||||||
|
response.error?.let { throw SolanaApiException(it.code, it.message) }
|
||||||
|
|
||||||
|
return response.result?.value?.toBigInteger() ?: BigInteger.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun fetchLatestBlockhash(baseUrl: String): ByteArray {
|
||||||
|
val request = SolanaRpcRequest(method = "getLatestBlockhash")
|
||||||
|
val response = retrofitApi.getLatestBlockhash(baseUrl, request)
|
||||||
|
|
||||||
|
response.error?.let { throw SolanaApiException(it.code, it.message) }
|
||||||
|
|
||||||
|
val blockhashBase58 = response.result?.value?.blockhash
|
||||||
|
?: throw SolanaApiException(-1, "getLatestBlockhash returned no result")
|
||||||
|
|
||||||
|
return Base58.decode(blockhashBase58)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun calculateFeeForMessage(baseUrl: String, message: ByteArray): BigInteger {
|
||||||
|
val messageBase64 = Base64.getEncoder().encodeToString(message)
|
||||||
|
val request = SolanaRpcRequest(method = "getFeeForMessage", params = listOf(messageBase64, mapOf("encoding" to "base64")))
|
||||||
|
val response = retrofitApi.getFeeForMessage(baseUrl, request)
|
||||||
|
|
||||||
|
response.error?.let { throw SolanaApiException(it.code, it.message) }
|
||||||
|
|
||||||
|
val feeLamports = response.result?.value
|
||||||
|
?: throw SolanaApiException(-1, "getFeeForMessage could not price this message (unknown/expired blockhash)")
|
||||||
|
|
||||||
|
return feeLamports.toBigInteger()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun broadcastTransaction(baseUrl: String, signedTransaction: ByteArray): String {
|
||||||
|
val txBase64 = Base64.getEncoder().encodeToString(signedTransaction)
|
||||||
|
val request = SolanaRpcRequest(method = "sendTransaction", params = listOf(txBase64, mapOf("encoding" to "base64")))
|
||||||
|
val response = retrofitApi.sendTransaction(baseUrl, request)
|
||||||
|
|
||||||
|
response.error?.let { throw SolanaApiException(it.code, it.message) }
|
||||||
|
|
||||||
|
return response.result ?: throw SolanaApiException(-1, "sendTransaction returned no signature")
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solana speaks a single JSON-RPC endpoint (unlike Tron/Bitcoin's path-per-resource REST style) - every call
|
||||||
|
* is an HTTP POST of one of these envelopes to the same url, distinguished only by [method]/[params]. See
|
||||||
|
* https://solana.com/docs/rpc/http for the spec this mirrors.
|
||||||
|
*/
|
||||||
|
class SolanaRpcRequest(
|
||||||
|
val method: String,
|
||||||
|
val params: List<Any> = emptyList(),
|
||||||
|
val jsonrpc: String = "2.0",
|
||||||
|
val id: Int = 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaRpcError(
|
||||||
|
val code: Int,
|
||||||
|
val message: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaGetBalanceResponse(
|
||||||
|
val result: SolanaBalanceResult?,
|
||||||
|
val error: SolanaRpcError?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaBalanceResult(
|
||||||
|
/** Lamports (1 SOL = 1_000_000_000 lamports) - Solana's native-coin base unit, same role planks/wei play elsewhere. */
|
||||||
|
val value: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaGetLatestBlockhashResponse(
|
||||||
|
val result: SolanaLatestBlockhashResult?,
|
||||||
|
val error: SolanaRpcError?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaLatestBlockhashResult(
|
||||||
|
val value: SolanaBlockhashValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaBlockhashValue(
|
||||||
|
/** Base58-encoded, same alphabet/encoding as an account address - decode with [Base58] before use. */
|
||||||
|
val blockhash: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaGetFeeForMessageResponse(
|
||||||
|
val result: SolanaFeeForMessageResult?,
|
||||||
|
val error: SolanaRpcError?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaFeeForMessageResult(
|
||||||
|
/** Lamports, or null if the message's blockhash is not found (e.g. too old) - see RealSolanaApi's handling. */
|
||||||
|
val value: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
class SolanaSendTransactionResponse(
|
||||||
|
/** The transaction's signature, base58-encoded - this doubles as the transaction id/hash used for tracking. */
|
||||||
|
val result: String?,
|
||||||
|
val error: SolanaRpcError?,
|
||||||
|
)
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.utils.SolanaTransaction
|
||||||
|
import io.novafoundation.nova.common.utils.emptySolanaAccountId
|
||||||
|
import io.novafoundation.nova.common.utils.isValidSolanaAddress
|
||||||
|
import io.novafoundation.nova.common.utils.solanaAddressToAccountId
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.SubmissionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.model.Fee
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.model.SolanaFee
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.CallExecutionType
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SubmissionHierarchy
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.requireMetaAccountFor
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdIn
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
|
||||||
|
import io.novafoundation.nova.runtime.ext.commissionAsset
|
||||||
|
import io.novafoundation.nova.runtime.ext.requireSolanaRpcBaseUrl
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.AccountId
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds, signs and broadcasts a Solana native SOL transfer from this wallet's own Ed25519 account - see
|
||||||
|
* `common/utils/SolanaTransaction.kt` for the wire-format primitives (cross-validated against the `solders`
|
||||||
|
* Python library) and `SecretsSigner`/`RealSecretsMetaAccount.multiChainEncryptionIn` for how a Solana account
|
||||||
|
* gets routed to `Signer.signEd25519` (raw-message Ed25519, no external hashing - matches Solana's own
|
||||||
|
* signing model exactly, unlike Bitcoin/Ethereum's hash-then-sign schemes).
|
||||||
|
*
|
||||||
|
* Unlike Bitcoin (UTXO selection) or Tron (server-assisted transaction construction), Solana's account model
|
||||||
|
* needs only two network round-trips beyond fee/broadcast themselves: `getLatestBlockhash` (a transaction is
|
||||||
|
* only valid against a recent one, ~60-90s window) and `getFeeForMessage` (the network's authoritative,
|
||||||
|
* flat-rate-per-signature fee for this exact message - not a client-side estimate).
|
||||||
|
*/
|
||||||
|
class RealSolanaTransactionService(
|
||||||
|
private val accountRepository: AccountRepository,
|
||||||
|
private val signerProvider: SignerProvider,
|
||||||
|
private val solanaApi: SolanaApi,
|
||||||
|
) : SolanaTransactionService {
|
||||||
|
|
||||||
|
override suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountLamports: BigInteger): Fee {
|
||||||
|
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
|
||||||
|
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
|
||||||
|
val baseUrl = chain.requireSolanaRpcBaseUrl()
|
||||||
|
|
||||||
|
val recentBlockhash = solanaApi.fetchLatestBlockhash(baseUrl)
|
||||||
|
|
||||||
|
// Solana's fee is a flat rate per required signature (always 1 here) - it does not depend on the
|
||||||
|
// recipient or amount, so a syntactically-valid placeholder recipient is fine when the real one can't
|
||||||
|
// be parsed yet (e.g. mid-typing in the send UI), same tolerance Bitcoin's calculateFee has.
|
||||||
|
val recipientAccountId = recipientAddress.takeIf { it.isValidSolanaAddress() }
|
||||||
|
?.solanaAddressToAccountId()
|
||||||
|
?: emptySolanaAccountId()
|
||||||
|
|
||||||
|
val message = SolanaTransaction.buildTransferMessage(ownerAccountId, recipientAccountId, lamports = 0L, recentBlockhash)
|
||||||
|
val feeLamports = solanaApi.calculateFeeForMessage(baseUrl, message)
|
||||||
|
|
||||||
|
return SolanaFee(feeLamports, SubmissionOrigin.singleOrigin(ownerAccountId), chain.commissionAsset)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transact(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipientAddress: String,
|
||||||
|
presetFee: Fee?,
|
||||||
|
amountLamports: BigInteger
|
||||||
|
): Result<ExtrinsicSubmission> = runCatching {
|
||||||
|
val submittingMetaAccount = accountRepository.requireMetaAccountFor(origin, chain.id)
|
||||||
|
val ownerAccountId = submittingMetaAccount.requireAccountIdIn(chain)
|
||||||
|
val baseUrl = chain.requireSolanaRpcBaseUrl()
|
||||||
|
|
||||||
|
val recipientAccountId = recipientAddress.solanaAddressToAccountId()
|
||||||
|
val recentBlockhash = solanaApi.fetchLatestBlockhash(baseUrl)
|
||||||
|
|
||||||
|
val message = SolanaTransaction.buildTransferMessage(ownerAccountId, recipientAccountId, amountLamports.toLong(), recentBlockhash)
|
||||||
|
|
||||||
|
val signer = signerProvider.rootSignerFor(submittingMetaAccount)
|
||||||
|
val signedRaw = signer.signRaw(SignerPayloadRaw(message = message, accountId = ownerAccountId, skipMessageHashing = true))
|
||||||
|
val signature = signedRaw.signatureWrapper.signature
|
||||||
|
|
||||||
|
val signedTransaction = SolanaTransaction.serializeSigned(message, signature)
|
||||||
|
val txSignature = solanaApi.broadcastTransaction(baseUrl, signedTransaction)
|
||||||
|
|
||||||
|
ExtrinsicSubmission(
|
||||||
|
hash = txSignature,
|
||||||
|
submissionOrigin = SubmissionOrigin.singleOrigin(ownerAccountId),
|
||||||
|
callExecutionType = CallExecutionType.IMMEDIATE,
|
||||||
|
submissionHierarchy = SubmissionHierarchy(submittingMetaAccount, CallExecutionType.IMMEDIATE)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transactAndAwaitExecution(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipientAddress: String,
|
||||||
|
presetFee: Fee?,
|
||||||
|
amountLamports: BigInteger
|
||||||
|
): Result<TransactionExecution> {
|
||||||
|
// Broadcast acceptance is already a strong signal (same posture as Bitcoin/Tron/EVM) - this sits
|
||||||
|
// outside the primary send flow's critical path, which only ever calls transact().
|
||||||
|
return transact(chain, origin, recipientAddress, presetFee, amountLamports).map { TransactionExecution.Solana(it.hash) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction
|
||||||
|
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicSubmission
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.model.Fee
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.TransactionExecution
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors [io.novafoundation.nova.feature_wallet_impl.data.network.bitcoin.transaction.BitcoinTransactionService]'s
|
||||||
|
* shape (calculateFee/transact/transactAndAwaitExecution over a sending origin), but for Solana's simpler
|
||||||
|
* account-model native transfer - see `RealSolanaTransactionService` for the message-build/sign/broadcast detail.
|
||||||
|
*/
|
||||||
|
interface SolanaTransactionService {
|
||||||
|
|
||||||
|
suspend fun calculateFee(chain: Chain, origin: TransactionOrigin, recipientAddress: String, amountLamports: BigInteger): Fee
|
||||||
|
|
||||||
|
suspend fun transact(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipientAddress: String,
|
||||||
|
presetFee: Fee?,
|
||||||
|
amountLamports: BigInteger
|
||||||
|
): Result<ExtrinsicSubmission>
|
||||||
|
|
||||||
|
suspend fun transactAndAwaitExecution(
|
||||||
|
chain: Chain,
|
||||||
|
origin: TransactionOrigin,
|
||||||
|
recipientAddress: String,
|
||||||
|
presetFee: Fee?,
|
||||||
|
amountLamports: BigInteger
|
||||||
|
): Result<TransactionExecution>
|
||||||
|
}
|
||||||
+3
@@ -23,6 +23,7 @@ import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets
|
|||||||
EquilibriumAssetsModule::class,
|
EquilibriumAssetsModule::class,
|
||||||
TronAssetsModule::class,
|
TronAssetsModule::class,
|
||||||
BitcoinAssetsModule::class,
|
BitcoinAssetsModule::class,
|
||||||
|
SolanaAssetsModule::class,
|
||||||
UnsupportedAssetsModule::class
|
UnsupportedAssetsModule::class
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -40,6 +41,7 @@ class AssetsModule {
|
|||||||
@TronNativeAssets tronNative: Lazy<AssetSource>,
|
@TronNativeAssets tronNative: Lazy<AssetSource>,
|
||||||
@Trc20Assets trc20: Lazy<AssetSource>,
|
@Trc20Assets trc20: Lazy<AssetSource>,
|
||||||
@BitcoinNativeAssets bitcoinNative: Lazy<AssetSource>,
|
@BitcoinNativeAssets bitcoinNative: Lazy<AssetSource>,
|
||||||
|
@SolanaNativeAssets solanaNative: Lazy<AssetSource>,
|
||||||
@UnsupportedAssets unsupported: AssetSource,
|
@UnsupportedAssets unsupported: AssetSource,
|
||||||
|
|
||||||
nativeAssetEventDetector: NativeAssetEventDetector,
|
nativeAssetEventDetector: NativeAssetEventDetector,
|
||||||
@@ -56,6 +58,7 @@ class AssetsModule {
|
|||||||
tronNativeSource = tronNative,
|
tronNativeSource = tronNative,
|
||||||
trc20Source = trc20,
|
trc20Source = trc20,
|
||||||
bitcoinNativeSource = bitcoinNative,
|
bitcoinNativeSource = bitcoinNative,
|
||||||
|
solanaNativeSource = solanaNative,
|
||||||
unsupportedBalanceSource = unsupported,
|
unsupportedBalanceSource = unsupported,
|
||||||
|
|
||||||
nativeAssetEventDetector = nativeAssetEventDetector,
|
nativeAssetEventDetector = nativeAssetEventDetector,
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.di.modules
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import io.novafoundation.nova.common.data.network.NetworkApiCreator
|
||||||
|
import io.novafoundation.nova.common.di.scope.FeatureScope
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.cache.AssetCache
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSource
|
||||||
|
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.StaticAssetSource
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.balances.solanaNative.SolanaNativeAssetBalance
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.history.UnsupportedAssetHistory
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.blockchain.assets.transfers.solanaNative.SolanaNativeAssetTransfers
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.RealSolanaApi
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.RetrofitSolanaApi
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction.RealSolanaTransactionService
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction.SolanaTransactionService
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
annotation class SolanaNativeAssets
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Solana support: `balance` (JSON-RPC `getBalance` polling) and `transfers` (client-side message build,
|
||||||
|
* Ed25519 signing, `sendTransaction` broadcast - see `RealSolanaTransactionService` for the full notes).
|
||||||
|
*
|
||||||
|
* `history` remains unsupported (out of scope for this phase, same as the rest of the app's `Unsupported*`
|
||||||
|
* stubs used for asset types without history support - see `BitcoinAssetsModule`/`TronAssetsModule` for the
|
||||||
|
* identical precedent).
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
class SolanaAssetsModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideRetrofitSolanaApi(
|
||||||
|
networkApiCreator: NetworkApiCreator
|
||||||
|
): RetrofitSolanaApi = networkApiCreator.create(RetrofitSolanaApi::class.java)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideSolanaApi(retrofitSolanaApi: RetrofitSolanaApi): SolanaApi = RealSolanaApi(retrofitSolanaApi)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideSolanaTransactionService(
|
||||||
|
accountRepository: AccountRepository,
|
||||||
|
signerProvider: SignerProvider,
|
||||||
|
solanaApi: SolanaApi,
|
||||||
|
): SolanaTransactionService = RealSolanaTransactionService(
|
||||||
|
accountRepository = accountRepository,
|
||||||
|
signerProvider = signerProvider,
|
||||||
|
solanaApi = solanaApi
|
||||||
|
)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideSolanaNativeBalance(assetCache: AssetCache, solanaApi: SolanaApi) = SolanaNativeAssetBalance(assetCache, solanaApi)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@FeatureScope
|
||||||
|
fun provideSolanaNativeAssetTransfers(
|
||||||
|
solanaTransactionService: SolanaTransactionService,
|
||||||
|
assetSourceRegistry: AssetSourceRegistry,
|
||||||
|
) = SolanaNativeAssetTransfers(solanaTransactionService, assetSourceRegistry)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@SolanaNativeAssets
|
||||||
|
@FeatureScope
|
||||||
|
fun provideSolanaNativeAssetSource(
|
||||||
|
solanaNativeAssetBalance: SolanaNativeAssetBalance,
|
||||||
|
solanaNativeAssetTransfers: SolanaNativeAssetTransfers,
|
||||||
|
unsupportedAssetHistory: UnsupportedAssetHistory,
|
||||||
|
): AssetSource = StaticAssetSource(
|
||||||
|
transfers = solanaNativeAssetTransfers,
|
||||||
|
balance = solanaNativeAssetBalance,
|
||||||
|
history = unsupportedAssetHistory
|
||||||
|
)
|
||||||
|
}
|
||||||
+218
@@ -0,0 +1,218 @@
|
|||||||
|
package io.novafoundation.nova.feature_wallet_impl.data.network.solana.transaction
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.utils.Base58
|
||||||
|
import io.novafoundation.nova.common.utils.Precision
|
||||||
|
import io.novafoundation.nova.common.utils.SolanaTransaction
|
||||||
|
import io.novafoundation.nova.common.utils.TokenSymbol
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.NovaSigner
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.signer.SignerProvider
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
|
||||||
|
import io.novafoundation.nova.feature_wallet_impl.data.network.solana.SolanaApi
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import io.novasama.substrate_sdk_android.encrypt.SignatureWrapper
|
||||||
|
import io.novasama.substrate_sdk_android.extensions.fromHex
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignedRaw
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.extrinsic.signer.SignerPayloadRaw
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.mockito.ArgumentMatcher
|
||||||
|
import org.mockito.Mock
|
||||||
|
import org.mockito.Mockito
|
||||||
|
import org.mockito.Mockito.verify
|
||||||
|
import org.mockito.junit.MockitoJUnitRunner
|
||||||
|
|
||||||
|
// Same guaranteed-non-null-return wrappers as RealTronTransactionServiceTest, and for the same reason - see
|
||||||
|
// that test's class doc for the full writeup.
|
||||||
|
private fun <T> eq(value: T): T = Mockito.eq(value) ?: value
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> any(): T {
|
||||||
|
Mockito.any<T>()
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
private fun <T> argThat(matcher: (T) -> Boolean): T {
|
||||||
|
Mockito.argThat(ArgumentMatcher<T> { matcher(it) })
|
||||||
|
return null as T
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> whenever(methodCall: T?) = Mockito.`when`(methodCall)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Covers [RealSolanaTransactionService.transact]/[RealSolanaTransactionService.calculateFee] - the
|
||||||
|
* message-build/sign/broadcast pipeline. Reuses the exact sender/recipient/blockhash/amount fixture that
|
||||||
|
* [io.novafoundation.nova.common.utils.SolanaTransactionTest] cross-validated against the `solders` Python
|
||||||
|
* library, so a regression in what THIS service hands to the signer/broadcaster is caught independently of
|
||||||
|
* that lower-level wire-format test.
|
||||||
|
*/
|
||||||
|
@RunWith(MockitoJUnitRunner::class)
|
||||||
|
class RealSolanaTransactionServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var accountRepository: AccountRepository
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var signerProvider: SignerProvider
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var solanaApi: SolanaApi
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var metaAccount: MetaAccount
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
lateinit var signer: NovaSigner
|
||||||
|
|
||||||
|
private lateinit var subject: RealSolanaTransactionService
|
||||||
|
|
||||||
|
private val senderPublicKey = "8a88e3dd7409f195fd52db2d3cba5d72ca6709bf1d94121bf3748801b40f6f5".fromHex()
|
||||||
|
private val recipientPublicKey = "c8139770ea87d175f56a35466c34c7ecccb8d8a91b4ee37a25df60f5b8fc9b3".fromHex()
|
||||||
|
private val recipientAddress = Base58.encode(recipientPublicKey)
|
||||||
|
private val recentBlockhash = ByteArray(32)
|
||||||
|
private val lamports = 123456789L
|
||||||
|
|
||||||
|
private val baseUrl = "https://api.mainnet-beta.solana.com"
|
||||||
|
private val chain = solanaChain(baseUrl)
|
||||||
|
|
||||||
|
private val expectedTransferMessage = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports, recentBlockhash)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setup() {
|
||||||
|
subject = RealSolanaTransactionService(accountRepository, signerProvider, solanaApi)
|
||||||
|
|
||||||
|
whenever(metaAccount.accountIdIn(eq(chain))).thenReturn(senderPublicKey)
|
||||||
|
whenever(signerProvider.rootSignerFor(eq(metaAccount))).thenReturn(signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transact should build the transfer message, sign it raw with skipMessageHashing, and broadcast the serialized signed transaction`(): Unit = runBlocking {
|
||||||
|
val signature = ByteArray(64) { it.toByte() }
|
||||||
|
val fakeSignedRaw = SignedRaw(
|
||||||
|
SignerPayloadRaw(message = expectedTransferMessage, accountId = senderPublicKey, skipMessageHashing = true),
|
||||||
|
SignatureWrapper.Ed25519(signature = signature)
|
||||||
|
)
|
||||||
|
|
||||||
|
whenever(solanaApi.fetchLatestBlockhash(eq(baseUrl))).thenReturn(recentBlockhash)
|
||||||
|
whenever(signer.signRaw(any())).thenReturn(fakeSignedRaw)
|
||||||
|
whenever(solanaApi.broadcastTransaction(eq(baseUrl), any())).thenReturn("some-broadcast-signature")
|
||||||
|
|
||||||
|
val result = subject.transact(
|
||||||
|
chain = chain,
|
||||||
|
origin = TransactionOrigin.Wallet(metaAccount),
|
||||||
|
recipientAddress = recipientAddress,
|
||||||
|
presetFee = null,
|
||||||
|
amountLamports = lamports.toBigInteger()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(result.isSuccess)
|
||||||
|
assertEquals("some-broadcast-signature", result.getOrThrow().hash)
|
||||||
|
|
||||||
|
// The message actually handed to the signer must be the exact compiled transfer message, not some other
|
||||||
|
// byte sequence - a regression here would silently produce a signature over the wrong bytes.
|
||||||
|
verify(signer).signRaw(
|
||||||
|
argThat<SignerPayloadRaw> { payload ->
|
||||||
|
payload.message.contentEquals(expectedTransferMessage) &&
|
||||||
|
payload.accountId.contentEquals(senderPublicKey) &&
|
||||||
|
payload.skipMessageHashing
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// The broadcast transaction must be compact-u16(1) + the raw 64-byte Ed25519 signature + the message,
|
||||||
|
// exactly as SolanaTransaction.serializeSigned defines it - no DER/other re-encoding, unlike Bitcoin.
|
||||||
|
val expectedSignedTransaction = SolanaTransaction.serializeSigned(expectedTransferMessage, signature)
|
||||||
|
verify(solanaApi).broadcastTransaction(baseUrl, expectedSignedTransaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calculateFee should price a real message built against the latest blockhash and return it as SolanaFee`(): Unit = runBlocking {
|
||||||
|
whenever(solanaApi.fetchLatestBlockhash(eq(baseUrl))).thenReturn(recentBlockhash)
|
||||||
|
|
||||||
|
val expectedFeeMessage = SolanaTransaction.buildTransferMessage(senderPublicKey, recipientPublicKey, lamports = 0L, recentBlockhash)
|
||||||
|
whenever(solanaApi.calculateFeeForMessage(eq(baseUrl), eq(expectedFeeMessage))).thenReturn(5000.toBigInteger())
|
||||||
|
|
||||||
|
val fee = subject.calculateFee(
|
||||||
|
chain = chain,
|
||||||
|
origin = TransactionOrigin.Wallet(metaAccount),
|
||||||
|
recipientAddress = recipientAddress,
|
||||||
|
amountLamports = lamports.toBigInteger()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(5000.toBigInteger(), fee.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `calculateFee should fall back to a placeholder recipient when the real one can't be parsed yet`(): Unit = runBlocking {
|
||||||
|
whenever(solanaApi.fetchLatestBlockhash(eq(baseUrl))).thenReturn(recentBlockhash)
|
||||||
|
whenever(solanaApi.calculateFeeForMessage(eq(baseUrl), any())).thenReturn(5000.toBigInteger())
|
||||||
|
|
||||||
|
// A blank recipient (e.g. mid-typing in the send UI) must not throw - Solana's fee only depends on the
|
||||||
|
// signature count, not on which recipient is used, so this should still price successfully.
|
||||||
|
val fee = subject.calculateFee(
|
||||||
|
chain = chain,
|
||||||
|
origin = TransactionOrigin.Wallet(metaAccount),
|
||||||
|
recipientAddress = "",
|
||||||
|
amountLamports = lamports.toBigInteger()
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(5000.toBigInteger(), fee.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun solanaChain(baseUrl: String): Chain {
|
||||||
|
val solAsset = Chain.Asset(
|
||||||
|
icon = null,
|
||||||
|
id = 0,
|
||||||
|
priceId = "solana",
|
||||||
|
chainId = "solana:mainnet",
|
||||||
|
symbol = TokenSymbol("SOL"),
|
||||||
|
precision = Precision(9),
|
||||||
|
buyProviders = emptyMap(),
|
||||||
|
sellProviders = emptyMap(),
|
||||||
|
staking = emptyList(),
|
||||||
|
type = Chain.Asset.Type.SolanaNative,
|
||||||
|
source = Chain.Asset.Source.DEFAULT,
|
||||||
|
name = "Solana",
|
||||||
|
enabled = true
|
||||||
|
)
|
||||||
|
|
||||||
|
return Chain(
|
||||||
|
id = "solana:mainnet",
|
||||||
|
name = "Solana",
|
||||||
|
assets = listOf(solAsset),
|
||||||
|
nodes = Chain.Nodes(
|
||||||
|
autoBalanceStrategy = Chain.Nodes.AutoBalanceStrategy.ROUND_ROBIN,
|
||||||
|
wssNodeSelectionStrategy = Chain.Nodes.NodeSelectionStrategy.AutoBalance,
|
||||||
|
nodes = listOf(Chain.Node(chainId = "solana:mainnet", unformattedUrl = baseUrl, name = "Solana", orderId = 0, isCustom = false))
|
||||||
|
),
|
||||||
|
explorers = emptyList(),
|
||||||
|
externalApis = emptyList(),
|
||||||
|
icon = null,
|
||||||
|
addressPrefix = 0,
|
||||||
|
legacyAddressPrefix = null,
|
||||||
|
types = null,
|
||||||
|
isEthereumBased = false,
|
||||||
|
isTronBased = false,
|
||||||
|
isBitcoinBased = false,
|
||||||
|
isSolanaBased = true,
|
||||||
|
isTestNet = false,
|
||||||
|
source = Chain.Source.DEFAULT,
|
||||||
|
hasSubstrateRuntime = false,
|
||||||
|
pushSupport = false,
|
||||||
|
hasCrowdloans = false,
|
||||||
|
supportProxy = false,
|
||||||
|
governance = emptyList(),
|
||||||
|
swap = emptyList(),
|
||||||
|
customFee = emptyList(),
|
||||||
|
multisigSupport = false,
|
||||||
|
connectionState = Chain.ConnectionState.FULL_SYNC,
|
||||||
|
parentId = null,
|
||||||
|
additional = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -258,6 +258,7 @@ class RealTronTransactionServiceTest {
|
|||||||
isEthereumBased = false,
|
isEthereumBased = false,
|
||||||
isTronBased = true,
|
isTronBased = true,
|
||||||
isBitcoinBased = false,
|
isBitcoinBased = false,
|
||||||
|
isSolanaBased = false,
|
||||||
isTestNet = false,
|
isTestNet = false,
|
||||||
source = Chain.Source.DEFAULT,
|
source = Chain.Source.DEFAULT,
|
||||||
hasSubstrateRuntime = false,
|
hasSubstrateRuntime = false,
|
||||||
|
|||||||
Reference in New Issue
Block a user