Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions clients/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ android {
applicationId = "com.v0id.wallet"
minSdk = 26
targetSdk = 35
versionCode = 6
versionName = "0.1.7"
versionCode = 7
versionName = "0.1.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.v0id.wallet.core.*
import com.v0id.wallet.data.ChainCache
import com.v0id.wallet.data.KeyVault
import com.v0id.wallet.net.WsClient
import kotlinx.coroutines.Dispatchers
Expand Down Expand Up @@ -83,6 +84,7 @@ fun shortAddress(a: String): String =
class WalletViewModel(app: Application) : AndroidViewModel(app) {

private val vault = KeyVault(app)
private val chainCache = ChainCache(app)
private var wallet: Wallet? = null

private val _ui = MutableStateFlow(WalletUi())
Expand Down Expand Up @@ -118,6 +120,8 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
val hex = withContext(Dispatchers.IO) { vault.loadPrivateKeyHex() }
val savedNode = withContext(Dispatchers.IO) { vault.nodeUrl }
val savedPeers = withContext(Dispatchers.IO) { vault.knownPeers }
// 读本地已同步的链——App 重开(冷启动/被系统杀后台再拉起)不用每次都问节点要整条链。
val cachedChain = withContext(Dispatchers.IO) { chainCache.load() }
if (savedPeers.isNotBlank()) backupPeers.addAll(savedPeers.split(",").filter { it.isNotBlank() })
val node = savedNode.ifBlank { DEFAULT_SEED_WS }
if (hex != null) {
Expand All @@ -129,9 +133,10 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
hasWallet = wallet != null,
address = wallet?.address ?: "",
nodeUrl = node,
chain = cachedChain,
)
}
if (wallet != null) connect()
if (wallet != null) { recompute(); connect() }
}
}

Expand Down Expand Up @@ -183,8 +188,10 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
return
}
viewModelScope.launch(Dispatchers.IO) { vault.nodeUrl = clean }
// 切换节点 = 重新以该节点的视角同步:清空旧链与待发交易,避免“旧链更高 → 新节点较短链被忽略”。
// 切换节点 = 重新以该节点的视角同步:清空旧链与待发交易,避免“旧链更高 → 新节点较短链被忽略”;
// 落盘缓存也一并清掉,否则下次冷启动又把旧节点的链读回来。
pending.clear()
viewModelScope.launch(Dispatchers.IO) { chainCache.save(emptyList()) }
_ui.update { it.copy(nodeUrl = clean, chain = emptyList()) }
recompute()
if (wallet != null) {
Expand All @@ -198,7 +205,8 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
wantConnected = true
val url = _ui.value.nodeUrl
lastTargetUrl = url
ws.connect(url, w.address, isBootstrap = true)
val knownHeight = _ui.value.chain.lastOrNull()?.index ?: -1L
ws.connect(url, w.address, isBootstrap = true, knownHeight = knownHeight)
}

fun disconnect() {
Expand Down Expand Up @@ -287,7 +295,8 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
val isBootstrap = targetUrl == _ui.value.nodeUrl
appendLog(if (isBootstrap) "正在重连…" else "种子不可达,切换备用节点…")
lastTargetUrl = targetUrl
ws.connect(targetUrl, w.address, isBootstrap = isBootstrap)
val knownHeight = _ui.value.chain.lastOrNull()?.index ?: -1L
ws.connect(targetUrl, w.address, isBootstrap = isBootstrap, knownHeight = knownHeight)
}
}

Expand All @@ -298,6 +307,7 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
when (merged) {
is Merge.Replace -> { setChain(merged.chain); appendLog("已同步至 #${merged.chain.last().index}") }
is Merge.Append -> { setChain(merged.chain); appendLog("新块 #${merged.chain.last().index}") }
is Merge.NeedRange -> ws.requestRange(merged.from, merged.to)
Merge.NeedFull -> ws.requestChain()
Merge.Ignore -> {}
}
Expand All @@ -306,28 +316,43 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
private sealed interface Merge {
data class Replace(val chain: List<Block>) : Merge
data class Append(val chain: List<Block>) : Merge
data class NeedRange(val from: Long, val to: Long) : Merge
data object NeedFull : Merge
data object Ignore : Merge
}

/** 单次 QUERY_BLOCK_RANGE 最多补多少块(节点端上限 10_000,留余量分批)。 */
private val maxRangeRequest = 5_000L

