增量链同步:本地缓存 + 补缺口,代替每次全量重拉#59
Conversation
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 <noreply@anthropic.com>
…ll resync
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 <noreply@anthropic.com>
… 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 <noreply@anthropic.com>
Released as wallet-v0.1.8 and wallet-android-v0.1.8 alongside the incremental chain sync changes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 57ba6720b5
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var changed = false | ||
| for nb in blocks { | ||
| if nb.index == chain.count && nb.prevHash == chain.last?.hash { | ||
| if nb.index < chain.count { continue } // 已有,跳过 |
There was a problem hiding this comment.
Emit cached chain when already at tip
When the macOS client restarts with a cached chain and the connected node is at the same tip, startup sends only QUERY_LATEST; the returned tip has nb.index < chain.count, so this new branch just continues and changed stays false. AppModel initializes its visible chain only from .chain events, so balances and derived state remain empty until another block arrives; emit the cached chain after loading it or when the latest block matches the cache.
Useful? React with 👍 / 👎.
| // 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 } |
There was a problem hiding this comment.
Accept same-height resyncs over stale cache
If the saved cache is for a different same-height fork, or the user changes to a node at the same height, the single-latest path detects the hash mismatch and sends QUERY_ALL, but this guard rejects the returned full chain because blocks.count is not greater than the cached chain.count. The client stays pinned to the old cached chain and refreshes cannot recover until that node grows past it; after verifying the full chain, allow equal-height replacement when the tip hash differs.
Useful? React with 👍 / 👎.
|
|
||
| // 启动时先读本地 IndexedDB 缓存的链,有多少读多少,后续只补缺口——不用每次开钱包都整条链重拉。 | ||
| useEffect(() => { | ||
| loadCachedChain().then((cached) => { |
There was a problem hiding this comment.
Fall back when IndexedDB cache loading fails
In browsers where indexedDB.open rejects, such as private/disabled storage or a corrupt/quota-failed DB, this promise rejection leaves cacheReady false forever, and the polling effect below returns before calling poll(). The dashboard then never fetches node data even though the node is reachable; catch cache errors and continue with an empty cached chain.
Useful? React with 👍 / 👎.
| return; | ||
| } | ||
| await clearCache(); | ||
| await putBlocks(full); |
There was a problem hiding this comment.
Don't make cache writes gate chain display
When IndexedDB writes fail during a full resync, for example because the browser denies storage or quota is exhausted, this await throws before chainRef.current/setChain(full) run and poll() reports the node as down. The cache is only an optimization, so the freshly fetched and verified chain should still be shown even if persisting it fails.
Useful? React with 👍 / 👎.
| 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)); |
There was a problem hiding this comment.
Validate cached blocks before trusting the tip
If IndexedDB contains a non-contiguous or header-corrupt cache while the last stored block still matches /tip, this returns the sorted records unvalidated and syncChain can skip resync entirely, leaving the explorer and actions derived from an incomplete local chain. Mirror the native clients by verifying the sorted cache from genesis and treating failures as an empty cache.
Useful? React with 👍 / 👎.
| 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 |
There was a problem hiding this comment.
Handle chunked full resyncs over warm caches
When Android falls back to requestChain() with a cached chain of at least 500 blocks, a normal node answers QUERY_ALL in 500-block chunks (packages/node/src/p2p.ts sends chunks for WS chains above that size). The first genesis chunk is therefore ignored here because its size is not larger than the cache, and later chunks cannot replace a divergent cached prefix, so the client remains stuck on the stale cache instead of recovering; buffer chunked full responses or use the from/total metadata before applying this size check.
Useful? React with 👍 / 👎.
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 <noreply@anthropic.com>
Summary
GET /tip(轻量高度探针),钱包用 IndexedDB 缓存已同步区块,重开只补/blocks?from&to缺口。QUERY_BLOCK_RANGEP2P 消息补缺口,不再发QUERY_ALL。ChainCache(内部文件存储),同样用QUERY_BLOCK_RANGE补缺口。chain.json,重启已经不需要整链重拉,未改代码。calcHash()自洽 +prevHash衔接 + 高度连续,校验不过(换节点/分叉/篡改)就退回整链重拉,绝不带病拼接本地缓存。顺手修了 macOS/iOS 一个潜在死循环:换节点后本地缓存跟新视角不一致时,原逻辑会静默卡在同一个不匹配上;现在强制整链重拉自愈。Test plan
corepack pnpm --filter @v0idchain/web typecheck通过/tip+ 补新块,不再重拉整链(浏览器 preview 网络日志核实)swift build+swift test(32 项)全绿swift build+swift test(24 项)全绿./gradlew compileDebugKotlin+testDebugUnitTest全绿🤖 Generated with Claude Code