mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 18:25:50 +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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user