/** 合并收到的区块:整链响应(QUERY_ALL)从创世校验哈希链后更长则采纳;紧接链尾的一段先校验
* 哈希链自洽再追加;有缺口时只问缺的那一段(QUERY_BLOCK_RANGE),不用整链重拉——这是重开钱包
* 不必每次全量同步的关键。哈希/prevHash 校验不过(本地缓存跟这个节点视角不一致)才兜底整链重拉。 */
private fun mergeChain(current: List<Block>, incoming: List<Block>): Merge {
if (incoming.isEmpty()) return Merge.Ignore
val isFull = incoming.first().index == 0L
if (current.isEmpty()) {
return if (isFull) Merge.Replace(incoming) else Merge.NeedFull
val first = incoming.first()
if (first.index == 0L) {
// 更长,或等长但链尾 hash 不同(同高分叉/换了视角不同的节点)才采纳——严格要求"更长"
// 会让同高分叉/换节点后的整链重灌被拒绝,永远卡在旧缓存上刷新不动。
val tipDiffers = incoming.size == current.size && incoming.last().hash != current.lastOrNull()?.hash
if (incoming.size <= current.size && !tipDiffers) return Merge.Ignore
return if (verifyChainLink(incoming, afterIndex = -1, tipHash = "")) Merge.Replace(incoming) else Merge.Ignore
}
if (current.isEmpty()) return Merge.NeedFull // 没有缓存/链,非整链响应帮不上忙,兜底整链要
val currentTop = current.last().index
val incomingTop = incoming.last().index
if (isFull) {
return if (incomingTop > currentTop) Merge.Replace(incoming) else Merge.Ignore
}
// 增量
if (incomingTop <= currentTop) return Merge.Ignore
val first = incoming.first()
return if (first.index == currentTop + 1 && first.prevHash == current.last().hash) {
Merge.Append(current + incoming)
} else {
Merge.NeedFull // 有缺口或分叉 → 重拉全链
return when {
first.index == currentTop + 1 ->
if (verifyChainLink(incoming, afterIndex = currentTop, tipHash = current.last().hash)) {
Merge.Append(current + incoming)
} else {
Merge.NeedFull // 紧接着但哈希/prevHash 对不上:视角不一致,整链重拉
}
first.index > currentTop + 1 -> {
val to = minOf(incomingTop, currentTop + maxRangeRequest)
Merge.NeedRange(currentTop + 1, to)
}
else -> Merge.Ignore // first.index <= currentTop:已有的旧数据
}
}

Expand All @@ -340,6 +365,7 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) {
}
_ui.update { it.copy(chain = chain) }
recompute()
viewModelScope.launch(Dispatchers.IO) { chainCache.save(chain) }
}

// ---- 重算派生状态 ----
Expand Down
20 changes: 20 additions & 0 deletions clients/android/app/src/main/java/com/v0id/wallet/core/Block.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,23 @@ data class Block(
val miner: String,
val hash: String,
)

/** 区块哈希:覆盖头部所有字段,与 packages/core/src/block.ts 的 calcBlockHash 逐字节一致。
* 交易通过 merkleRoot 间接承诺。用于本地缓存/收到的新块的完整性校验。 */
fun Block.calcHash(): String =
sha256Hex(JsonStringify.array(listOf(index, timestamp, prevHash, merkleRoot, difficulty, nonce, miner)))

