From 258f75b8564f9b72713e74d2606bbd982689b4df Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 04:32:34 -0700 Subject: [PATCH 1/5] feat(web): incremental chain sync with local IndexedDB cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web wallet was redownloading the entire chain via GET /chain every 1.5s poll, even on a fresh page load — full resync every time, no local persistence. Add GET /tip (lightweight height+hash probe) so clients can check for new blocks without pulling the full chain, and have the web wallet cache synced blocks in IndexedDB, only fetching the gap since the last known height on reload. Fetched blocks are verified (hash self-consistency + prevHash linkage + contiguous height) before being appended/cached; a verification failure or detected reorg falls back to a full resync from genesis. Co-Authored-By: Claude Sonnet 5 --- packages/node/src/api.ts | 4 +++ packages/web/package.json | 1 + packages/web/src/App.tsx | 59 ++++++++++++++++++++++++++++++---- packages/web/src/api.ts | 35 ++++++++++++++++++++ packages/web/src/chainCache.ts | 53 ++++++++++++++++++++++++++++++ pnpm-lock.yaml | 24 ++++---------- 6 files changed, 153 insertions(+), 23 deletions(-) create mode 100644 packages/web/src/chainCache.ts 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..11e8c72 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,58 @@ 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 缓存的链,有多少读多少,后续只补缺口——不用每次开钱包都整条链重拉。 + useEffect(() => { + loadCachedChain().then((cached) => { + chainRef.current = cached; + setChain(cached); + setCacheReady(true); + }); + }, []); + + // 整链重灌:本地缓存的链尾接不上节点(分叉/清空/首次同步)时兜底,从创世块逐块校验哈希链。 + const fullResync = useCallback(async (base: string) => { + const full = await getJSON(base, '/chain'); + if (!verifyBlockChainLink(full, -1, '')) { + console.error('v0id: 节点返回的链未通过完整性校验(hash/prevHash 不衔接),本次跳过同步'); + return; + } + await clearCache(); + await putBlocks(full); + chainRef.current = full; + setChain(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); + await 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 +117,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 +130,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==} From 29b64a714802898046a33263572de8232a438867 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 04:43:09 -0700 Subject: [PATCH 2/5] feat(macos,ios): persist synced chain locally, gap-fill instead of full resync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS/iOS wallets kept the synced chain in memory only and requested QUERY_ALL (the entire chain) from a peer on every cold start, and after every disconnect/reconnect — same "redownload everything" issue as the web wallet. Persist the chain to a JSON file under Application Support, load it on start/init, and use the existing QUERY_BLOCK_RANGE P2P message (already implemented server-side in p2p.ts) to fetch only the gap since the locally cached tip instead of the whole chain. Every adopted block (from disk load, full resync, or gap-fill) is verified via calcHash() + prevHash linkage + contiguous height before being trusted; a failed check falls back to a full QUERY_ALL resync rather than caching corrupted/forged data. iOS additionally needed a calcHash() added to its Block model (macOS already had one) and logic to distinguish a QUERY_BLOCK_RANGE response from a QUERY_ALL chunked stream, since both reuse the same {blocks, from, total} wire shape but "total" means something different in each (chain length vs. this chunk's total). Co-Authored-By: Claude Sonnet 5 --- .../ios/V0idKit/Sources/V0idKit/Models.swift | 14 +++ .../V0idKit/Sources/V0idKit/NodeClient.swift | 100 +++++++++++++++--- .../V0idKit/Sources/V0idKit/Protocol.swift | 4 + .../V0idKit/Sources/V0idKit/NodeClient.swift | 70 ++++++++++-- 4 files changed, 165 insertions(+), 23 deletions(-) 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..5ecd64e 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,31 @@ 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 { + guard blocks.count > chain.count, 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 { + 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 +373,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/V0idKit/Sources/V0idKit/NodeClient.swift b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift index 7de095b..5cd2402 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,48 @@ 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 + } + + 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 } @@ -267,19 +306,27 @@ public actor NodeClient { private func handleBlocks(_ blocks: [Block], conn: Conn) { guard let first = blocks.first else { return } if first.index == 0 { - // 整链(QUERY_ALL 回应):更长则采纳(信任所连节点,符合规范 MVP)。 - if blocks.count > chain.count { chain = blocks; emitChain(); removeMined(blocks) } + // 整链(QUERY_ALL 回应):更长 + 哈希链校验通过才采纳,防伪造/损坏数据污染本地缓存。 + guard blocks.count > chain.count, 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 { 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 { - Self.rawSend(conn.ws, .queryAll); break + } else if nb.index == chain.count { + 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 +385,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 +402,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) From 1187f6190b6b15d041c80a9a3c20430d253d6d64 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 04:49:53 -0700 Subject: [PATCH 3/5] feat(android): persist synced chain locally, gap-fill instead of full resync Same fix as macOS/iOS: Android wallet always sent QUERY_ALL (whole chain) on every connect, with the chain living only in ViewModel state, wiped on every cold start. Add ChainCache (internal file storage, chain data isn't sensitive so no need for the encrypted prefs used for the private key) to persist the chain and load it on init; WsClient.connect() now takes a knownHeight and only sends QUERY_ALL when there's no cache, otherwise QUERY_LATEST + a QUERY_BLOCK_RANGE gap-fill via mergeChain's new NeedRange case. Also fixes a latent stuck-loop bug in macOS/iOS: when a single next-expected block failed hash/prevHash verification (e.g. after switching to a node with an incompatible chain view), both clients silently dropped it and would hit the identical mismatch on every future heartbeat forever. Now that specific case forces a full QUERY_ALL resync instead of retrying the same bad data indefinitely; Android's mergeChain was written with this distinction from the start. Verified: swift build + swift test green on macOS/iOS V0idKit, ./gradlew compileDebugKotlin + testDebugUnitTest green on Android (JDK 25 from the system doesn't work with this Gradle wrapper; built using Android Studio's bundled JBR 21 instead). Co-Authored-By: Claude Sonnet 5 --- .../java/com/v0id/wallet/WalletViewModel.kt | 55 +++++++++++++------ .../main/java/com/v0id/wallet/core/Block.kt | 20 +++++++ .../java/com/v0id/wallet/data/ChainCache.kt | 41 ++++++++++++++ .../java/com/v0id/wallet/net/ChainCodec.kt | 22 ++++++++ .../main/java/com/v0id/wallet/net/WsClient.kt | 25 +++++++-- .../V0idKit/Sources/V0idKit/NodeClient.swift | 6 +- .../V0idKit/Sources/V0idKit/NodeClient.swift | 4 +- 7 files changed, 150 insertions(+), 23 deletions(-) create mode 100644 clients/android/app/src/main/java/com/v0id/wallet/data/ChainCache.kt 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..9d9b3ee 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,40 @@ 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) { + if (incoming.size <= current.size) 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 +362,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..bf7ca83 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,8 @@ class WsClient( // 每条连接唯一的 HELLO listen 后缀:节点端按 listen 给连接判重(p2p.ts),同一客户端 // 自动重连时若沿用旧 listen,可能与服务端尚未清理的陈旧连接对撞被踢 → 永久抖动。 private var connToken: String = "" + // 本地已缓存的链尾高度(-1 = 无缓存):决定握手要整链(QUERY_ALL)还是只探最新块(QUERY_LATEST)。 + private var knownHeight: Long = -1 @Volatile var status: Status = Status.DISCONNECTED private set @@ -50,8 +52,9 @@ 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 connToken = UUID.randomUUID().toString().take(8) // 本次连接唯一,避免 listen 自我对撞 @@ -89,6 +92,11 @@ class WsClient( ws?.send(JSONObject().put("type", "QUERY_ALL").toString()) } + /** 只补 [from, to] 这一段缺口(本地已有缓存,节点更高时用这个而不是整链重拉)。 */ + fun requestRange(from: Long, to: Long) { + 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 +114,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) { diff --git a/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift b/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift index 5ecd64e..347ba57 100644 --- a/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift +++ b/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift @@ -346,7 +346,11 @@ public final class NodeClient: ObservableObject { if last.index == nextIndex, last.prevHash == tipHash, last.calcHash() == last.hash { chain.append(last) afterChainChanged() - } else if last.index >= nextIndex { + } 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)) diff --git a/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift index 5cd2402..1ebe02f 100644 --- a/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift +++ b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift @@ -319,7 +319,9 @@ public actor NodeClient { 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 { - break // 衔接不上或哈希校验失败:疑似分叉/篡改,本批停止 + // 衔接不上或哈希校验失败:本地缓存跟这个节点的视角不一致(换过节点/分叉)—— + // 不整链重拉的话这个不匹配会在每次心跳原样重现,永远卡住,所以这里必须兜底重灌。 + 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)) From 57ba6720b585f8f096bb5afc307642e4563a13a0 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 06:50:00 -0700 Subject: [PATCH 4/5] chore(macos,android): bump wallet versions to 0.1.8 Released as wallet-v0.1.8 and wallet-android-v0.1.8 alongside the incremental chain sync changes. Co-Authored-By: Claude Sonnet 5 --- clients/android/app/build.gradle.kts | 4 ++-- clients/macos/Info.plist | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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 From 78e17d663b21d148072eaac365bf9a021e77efdc Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 08:22:50 -0700 Subject: [PATCH 5/5] fix: address Codex review findings on incremental chain sync (PR #59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 6 findings from the automated review, plus matching gaps found in self-review of the same code paths: - macOS (P1): cached chain loaded at cold start was never emitted to AppModel if the connected node was already at the cached tip (onOpen only sends QUERY_LATEST in that case, and the reply's index is below chain.count so handleBlocks silently ignores it) — wallet showed an empty balance until the next block. Now emits right after loading the cache. - iOS (P1): full-chain resync required strictly more blocks than the cached chain, so a same-height fork or a switch to a node with a diverging same-length view could never replace the stale cache. Now also accepts a same-length replacement when the tip hash differs. Self-review: the identical bug existed in macOS's full-chain branch and, via a different code path, in its incremental-append loop (blocks below chain.count were skipped without a hash check) and in Android's mergeChain — fixed all three the same way. - Web (P2 x3): loadCachedChain() had no .catch, so a rejected IndexedDB open (private browsing, disabled storage) left cacheReady stuck false forever and polling never started; a failed cache write inside fullResync threw before the already-verified chain was shown, making poll()'s catch report the node as down; and the cache read on mount was never hash-verified, so a corrupted-but-tip-matching local DB would be trusted outright. Fixed by catching the load, making persistence best-effort (display first, cache write fire-and-forget), and verifying the loaded cache before trusting it. - Android (P2): WsClient ignored the from/total fields on BLOCKS frames, so a QUERY_ALL response to a node with a chain >500 blocks (server-side chunk size) arrived as separate chunks each compared independently against the full cached chain — every chunk looked too short to replace the cache and the client stayed stuck. Added chunk reassembly (mirroring iOS's existing chunkBuffer approach) and disambiguation from QUERY_BLOCK_RANGE responses, which reuse the same wire shape but with a different meaning for `total`. Verified: swift build + swift test green on macOS/iOS V0idKit, tsc --noEmit green on web, gradlew compileDebugKotlin + testDebugUnitTest green on Android. Co-Authored-By: Claude Sonnet 5 --- .../java/com/v0id/wallet/WalletViewModel.kt | 5 +- .../main/java/com/v0id/wallet/net/WsClient.kt | 46 ++++++++++++++++++- .../V0idKit/Sources/V0idKit/NodeClient.swift | 5 +- .../V0idKit/Sources/V0idKit/NodeClient.swift | 21 +++++++-- packages/web/src/App.tsx | 29 ++++++++---- 5 files changed, 91 insertions(+), 15 deletions(-) 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 9d9b3ee..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 @@ -331,7 +331,10 @@ class WalletViewModel(app: Application) : AndroidViewModel(app) { if (incoming.isEmpty()) return Merge.Ignore val first = incoming.first() if (first.index == 0L) { - if (incoming.size <= current.size) return Merge.Ignore + // 更长,或等长但链尾 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 // 没有缓存/链,非整链响应帮不上忙,兜底整链要 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 bf7ca83..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 @@ -44,6 +44,15 @@ class WsClient( // 本地已缓存的链尾高度(-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 @@ -57,6 +66,7 @@ class WsClient( this.knownHeight = knownHeight userClosed = false myAddress = address + resetChunkState() // 新连接不带上一条连接的残片/在途请求状态 connToken = UUID.randomUUID().toString().take(8) // 本次连接唯一,避免 listen 自我对撞 setStatus(Status.CONNECTING) val trimmed = url.trim() @@ -84,16 +94,25 @@ 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()) } @@ -142,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/NodeClient.swift b/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift index 347ba57..0cc54c8 100644 --- a/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift +++ b/clients/ios/V0idKit/Sources/V0idKit/NodeClient.swift @@ -334,7 +334,10 @@ public final class NodeClient: ObservableObject { let nextIndex = chain.count if blocks.count >= 2 { if first.index == 0 { - guard blocks.count > chain.count, Self.verifyChainLink(blocks, afterIndex: -1, tipHash: "") else { return } + // 更长,或等长但链尾 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) diff --git a/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift index 1ebe02f..b95c4a4 100644 --- a/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift +++ b/clients/macos/V0idKit/Sources/V0idKit/NodeClient.swift @@ -102,6 +102,10 @@ public actor NodeClient { 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() { @@ -304,10 +308,12 @@ 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 回应):更长 + 哈希链校验通过才采纳,防伪造/损坏数据污染本地缓存。 - guard blocks.count > chain.count, Self.verifyChainLink(blocks, afterIndex: -1, tipHash: "") else { return } + // 整链(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 { @@ -315,7 +321,14 @@ public actor NodeClient { // 不再整链重拉——这是"重开钱包不用每次全量同步"的关键:缺口通常只有几个块。 var changed = false for nb in blocks { - if nb.index < chain.count { continue } // 已有,跳过 + 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 { diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 11e8c72..6d1c54c 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -62,14 +62,25 @@ export default function App() { const [cacheReady, setCacheReady] = useState(false); // 启动时先读本地 IndexedDB 缓存的链,有多少读多少,后续只补缺口——不用每次开钱包都整条链重拉。 + // 缓存本身也要校验哈希链(万一被篡改/损坏,最后一块碰巧还是 /tip 报的那个,增量同步会误判"无新块" + // 而直接信任中间坏掉的数据);打开失败(隐私模式/配额满)也不能让 cacheReady 卡死、轮询永远不启动。 useEffect(() => { - loadCachedChain().then((cached) => { - chainRef.current = cached; - setChain(cached); - setCacheReady(true); - }); + 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'); @@ -77,10 +88,12 @@ export default function App() { console.error('v0id: 节点返回的链未通过完整性校验(hash/prevHash 不衔接),本次跳过同步'); return; } - await clearCache(); - await putBlocks(full); chainRef.current = full; setChain(full); + persistBestEffort(async () => { + await clearCache(); + await putBlocks(full); + }); }, []); // 增量同步:先问 /tip 有没有新块,有才拉缺口区间;拉到的区块必须校验能接上本地链尾(hash 自洽 + prevHash 衔接 + 高度连续), @@ -98,7 +111,7 @@ export default function App() { const merged = [...local, ...fresh]; chainRef.current = merged; setChain(merged); - await putBlocks(fresh); + persistBestEffort(() => putBlocks(fresh)); return; } }