Skip to content

增量链同步:本地缓存 + 补缺口,代替每次全量重拉#59

Merged
v0id-byte merged 5 commits into
mainfrom
feat/incremental-chain-sync
Jul 2, 2026
Merged

增量链同步:本地缓存 + 补缺口,代替每次全量重拉#59
v0id-byte merged 5 commits into
mainfrom
feat/incremental-chain-sync

Conversation

@v0id-byte

Copy link
Copy Markdown
Owner

Summary

  • 所有钱包客户端(Web / macOS / iOS / Android)此前每次打开都全量重同步整条链,哪怕后台切回也一样,流量和耗时随链长线性增长。本 PR 给每端加本地持久化 + 增量补缺口。
  • Web:新增节点端 GET /tip(轻量高度探针),钱包用 IndexedDB 缓存已同步区块,重开只补 /blocks?from&to 缺口。
  • macOS / iOS:链落盘到 Application Support(JSON),冷启动读缓存,用已有的 QUERY_BLOCK_RANGE P2P 消息补缺口,不再发 QUERY_ALL
  • Android:新增 ChainCache(内部文件存储),同样用 QUERY_BLOCK_RANGE 补缺口。
  • Desktop(Electron):调研确认 daemon 本就在每个区块提交后落盘 chain.json,重启已经不需要整链重拉,未改代码。
  • 所有端统一规则:拿到的区块必须 calcHash() 自洽 + prevHash 衔接 + 高度连续,校验不过(换节点/分叉/篡改)就退回整链重拉,绝不带病拼接本地缓存。顺手修了 macOS/iOS 一个潜在死循环:换节点后本地缓存跟新视角不一致时,原逻辑会静默卡在同一个不匹配上;现在强制整链重拉自愈。
  • 附带发布 wallet-v0.1.8(macOS,已签名公证)与 wallet-android-v0.1.8(Android)。

Test plan

  • corepack pnpm --filter @v0idchain/web typecheck 通过
  • Web:起真实节点验证,首次拉全量后刷新页面只问 /tip + 补新块,不再重拉整链(浏览器 preview 网络日志核实)
  • macOS:swift build + swift test(32 项)全绿
  • iOS:swift build + swift test(24 项)全绿
  • Android:./gradlew compileDebugKotlin + testDebugUnitTest 全绿
  • macOS 发布 DMG:签名 + 公证 Accepted + staple + spctl 校验通过
  • Android 发布 APK:SHA-256 与 GitHub Release 资产一致

🤖 Generated with Claude Code

v0id-byte and others added 4 commits July 2, 2026 04:32
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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 } // 已有,跳过

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread packages/web/src/App.tsx Outdated

// 启动时先读本地 IndexedDB 缓存的链,有多少读多少,后续只补缺口——不用每次开钱包都整条链重拉。
useEffect(() => {
loadCachedChain().then((cached) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread packages/web/src/App.tsx Outdated
return;
}
await clearCache();
await putBlocks(full);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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>
@v0id-byte v0id-byte merged commit ae06797 into main Jul 2, 2026
2 checks passed
@v0id-byte v0id-byte deleted the feat/incremental-chain-sync branch July 2, 2026 15:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant