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:
2026-07-16 18:49:29 -07:00
parent d35687f2ce
commit ecdb5ee44c
18 changed files with 1008 additions and 0 deletions
@@ -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()
}
}
}