diff --git a/clients/android/app/build.gradle.kts b/clients/android/app/build.gradle.kts index 99b6765..85b3f87 100644 --- a/clients/android/app/build.gradle.kts +++ b/clients/android/app/build.gradle.kts @@ -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" } diff --git a/clients/android/app/src/main/java/com/v0id/wallet/WalletViewModel.kt b/clients/android/app/src/main/java/com/v0id/wallet/WalletViewModel.kt index 476232e..727fe9b 100644 --- a/clients/android/app/src/main/java/com/v0id/wallet/WalletViewModel.kt +++ b/clients/android/app/src/main/java/com/v0id/wallet/WalletViewModel.kt @@ -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 @@ -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()) @@ -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) { @@ -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() } } } @@ -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) { @@ -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() { @@ -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) } } @@ -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 -> {} } @@ -306,28 +316,43 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { private sealed interface Merge { data class Replace(val chain: List) : Merge data class Append(val chain: List) : 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, incoming: List): 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:已有的旧数据 } } @@ -340,6 +365,7 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { } _ui.update { it.copy(chain = chain) } recompute() + viewModelScope.launch(Dispatchers.IO) { chainCache.save(chain) } } // ---- 重算派生状态 ---- diff --git a/clients/android/app/src/main/java/com/v0id/wallet/core/Block.kt b/clients/android/app/src/main/java/com/v0id/wallet/core/Block.kt index 8b5bd1d..efc9dde 100644 --- a/clients/android/app/src/main/java/com/v0id/wallet/core/Block.kt +++ b/clients/android/app/src/main/java/com/v0id/wallet/core/Block.kt @@ -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, 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 +} diff --git a/clients/android/app/src/main/java/com/v0id/wallet/data/ChainCache.kt b/clients/android/app/src/main/java/com/v0id/wallet/data/ChainCache.kt new file mode 100644 index 0000000..34349b0 --- /dev/null +++ b/clients/android/app/src/main/java/com/v0id/wallet/data/ChainCache.kt @@ -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 { + 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) { + try { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(ChainCodec.blocksToJson(chain).toString()) + tmp.renameTo(file) // 原子替换,避免写到一半被杀进程留半个文件 + } catch (e: Exception) { + // 落盘失败不致命:下次重开退回整链同步,故意静默。 + } + } +} diff --git a/clients/android/app/src/main/java/com/v0id/wallet/net/ChainCodec.kt b/clients/android/app/src/main/java/com/v0id/wallet/net/ChainCodec.kt index f4579db..963ba68 100644 --- a/clients/android/app/src/main/java/com/v0id/wallet/net/ChainCodec.kt +++ b/clients/android/app/src/main/java/com/v0id/wallet/net/ChainCodec.kt @@ -53,6 +53,28 @@ object ChainCodec { ) } + fun blocksToJson(blocks: List): 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() diff --git a/clients/android/app/src/main/java/com/v0id/wallet/net/WsClient.kt b/clients/android/app/src/main/java/com/v0id/wallet/net/WsClient.kt index a101182..c0c4480 100644 --- a/clients/android/app/src/main/java/com/v0id/wallet/net/WsClient.kt +++ b/clients/android/app/src/main/java/com/v0id/wallet/net/WsClient.kt @@ -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() + private var chunkTotal = -1L + // 在途的 QUERY_BLOCK_RANGE 请求起点:响应帧同样带 from/total,但那是"全链长度"不是"这次 + // 请求的块数",且节点端从不分片发它(单帧顶格)——命中就直接当完整答案处理,不进分片累积。 + private var pendingRangeFrom: Long? = null @Volatile var status: Status = Status.DISCONNECTED private set @@ -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() @@ -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 @@ -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) { @@ -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 diff --git a/clients/ios/V0idKit/Sources/V0idKit/Models.swift b/clients/ios/V0idKit/Sources/V0idKit/Models.swift index 0f692ad..3badf20 100644 --- a/clients/ios/V0idKit/Sources/V0idKit/Models.swift +++ b/clients/ios/V0idKit/Sources/V0idKit/Models.swift @@ -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 { diff --git a/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift b/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift index adf4648..0cc54c8 100644 --- a/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift +++ b/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift @@ -37,6 +37,12 @@ public final class NodeClient: ObservableObject { private var chunkBuffer: [Block] = [] private var chunkTotal: Int = 0 + // ---- 补缺口(QUERY_BLOCK_RANGE)在途请求:响应帧同样带 from/total 字段, + // 靠这个标记把它和 QUERY_ALL 的分片流区分开,不进 chunkBuffer 累积逻辑(total 语义不同—— + // QUERY_BLOCK_RANGE 回的 total 是全链长度,不是这次请求的块数,累积会永远凑不齐卡死)。 + private var pendingRangeFrom: Int? + private static let maxRangeRequest = 5_000 // 单次 QUERY_BLOCK_RANGE 最多补多少块(节点端上限 10_000,留余量) + // ---- 种子失效 fallback:gossip 学到的备用地址 + 连续失败计数 ---- private var backupURLs: [String] = [] private var failCount = 0 @@ -55,6 +61,44 @@ public final class NodeClient: ObservableObject { // 代理(Clash/mihomo),把 ws:// 握手送进代理隧道,代理一抖就断。裸 socket 直连内核 // TCP 栈,绕过系统代理。详见 WebSocket.swift。 self.backupURLs = UserDefaults.standard.stringArray(forKey: "v0id-peer-backup") ?? [] + loadCachedChain() // 读本地已同步的链——重开 App 不用每次都问节点要整条链 + } + + // ---- 本地链缓存(落盘)---- + private static func chainFileURL() -> URL? { + guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } + let v0idDir = dir.appendingPathComponent("v0idchain", isDirectory: true) + try? FileManager.default.createDirectory(at: v0idDir, withIntermediateDirectories: true) + return v0idDir.appendingPathComponent("light-chain.json") + } + + private func loadCachedChain() { + guard let url = Self.chainFileURL(), + let data = try? Data(contentsOf: url), + let cached = try? JSONDecoder().decode([Block].self, from: data), + Self.verifyChainLink(cached, afterIndex: -1, tipHash: "") + else { return } // 文件不存在/损坏/校验不过:当空缓存处理,退回冷启动整链同步 + chain = cached + } + + private func persistChain() { + guard let url = Self.chainFileURL(), let data = try? JSONEncoder().encode(chain) else { return } + try? data.write(to: url, options: .atomic) + } + + /// 校验一段区块能接到 afterIndex/tipHash 指定的链尾上:高度连续 + prevHash 衔接 + 逐块哈希自洽。 + /// tipHash 为空串=从创世块开始验(不查第一块的 prevHash)。用于本地缓存读回校验,也用于收到的新块。 + private nonisolated static func verifyChainLink(_ blocks: [Block], afterIndex: Int, tipHash: String) -> Bool { + var prevIndex = afterIndex + var prevHash = tipHash + for b in blocks { + guard b.index == prevIndex + 1 else { return false } + if !prevHash.isEmpty, b.prevHash != prevHash { return false } + guard b.calcHash() == b.hash else { return false } + prevIndex = b.index + prevHash = b.hash + } + return true } // MARK: - 派生状态 @@ -122,12 +166,19 @@ public final class NodeClient: ObservableObject { guard let self, gen == self.generation else { return } // 连上了 → 重置该 peer 的失败计数(peer 复活) self.gossipFailCounts.removeValue(forKey: self.connectURL) - // 握手:HELLO(height=0,listen 唯一——我们不被回拨)→ 要整条链 + 问邻居 - self.send(.hello(address: self.address ?? Crypto.nullAddress, height: 0, listen: listen)) - self.send(.queryAll) + // 握手:HELLO(listen 唯一——我们不被回拨)+ 问邻居。 + // 本地已有缓存链 → 只探最新块,缺口由 onBlocks 里的 QUERY_BLOCK_RANGE 补; + // 缓存为空(首次启动)才整条链要(QUERY_ALL)。 + self.send(.hello(address: self.address ?? Crypto.nullAddress, height: max(0, self.chain.count - 1), listen: listen)) self.send(.queryPeers) - self.status = .syncing - self.syncingStart = Date() + if self.chain.isEmpty { + self.send(.queryAll) + self.status = .syncing + self.syncingStart = Date() + } else { + self.send(.queryLatest) + self.status = .connected + } } } conn.onText = { [weak self] text in @@ -183,6 +234,7 @@ public final class NodeClient: ObservableObject { ws?.close() ws = nil chunkBuffer = []; chunkTotal = 0 // 旧连接的残片不要带入下一次同步 + pendingRangeFrom = nil // 旧连接的在途补缺口请求作废,换连接后重新判断 if manual { status = .disconnected } } @@ -239,7 +291,14 @@ public final class NodeClient: ObservableObject { case .blocks(let blocks): onBlocks(blocks) case .blocksChunk(let blocks, let from, let total): - onBlocksChunk(blocks, from: from, total: total) + // QUERY_BLOCK_RANGE 的响应也带 from/total,但那是"全链长度"不是"这次请求的块数", + // 且服务端从不分片发它(单帧顶格):命中在途补缺口请求就直接当完整答案处理,别进分片累积。 + if let pending = pendingRangeFrom, from == pending { + pendingRangeFrom = nil + onBlocks(blocks) + } else { + onBlocksChunk(blocks, from: from, total: total) + } case .blocksError(let msg): lastError = msg case .peers(let urls): @@ -267,19 +326,38 @@ public final class NodeClient: ObservableObject { } } - /// 合并收到的区块(信任节点):整链快照直接采纳更长者;单块若正好接续链顶就追加,落后则补拉整链。 + /// 合并收到的区块:多块响应按来源分两种——QUERY_ALL 整链重灌(从创世校验哈希链后更长则采纳), + /// QUERY_BLOCK_RANGE 补缺口(必须紧接链尾、哈希链自洽才追加)。单块响应正好接续链顶且哈希自洽就追加, + /// 落后则问缺的那一段(QUERY_BLOCK_RANGE),不再整链重拉——这是重开钱包不必每次全量同步的关键。 private func onBlocks(_ blocks: [Block]) { - guard let last = blocks.last else { return } + guard let last = blocks.last, let first = blocks.first else { return } + let nextIndex = chain.count if blocks.count >= 2 { - // QUERY_ALL 的整链响应(或多块):采纳更高/同高的节点视图 - if last.index >= height { adopt(blocks) } + if first.index == 0 { + // 更长,或等长但链尾 hash 不同(同高分叉/换了视角不同的节点)才采纳——严格要求 + // "更长" 会让同高分叉/换节点后的整链重灌被拒绝,永远卡在旧缓存上刷新不动。 + let tipDiffers = blocks.count == chain.count && last.hash != chain.last?.hash + guard blocks.count > chain.count || tipDiffers, Self.verifyChainLink(blocks, afterIndex: -1, tipHash: "") else { return } + adopt(blocks) + } else if first.index == nextIndex, Self.verifyChainLink(blocks, afterIndex: nextIndex - 1, tipHash: tipHash) { + chain.append(contentsOf: blocks) + afterChainChanged() + } else if last.index >= nextIndex { + send(.queryAll) // 衔接不上/哈希校验失败:保底整链重拉 + } } else { - if last.index == height + 1, last.prevHash == tipHash { + if last.index == nextIndex, last.prevHash == tipHash, last.calcHash() == last.hash { chain.append(last) afterChainChanged() - } else if last.index > height { - send(.queryAll) // 落后不止一块(或分叉)→ 要整条链 - } else if last.index <= height, last.index < chain.count, chain[last.index].hash != last.hash { + } else if last.index == nextIndex { + // 紧接着但衔接不上/哈希校验失败:本地缓存跟这个节点视角不一致(换过节点/分叉)。 + // 不重灌的话这个不匹配会每次心跳原样重现、永远卡住,所以必须兜底整链重拉。 + send(.queryAll) + } else if last.index > nextIndex { + let to = min(last.index, nextIndex + Self.maxRangeRequest - 1) + pendingRangeFrom = nextIndex + send(.queryBlockRange(from: nextIndex, to: to)) + } else if last.index < chain.count, chain[last.index].hash != last.hash { send(.queryAll) // 同高但 hash 不同(reorg)→ 重新同步 } } @@ -302,6 +380,7 @@ public final class NodeClient: ObservableObject { let onChain = Set(chain.flatMap { $0.transactions.map(\.txid) }) pending.removeAll { onChain.contains($0.txid) } } + persistChain() } /// 把节点 gossip 来的对等地址加入备用池并持久化(过滤掉私网/localhost/已有的)。 diff --git a/clients/ios/V0idKit/Sources/V0idKit/Protocol.swift b/clients/ios/V0idKit/Sources/V0idKit/Protocol.swift index 166abeb..af1ce9e 100644 --- a/clients/ios/V0idKit/Sources/V0idKit/Protocol.swift +++ b/clients/ios/V0idKit/Sources/V0idKit/Protocol.swift @@ -8,6 +8,7 @@ enum OutgoingMessage { case queryAll case queryLatest case queryPeers + case queryBlockRange(from: Int, to: Int) case tx(Transaction) func jsonData() throws -> Data { @@ -21,6 +22,8 @@ enum OutgoingMessage { return try encoder.encode(TypeOnly(type: "QUERY_LATEST")) case .queryPeers: return try encoder.encode(TypeOnly(type: "QUERY_PEERS")) + case let .queryBlockRange(from, to): + return try encoder.encode(QueryBlockRange(type: "QUERY_BLOCK_RANGE", from: from, to: to)) case let .tx(tx): return try encoder.encode(TxMessage(type: "TX", tx: tx)) } @@ -28,6 +31,7 @@ enum OutgoingMessage { private struct TypeOnly: Encodable { let type: String } private struct Hello: Encodable { let type: String; let address: String; let height: Int; let listen: String } + private struct QueryBlockRange: Encodable { let type: String; let from: Int; let to: Int } private struct TxMessage: Encodable { let type: String; let tx: Transaction } } diff --git a/clients/macos/Info.plist b/clients/macos/Info.plist index 10a8e5e..8a0e615 100644 --- a/clients/macos/Info.plist +++ b/clients/macos/Info.plist @@ -18,9 +18,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.7 + 0.1.8 CFBundleVersion - 8 + 9 LSMinimumSystemVersion 13.0 NSHighResolutionCapable diff --git a/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift index 7de095b..b95c4a4 100644 --- a/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift +++ b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift @@ -17,6 +17,7 @@ public actor NodeClient { private static let heartbeat: UInt64 = 5_000_000_000 private static let maintainEvery: UInt64 = 4_000_000_000 private static let retryCooldown: TimeInterval = 15 // 连不上的地址冷却多久再重试 + private static let maxRangeRequest = 5_000 // 单次 QUERY_BLOCK_RANGE 最多补多少块(节点端上限 10_000,留余量分批) private let bootstrap: [String] private let myAddress: String @@ -71,6 +72,7 @@ public actor NodeClient { if let saved = UserDefaults.standard.stringArray(forKey: "v0id-known-peers") { for url in saved { if let n = Self.normalize(url) { known.insert(n) } } } + loadCachedChain() // 读本地已同步的链——重开钱包不用每次都问节点要整条链 maintainTask = Task { [weak self] in await self?.maintainLoop() } } @@ -80,11 +82,52 @@ public actor NodeClient { maintainTask?.cancel() maintainTask = nil for url in Array(conns.keys) { disconnect(url) } - chain = [] + // 注:chain 不再清空——已落盘且经哈希链校验,下次 start() 直接复用,只补新块。 mempool.removeAll() lastPeers = -1 } + // ---- 本地链缓存(落盘)---- + private static func chainFileURL() -> URL? { + guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return nil } + let v0idDir = dir.appendingPathComponent("v0idchain", isDirectory: true) + try? FileManager.default.createDirectory(at: v0idDir, withIntermediateDirectories: true) + return v0idDir.appendingPathComponent("light-chain.json") + } + + private func loadCachedChain() { + guard let url = Self.chainFileURL(), + let data = try? Data(contentsOf: url), + let cached = try? JSONDecoder().decode([Block].self, from: data), + Self.verifyChainLink(cached, afterIndex: -1, tipHash: "") + else { return } // 文件不存在/损坏/校验不过:当空缓存处理,退回冷启动整链同步 + chain = cached + // 必须现在就把缓存的链广播出去:如果节点已经在这个高度,onOpen 只会发 QUERY_LATEST, + // 回来的块 index < chain.count 会被 handleBlocks 忽略、不会再触发一次 emitChain()—— + // 不在这里主动 emit,AppModel(只从 .chain 事件更新可见状态)就会一直显示空钱包。 + emitChain() + } + + private func persistChain() { + guard let url = Self.chainFileURL(), let data = try? JSONEncoder().encode(chain) else { return } + try? data.write(to: url, options: .atomic) + } + + /// 校验一段区块能接到 afterIndex/tipHash 指定的链尾上:高度连续 + prevHash 衔接 + 逐块哈希自洽。 + /// tipHash 为空串=从创世块开始验(不查第一块的 prevHash)。用于本地缓存读回校验,也用于收到的新块。 + private nonisolated static func verifyChainLink(_ blocks: [Block], afterIndex: Int, tipHash: String) -> Bool { + var prevIndex = afterIndex + var prevHash = tipHash + for b in blocks { + guard b.index == prevIndex + 1 else { return false } + if !prevHash.isEmpty, b.prevHash != prevHash { return false } + guard b.calcHash() == b.hash else { return false } + prevIndex = b.index + prevHash = b.hash + } + return true + } + /// 广播一笔已签名交易给**所有已建立**的连接。无可用连接则抛错。 public func broadcast(_ tx: Transaction) throws { let live = conns.values.filter { $0.open } @@ -265,21 +308,40 @@ public actor NodeClient { } private func handleBlocks(_ blocks: [Block], conn: Conn) { - guard let first = blocks.first else { return } + guard let first = blocks.first, let last = blocks.last else { return } if first.index == 0 { - // 整链(QUERY_ALL 回应):更长则采纳(信任所连节点,符合规范 MVP)。 - if blocks.count > chain.count { chain = blocks; emitChain(); removeMined(blocks) } + // 整链(QUERY_ALL 回应):更长,或等长但链尾 hash 不同(同高分叉/换了视角不同的节点) + // 才采纳;哈希链校验必须通过,防伪造/损坏数据污染本地缓存。 + let tipDiffers = blocks.count == chain.count && last.hash != chain.last?.hash + guard blocks.count > chain.count || tipDiffers, Self.verifyChainLink(blocks, afterIndex: -1, tipHash: "") else { return } + chain = blocks + emitChain(); removeMined(blocks); persistChain() } else { - // 增量 / 探测:能干净接到链顶则追加;否则若对方更高 → 只向该节点补拉整链。 + // 增量 / 探测:能干净接到链顶且哈希自洽则追加;有缺口则只问缺的那一段(QUERY_BLOCK_RANGE), + // 不再整链重拉——这是"重开钱包不用每次全量同步"的关键:缺口通常只有几个块。 var changed = false for nb in blocks { - if nb.index == chain.count && nb.prevHash == chain.last?.hash { + if nb.index < chain.count { + // 已有这个高度:正常应该 hash 相同直接跳过;若对不上,说明我们当前的链尾在一个 + // 不同分叉上(同高换节点/分叉),必须整链重拉,否则这个不匹配会每次心跳重现、永远卡住。 + if nb.index == chain.count - 1, nb.hash != chain.last?.hash { + Self.rawSend(conn.ws, .queryAll); break + } + continue + } + if nb.index == chain.count, nb.prevHash == chain.last?.hash, nb.calcHash() == nb.hash { chain.append(nb); changed = true - } else if nb.index >= chain.count { + } else if nb.index == chain.count { + // 衔接不上或哈希校验失败:本地缓存跟这个节点的视角不一致(换过节点/分叉)—— + // 不整链重拉的话这个不匹配会在每次心跳原样重现,永远卡住,所以这里必须兜底重灌。 Self.rawSend(conn.ws, .queryAll); break + } else { + let to = min(nb.index, chain.count + Self.maxRangeRequest - 1) + Self.rawSend(conn.ws, .queryBlockRange(from: chain.count, to: to)) + break } } - if changed { emitChain(); removeMined(blocks) } + if changed { emitChain(); removeMined(blocks); persistChain() } } } @@ -338,10 +400,11 @@ private enum OutMsg: Encodable { case queryAll case queryLatest case queryPeers + case queryBlockRange(from: Int, to: Int) case tx(Transaction) case blocks([Block]) - enum CodingKeys: String, CodingKey { case type, address, height, listen, tx, blocks } + enum CodingKeys: String, CodingKey { case type, address, height, listen, tx, blocks, from, to } func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) @@ -354,6 +417,10 @@ private enum OutMsg: Encodable { case .queryAll: try c.encode("QUERY_ALL", forKey: .type) case .queryLatest: try c.encode("QUERY_LATEST", forKey: .type) case .queryPeers: try c.encode("QUERY_PEERS", forKey: .type) + case .queryBlockRange(let from, let to): + try c.encode("QUERY_BLOCK_RANGE", forKey: .type) + try c.encode(from, forKey: .from) + try c.encode(to, forKey: .to) case .tx(let tx): try c.encode("TX", forKey: .type) try c.encode(tx, forKey: .tx) diff --git a/packages/node/src/api.ts b/packages/node/src/api.ts index 54f259a..39e949f 100644 --- a/packages/node/src/api.ts +++ b/packages/node/src/api.ts @@ -61,6 +61,10 @@ export function startHttpApi(node: V0idNode, port: number, token: string, roles? return json(200, node.info()); case '/chain': return json(200, node.bc.chain); + case '/tip': + // 轻量高度探针(无需令牌,与 /info 同级):客户端本地已缓存区块时, + // 用它判断有没有新块,而不必每次都拉整条链或整段 headers。 + return json(200, { height: node.bc.height, hash: node.bc.chain[node.bc.height]?.hash ?? '' }); case '/headers': { const from = Number(url.searchParams.get('from') ?? 0); const requestedTo = url.searchParams.has('to') ? Number(url.searchParams.get('to')) : node.bc.height; diff --git a/packages/web/package.json b/packages/web/package.json index d1c9b08..ac0dd21 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@v0idchain/core": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index c4b3981..6d1c54c 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getJSON, postJSON, isCoinbase, search, findTx, type Block, type Info, type Listing, type Tx, type TxRef, type Messages, type Newcomer, type NameRegistry, type RedPacket } from './api'; +import { getJSON, postJSON, getTip, getBlockRange, verifyBlockChainLink, isCoinbase, search, findTx, type Block, type Info, type Listing, type Tx, type TxRef, type Messages, type Newcomer, type NameRegistry, type RedPacket } from './api'; +import { loadCachedChain, putBlocks, clearCache } from './chainCache'; const short = (a: string) => (a && a.length > 14 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a || ''); const difficultyText = (d: number) => d > 255 ? `nBits 0x${d.toString(16).padStart(8, '0')}` : `${d} bit`; @@ -57,13 +58,71 @@ export default function App() { const [open, setOpen] = useState(null); const apiRef = useRef(api); apiRef.current = api; + const chainRef = useRef([]); // 与 chain state 同步的可变镜像,供同步逻辑同步读取(避免闭包拿到旧 state) + const [cacheReady, setCacheReady] = useState(false); + + // 启动时先读本地 IndexedDB 缓存的链,有多少读多少,后续只补缺口——不用每次开钱包都整条链重拉。 + // 缓存本身也要校验哈希链(万一被篡改/损坏,最后一块碰巧还是 /tip 报的那个,增量同步会误判"无新块" + // 而直接信任中间坏掉的数据);打开失败(隐私模式/配额满)也不能让 cacheReady 卡死、轮询永远不启动。 + useEffect(() => { + loadCachedChain() + .then((cached) => (verifyBlockChainLink(cached, -1, '') ? cached : [])) + .catch(() => []) + .then((cached) => { + chainRef.current = cached; + setChain(cached); + setCacheReady(true); + }); + }, []); + + // 落盘只是优化,不是正确性前提(数据已经过哈希链校验):写入失败(隐私模式/配额满)不该 + // 阻断链的展示,也不该被 poll() 的 catch 误判成"连不上节点"。 + const persistBestEffort = (fn: () => Promise) => { + fn().catch(() => {}); + }; + + // 整链重灌:本地缓存的链尾接不上节点(分叉/清空/首次同步)时兜底,从创世块逐块校验哈希链。 + const fullResync = useCallback(async (base: string) => { + const full = await getJSON(base, '/chain'); + if (!verifyBlockChainLink(full, -1, '')) { + console.error('v0id: 节点返回的链未通过完整性校验(hash/prevHash 不衔接),本次跳过同步'); + return; + } + chainRef.current = full; + setChain(full); + persistBestEffort(async () => { + await clearCache(); + await putBlocks(full); + }); + }, []); + + // 增量同步:先问 /tip 有没有新块,有才拉缺口区间;拉到的区块必须校验能接上本地链尾(hash 自洽 + prevHash 衔接 + 高度连续), + // 接不上(分叉/本地损坏)就退回整链重灌,绝不带病拼接。 + const syncChain = useCallback(async (base: string) => { + const tip = await getTip(base); + const local = chainRef.current; + const localTop = local.length ? local[local.length - 1] : undefined; + const localHeight = localTop ? localTop.index : -1; + const localHash = localTop ? localTop.hash : ''; + if (tip.height === localHeight && tip.hash === localHash) return; // 无新块,本次不用碰链数据 + if (tip.height > localHeight) { + const fresh = await getBlockRange(base, localHeight + 1, tip.height); + if (fresh.length > 0 && verifyBlockChainLink(fresh, localHeight, localHash)) { + const merged = [...local, ...fresh]; + chainRef.current = merged; + setChain(merged); + persistBestEffort(() => putBlocks(fresh)); + return; + } + } + await fullResync(base); // 高度倒退 / hash 对不上 / 区间校验失败:分叉或缓存损坏 + }, [fullResync]); const poll = useCallback(async () => { const base = apiRef.current; try { - const [i, c, m, mk, msg, nc, nm, rps] = await Promise.all([ + const [i, m, mk, msg, nc, nm, rps] = await Promise.all([ getJSON(base, '/info'), - getJSON(base, '/chain'), getJSON(base, '/mempool'), getJSON(base, '/market'), getJSON(base, '/messages'), @@ -71,8 +130,8 @@ export default function App() { getJSON(base, '/names'), getJSON(base, '/redpackets'), ]); + await syncChain(base); setInfo(i); - setChain(c); setMempool(m); setMarket(mk); setMessages(msg); @@ -84,13 +143,14 @@ export default function App() { } catch { setUp(false); } - }, []); + }, [syncChain]); useEffect(() => { + if (!cacheReady) return; // 先等本地缓存读出来,避免把已有的链当成空的重拉一遍 poll(); const t = setInterval(poll, 1500); return () => clearInterval(t); - }, [poll]); + }, [poll, cacheReady]); useEffect(() => { localStorage.setItem('v0id-api', api); diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index 7b33624..a559b1e 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -1,4 +1,6 @@ // 节点 HTTP API 的类型与取数辅助。 +import { calcBlockHash } from '@v0idchain/core/browser'; + export const NULL_ADDRESS = '0x' + '0'.repeat(64); export interface Tx { @@ -132,6 +134,39 @@ export async function postJSON(base: string, path: string, body: unknown, tok export const isCoinbase = (tx: Tx) => tx.from === NULL_ADDRESS; +export interface Tip { + height: number; + hash: string; +} + +export async function getTip(base: string): Promise { + return getJSON(base, '/tip'); +} + +/** 拉一段区块(含端点):闭区间 [from, to],节点侧会按 MAX_LIGHT_BLOCK_RANGE 截断。 */ +export async function getBlockRange(base: string, from: number, to: number): Promise { + const r = await getJSON<{ blocks: Block[] }>(base, `/blocks?from=${from}&to=${to}`); + return r.blocks; +} + +/** + * 校验一段"新拉到的区块"能接到本地已缓存的链尾上:逐块 hash 自洽(防本地/传输损坏)+ + * index 连续 + prevHash 衔接上一块(防分叉/伪造)。tipHash 为空串表示从创世块开始接。 + * 校验失败返回 false,调用方应整链重灌,不能带病拼接。 + */ +export function verifyBlockChainLink(newBlocks: Block[], afterIndex: number, tipHash: string): boolean { + let prevIndex = afterIndex; + let prevHash = tipHash; + for (const b of newBlocks) { + if (b.index !== prevIndex + 1) return false; + if (prevHash && b.prevHash !== prevHash) return false; + if (calcBlockHash(b) !== b.hash) return false; + prevIndex = b.index; + prevHash = b.hash; + } + return true; +} + // ---- 区块浏览器:客户端检索(数据都在已拉取的 /chain 里)---- export interface TxRef { tx: Tx; diff --git a/packages/web/src/chainCache.ts b/packages/web/src/chainCache.ts new file mode 100644 index 0000000..4a50497 --- /dev/null +++ b/packages/web/src/chainCache.ts @@ -0,0 +1,53 @@ +// 本地区块缓存(IndexedDB):钱包重开时不用每次都把整条链拉一遍。 +// 只存"已校验过完整性"的区块;读出来的链尾高度就是下次增量同步的起点。 +import type { Block } from './api'; + +const DB_NAME = 'v0id-chain-cache'; +const STORE = 'blocks'; +const DB_VERSION = 1; + +function openDb(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + req.result.createObjectStore(STORE, { keyPath: 'index' }); + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +/** 读出本地缓存的整条链,按高度升序。空缓存返回 []。 */ +export async function loadCachedChain(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).getAll(); + req.onsuccess = () => resolve((req.result as Block[]).sort((a, b) => a.index - b.index)); + req.onerror = () => reject(req.error); + }); +} + +/** 追加/覆盖一批区块(按 index 覆盖,用于新块或整链重灌)。 */ +export async function putBlocks(blocks: Block[]): Promise { + if (blocks.length === 0) return; + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + for (const b of blocks) store.put(b); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +/** 清空缓存:本地链与节点对不上(分叉/损坏)时整链重灌用。 */ +export async function clearCache(): Promise { + const db = await openDb(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).clear(); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c6dcc9..1ea0066 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,17 +91,20 @@ importers: ws: specifier: ^8.18.0 version: 8.21.0 - devDependencies: - '@types/ws': - specifier: ^8.5.13 - version: 8.18.1 optionalDependencies: node-datachannel: specifier: ^0.32.3 version: 0.32.3 + devDependencies: + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 packages/web: dependencies: + '@v0idchain/core': + specifier: workspace:* + version: link:../core react: specifier: ^18.3.1 version: 18.3.1 @@ -719,79 +722,66 @@ packages: resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.2': resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.2': resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.2': resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.2': resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.2': resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.2': resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.2': resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.2': resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.2': resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.2': resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.2': resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.2': resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.2': resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==}