/** 校验一段区块能接到 afterIndex/tipHash 指定的链尾上:高度连续 + prevHash 衔接 + 逐块哈希自洽。
* tipHash 为空串=从创世块开始验(不查第一块的 prevHash)。用于本地缓存读回校验,也用于收到的新块。 */
fun verifyChainLink(blocks: List<Block>, afterIndex: Long, tipHash: String): Boolean {
var prevIndex = afterIndex
var prevHash = tipHash
for (b in blocks) {
if (b.index != prevIndex + 1) return false
if (prevHash.isNotEmpty() && b.prevHash != prevHash) return false
if (b.calcHash() != b.hash) return false
prevIndex = b.index
prevHash = b.hash
}
return true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.v0id.wallet.data

import android.content.Context
import com.v0id.wallet.core.Block
import com.v0id.wallet.core.verifyChainLink
import com.v0id.wallet.net.ChainCodec
import org.json.JSONArray
import java.io.File

/**
* 本地已同步区块的落盘缓存:App 重开(冷启动/被系统杀后台再拉起)不用每次都问节点要整条链,
* 只读本地缓存的链尾高度,向节点补拉缺口(QUERY_BLOCK_RANGE)。
*
* 存内部私有文件(非 EncryptedSharedPreferences):链数据体量可能较大,SharedPreferences 不适合,
* 且链本身不含私钥等敏感信息,不需要加密——完整性由读回时的哈希链校验保证(防篡改/损坏)。
*/
class ChainCache(context: Context) {
private val file = File(context.filesDir, "chain-cache.json")

/** 读本地缓存的链;文件不存在/损坏/哈希链校验不过 → 视为空缓存(调用方退回整链同步)。 */
fun load(): List<Block> {
if (!file.exists()) return emptyList()
return try {
val arr = JSONArray(file.readText())
val blocks = ChainCodec.parseBlocks(arr)
if (verifyChainLink(blocks, afterIndex = -1, tipHash = "")) blocks else emptyList()
} catch (e: Exception) {
emptyList()
}
}

fun save(chain: List<Block>) {
try {
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(ChainCodec.blocksToJson(chain).toString())
tmp.renameTo(file) // 原子替换,避免写到一半被杀进程留半个文件
} catch (e: Exception) {
// 落盘失败不致命:下次重开退回整链同步,故意静默。
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ object ChainCodec {
)
}

fun blocksToJson(blocks: List<Block>): JSONArray {
val arr = JSONArray()
for (b in blocks) arr.put(blockToJson(b))
return arr
}

fun blockToJson(b: Block): JSONObject {
val o = JSONObject()
o.put("index", b.index)
o.put("timestamp", b.timestamp)
o.put("prevHash", b.prevHash)
val txs = JSONArray()
for (tx in b.transactions) txs.put(txToJson(tx))
o.put("transactions", txs)
o.put("merkleRoot", b.merkleRoot)
o.put("difficulty", b.difficulty)
o.put("nonce", b.nonce)
o.put("miner", b.miner)
o.put("hash", b.hash)
return o
}

/** 交易 → JSON。burn 仅在 >0 时写入(与 packages/core 一致:普通转账无 burn 字段)。 */
fun txToJson(tx: Transaction): JSONObject {
val o = JSONObject()
Expand Down
71 changes: 65 additions & 6 deletions clients/android/app/src/main/java/com/v0id/wallet/net/WsClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ class WsClient(
// 每条连接唯一的 HELLO listen 后缀:节点端按 listen 给连接判重(p2p.ts),同一客户端
// 自动重连时若沿用旧 listen,可能与服务端尚未清理的陈旧连接对撞被踢 → 永久抖动。
private var connToken: String = ""
// 本地已缓存的链尾高度(-1 = 无缓存):决定握手要整链(QUERY_ALL)还是只探最新块(QUERY_LATEST)。
private var knownHeight: Long = -1

// ---- 分块同步缓冲:节点把长链拆成多片 BLOCKS 发(packages/node/src/p2p.ts 的 CHUNK=500),
// 每片都带 from/total;只有收满 total 块才是完整的一次 QUERY_ALL 回应,提前当完整链处理
// 会把每一片单独拿去和本地缓存比较,长度/衔接对不上就被误判丢弃,卡死在旧缓存上。
private var chunkBuffer = mutableListOf<Block>()
private var chunkTotal = -1L
// 在途的 QUERY_BLOCK_RANGE 请求起点:响应帧同样带 from/total,但那是"全链长度"不是"这次
// 请求的块数",且节点端从不分片发它(单帧顶格)——命中就直接当完整答案处理,不进分片累积。
private var pendingRangeFrom: Long? = null

@Volatile var status: Status = Status.DISCONNECTED
private set
Expand All @@ -50,10 +61,12 @@ class WsClient(
// gossip peer 失败是正常 P2P 现象(TUN 代理下死 peer 握手被代理接管 → HTTP 502/504),不上报日志。
@Volatile private var isBootstrap = true

fun connect(url: String, address: String, isBootstrap: Boolean = true) {
fun connect(url: String, address: String, isBootstrap: Boolean = true, knownHeight: Long = -1) {
this.isBootstrap = isBootstrap
this.knownHeight = knownHeight
userClosed = false
myAddress = address
resetChunkState() // 新连接不带上一条连接的残片/在途请求状态
connToken = UUID.randomUUID().toString().take(8) // 本次连接唯一,避免 listen 自我对撞
setStatus(Status.CONNECTING)
val trimmed = url.trim()
Expand Down Expand Up @@ -81,14 +94,28 @@ class WsClient(
userClosed = true
ws?.close(1000, "bye")
ws = null
resetChunkState()
setStatus(Status.DISCONNECTED)
}

private fun resetChunkState() {
chunkBuffer = mutableListOf()
chunkTotal = -1
pendingRangeFrom = null
}

/** 主动重新拉全链(用户下拉刷新 / 检测到分叉时)。 */
fun requestChain() {
resetChunkState()
ws?.send(JSONObject().put("type", "QUERY_ALL").toString())
}

/** 只补 [from, to] 这一段缺口(本地已有缓存,节点更高时用这个而不是整链重拉)。 */
fun requestRange(from: Long, to: Long) {
pendingRangeFrom = from
ws?.send(JSONObject().put("type", "QUERY_BLOCK_RANGE").put("from", from).put("to", to).toString())
}

/** 广播一笔本地签名的交易。返回是否已写入发送队列。 */
fun broadcastTx(tx: Transaction): Boolean {
val sock = ws ?: return false
Expand All @@ -106,18 +133,25 @@ class WsClient(
private val listener = object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
setStatus(Status.CONNECTED)
// 自报家门(height=0:不被回拨也无所谓)+ 要整条链 + 问邻居
// 自报家门(height 不被回拨也无所谓,纯告知)+ 问邻居。
// 本地已有缓存链(knownHeight>=0)→ 只探最新块,缺口由 onBlocks 里的 QUERY_BLOCK_RANGE 补;
// 无缓存(首次启动)才整条链要(QUERY_ALL)。
webSocket.send(
JSONObject()
.put("type", "HELLO")
.put("address", myAddress)
.put("height", 0)
.put("height", maxOf(0L, knownHeight))
.put("listen", "light://$myAddress/$connToken")
.toString(),
)
webSocket.send(JSONObject().put("type", "QUERY_ALL").toString())
webSocket.send(JSONObject().put("type", "QUERY_PEERS").toString())
onLog("已连接,正在同步全链…")
if (knownHeight < 0) {
webSocket.send(JSONObject().put("type", "QUERY_ALL").toString())
onLog("已连接,正在同步全链…")
} else {
webSocket.send(JSONObject().put("type", "QUERY_LATEST").toString())
onLog("已连接,续同步…")
}
}

override fun onMessage(webSocket: WebSocket, text: String) {
Expand All @@ -127,7 +161,32 @@ class WsClient(
"BLOCKS" -> {
val arr = o.optJSONArray("blocks") ?: return
val blocks = ChainCodec.parseBlocks(arr)
if (blocks.isNotEmpty()) onBlocks(blocks)
if (blocks.isEmpty()) return
val from = if (o.has("from")) o.optLong("from", -1L) else -1L
val total = if (o.has("total")) o.optLong("total", -1L) else -1L
val pending = pendingRangeFrom
when {
pending != null && from == pending -> {
// QUERY_BLOCK_RANGE 的响应:单帧顶格发,直接当完整答案处理。
pendingRangeFrom = null
onBlocks(blocks)
}
from >= 0 && total >= 0 -> {
// QUERY_ALL 的分片流:按 from/total 累积,收满 total 块才是完整链。
if (from == 0L || chunkTotal != total) {
chunkBuffer = mutableListOf()
chunkTotal = total
}
chunkBuffer.addAll(blocks)
if (chunkBuffer.size >= chunkTotal) {
val full = chunkBuffer
chunkBuffer = mutableListOf()
chunkTotal = -1
onBlocks(full)
}
}
else -> onBlocks(blocks)
}
}
"PEERS" -> {
val arr = o.optJSONArray("peers") ?: return
Expand Down
14 changes: 14 additions & 0 deletions clients/ios/V0idKit/Sources/V0idKit/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ public struct Block: Codable, Identifiable, Hashable {
public var hash: String

public var id: Int { index }

/// 区块哈希:覆盖头部所有字段,与 packages/core/src/block.ts 的 calcBlockHash 逐字节一致。
/// 交易通过 merkleRoot 间接承诺。用于本地缓存/收到的新块的完整性校验。
public func calcHash() -> String {
Crypto.sha256Hex(CanonicalJSON.array([
.int(index),
.int(timestamp),
.string(prevHash),
.string(merkleRoot),
.int(difficulty),
.int(nonce),
.string(miner),
]))
}
}

public extension Transaction {
Expand Down
Loading
Loading