Skip to content

fix: make from-genesis replay work again (GenesisInit amount bound, exec-time address check) - #1401

Merged
bysomeone merged 8 commits into
33cn:masterfrom
bysomeone:fix-genesisinit-negative-only
Sep 30, 2026
Merged

bysomeone merged 8 commits into
33cn:masterfrom
bysomeone:fix-genesisinit-negative-only

Conversation

@bysomeone

@bysomeone bysomeone commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #1400 and #1403.

A node that syncs from genesis stops on historical blocks with ErrCheckStateHash. There are two independent causes, both checks that were added years after those blocks were accepted, and this PR fixes both.

1. GenesisInit amount bound -- account/genesis.go

GenesisInit now rejects only a negative amount. #1378 added the full CheckAmount validation, whose upper bound (MaxCoin*coinPrecision, 1e17) is tighter than the MaxTokenBalance (9e18) that safeAdd already enforces — and that a token's pre-create validates its total against.

GenesisInit is not genesis-only: the token executor calls it from every tokenFinishCreate with the token's total. On bityuan, 18 tokens have total >= 1e17, at heights 394223 / 595494 / 688446 / 1473857. With the tighter bound, a node syncing from genesis stops on each of them with ErrCheckStateHash, because the state root it computes no longer matches the one in the block header.

The negative case — what #1378 was actually about — is still rejected, and it is the only guard left. amount == 0 is accepted again, as are amounts between the two upper bounds, which is the rule those blocks were executed with: before #1378, GenesisInit had no validation at all beyond safeAdd. Reverting #1378's lower bound as well would introduce a new rejection, and a new rejection is the failure mode this PR exists to fix — a node replaying a block that called genesis_init(addr, 0) would stop on it.

GenesisInit has three callers, and the zero amount is reachable from two of them:

  • Coins.Exec_Genesis (system/dapp/coins/executor) runs only at height == 0 and validates the amount at its own entry, so the genesis path is unaffected either way. TestGenesisZeroAmount and TestGenesisNegativeAmount there still pass.
  • the token executor's tokenFinishCreate, with the token's total, which is where the upper bound matters.
  • the js executor's genesis_init, which a deployed contract can call at any height and which has no amount guard of its own. This is the path an earlier version of this description missed: the zero amount is not covered one level up here.

This relaxation is not fork-gated, and it applies at every height. So during the upgrade itself, a token created with a total in [1e17, 9e18] executes on the upgraded nodes and fails with ErrAmount on the ones that have not been upgraded yet, which is a state divergence rather than a replay-only difference: the whole network has to be upgraded together. For bityuan that is a coordinated upgrade, not a rolling one.

That is also the whole newly-accepted set on bityuan. The amount == 0 case cannot reach GenesisInit there: Coins.Exec_Genesis only runs at height == 0 and rejects a zero amount at its own entry, the token executor's pre-create already rejects total <= 0 (tokendb.go:163), and the js executor — the one caller with no amount guard, reachable at any height — is not linked into bityuan's binary at all (plugin/dapp/init/init.go registers no js). So the zero amount is a disclosure, not an exposure, for the chain this is written for.

A fork height is not the knob for this one. A gate only applies from some height H upward, and every block the change restores is historical: a gate above those blocks leaves them unreplayable, which is the bug, and a gate below them is indistinguishable from no gate. The strict rule is also the newer one — it is what a fresh sync computes its state root with today, and it is what disagrees with the chain.

Test changes in account/genesis_checkamount_test.go:

  • amount == 0 is now expected to succeed, with the reason documented
  • TestGenesisInitAmountBoundaries replaces the previous upper-bound test, which rejected MaxTokenBalance+1 on an account that already held MaxCoin*coinPrecision — i.e. it passed on safeAdd's sum check rather than on the amount. It now covers the three boundaries separately, including the one a replay depends on: a fresh account initialized with exactly MaxTokenBalance, which is what the token pre-create allows.

2. The address check -- common/address/address.go, system/dapp/driver.go

CheckAddress ranged over the driver map and kept the last error it saw, so the error returned for an address that several drivers reject depended on Go's randomized map iteration. Callers decide whether a legacy address format may be tolerated by comparing that error against ErrCheckVersion / ErrAddressChecksum, so the same block could be accepted on one node and rejected on another.

Measured over the 57 addresses that differ from a valid one only in the last character (so the length and the version byte still check out), on upstream code: ErrInvalidEthAddr x35, ErrAddressType x8, ErrCheckChecksum x7, ErrCheckVersion x7 — four different answers for the same address, and the fork gate treats two of them differently from the other two. Visiting the drivers in ascending id order and returning the first failure makes all 57 answer ErrCheckChecksum.

That is what fixes bityuan's 546820, a coins transfer to a legacy malformed address: the deterministic answer is the error the fork gate tolerates, so the transaction is accepted during a replay exactly as it was when its block was first executed.

Two consequences of the same measurement are handled here as well:

  • The gate has to tolerate ErrCheckChecksum too (system/dapp/driver.go). CheckBase58Address reports it for a 25-byte address and ErrAddressChecksum for longer ones, and both are the same legacy form. Previously such a 25-byte address could come back as ErrCheckVersion — from a driver that expects another version byte — which the gate tolerates below ForkMultiSignAddress; now it always reports ErrCheckChecksum, so leaving the gate alone would flip these addresses from "sometimes accepted" to "always rejected" below the fork. Both errors are tolerated below ForkBase58AddressCheck only, so nothing changes at or above it.
  • The address cache is keyed by the address and the enabled drivers (common/address/address.go). The answer depends on the height — a negative height enables every driver, including the ones the config disables — but the cache was keyed by the address alone, and the callers that pass -1 (rpc/client, rpc/jrpchandler, wallet, the CLI commands) share the process with the ones that pass a real height. A query could therefore decide a later check at a real height: the mempool check in system/mempool/check.go passes a real height, would take the cached nil, and accept a transaction the other nodes reject. ValidateAddr does not take a height, so the address and the enabled set are the whole input.

One behaviour change is disclosed rather than fixed: inside the window between the two fork heights, [ForkMultiSignAddress, ForkBase58AddressCheck), the deterministic answer for such an address is ErrCheckVersion, and that error stops being tolerated at ForkMultiSignAddress. So inside that window an address that only fails the legacy checks flips from "rejected unless the arbitrary visiting order happened to put a checksum-error driver first" to "always rejected". Extending the ErrCheckVersion tolerance to ForkBase58AddressCheck would restore the old acceptance, but that is a fork-semantics decision and this PR does not make it. The window is empty on the chains I know of — the chain33 defaults are 1298600/1800000 and bityuan sets both to 2270000 — so bityuan cannot reach it, and its affected block (546820) is far below both.

No new fork height is needed. ForkMultiSignAddress / ForkBase58AddressCheck already express "tolerate these formats below this height, reject them at or above it". At or above both heights the address is still rejected — now deterministically — and current heights are far above them, so live behaviour there is unchanged. For a chain whose fork heights are still in the future, this makes the check obey the configured tolerance instead of bypassing it; if stricter behaviour is wanted there, the fork height is the knob to move.

This PR originally also changed executor/execenv.go to use the wrapper at the EventCheckTx call site. That call site is not on the path a block is replayed through: EventCheckTx has exactly one sender (system/mempool/check.go), and the mempool checks the same address earlier with the raw function, which is stricter — anything that reaches execCheckTx has already passed it, so the wrapper cannot change the outcome there. The change is dropped rather than kept as a consensus-path edit with no causal evidence; the fix is the deterministic ordering, which applies to the check Exec runs during a replay.

system/mempool/check.go also calls the raw function. The mempool is not consensus-critical and at current heights the outcome is identical, so it is left unchanged rather than widened in the same PR.

Verification

A bityuan mainnet node was rebuilt from genesis with this branch's chain33 (via a temporary replace in a test branch of our fork — not part of this PR). It replayed every height that used to fail — 394223, 595494, 688446, 1473857 (GenesisInit) and 546820 (address check) — crossed the 2270000 fork boundary, and reached 9,719,794 blocks with zero ErrCheckStateHash and no panic. Block hashes at 2270000 and 6000000 match a healthy node on the same chain byte for byte.

The replay was not repeated after the last four commits, and does not need to be: neither the cache key (identical in a process that never calls CheckAddress with -1, which a replay is), nor the gate tolerance (below the fork only, and it can only accept more, never reject more), nor the removed execCheckTx edit (not on that path) can change a replay's outcome. That run also answers the question a replay can answer here — a clean process, with no -1 call to poison the cache, replayed the whole range, so no block in it depends on a poisoned cache entry to be accepted.

Tests

Added, each verified to fail before the change it covers and pass after:

  • common/address: TestCheckAddressErrorIsDeterministic (the 57 variants above must all return the same error), TestCheckAddressCacheFollowsHeight (CheckAddress(ethAddr, -1) must not decide CheckAddress(ethAddr, 0))
  • system/dapp: TestCheckAddressToleratesLegacyFormBelowFork, TestCheckAddressToleratesLegacyChecksumBelowFork
  • account: TestGenesisInitAmountBoundaries

go test ./common/address/ ./system/address/... ./system/dapp/ ./system/dapp/coins/executor/ ./account/ ./executor/ -count=1 passes, and go vet is clean on the touched packages.

The cache hit path was measured with a temporary benchmark, not committed: 40ns/0 allocs on upstream, 500ns/264B/7 allocs for the first version of the deterministic ordering, 45ns/0 allocs after the last commit, which keeps the driver order in a slice and the enabled driver set in the cache entry.

🤖 Generated with Claude Code

…dy were

GenesisInit has to reject a negative amount -- safeAdd only guards overflow, so
a negative amount is persisted as a negative balance -- but it must not apply
the rest of CheckAmount.

CheckAmount also rejects amount == 0 and everything at or above
MaxCoin*coinPrecision, a tighter upper bound than the MaxTokenBalance that
safeAdd enforces. GenesisInit is not a genesis-only entry point: the token
executor calls it from every tokenFinishCreate with the token's total, which
the pre-create step validated against MaxTokenBalance. A token whose total
falls between the two bounds was accepted by the blocks that are already on
the chain, so the tighter bound makes those blocks unreplayable -- a node
syncing from scratch fails on them with ErrCheckStateHash.

The genesis path keeps the validation it already had: Coins.Exec_Genesis calls
CheckAmount at the executor entry before it reaches GenesisInit.

Co-authored-by: Claude <[email protected]>
@bysomeone
bysomeone marked this pull request as draft September 24, 2026 08:07
@bysomeone
bysomeone marked this pull request as ready for review September 24, 2026 08:40
@bysomeone
bysomeone requested review from 33cn and vipwzw September 24, 2026 08:40
bysomeone added a commit to bysomeone/bityuan that referenced this pull request Sep 24, 2026
Verification only -- this branch must not be merged.

Adds 33cn/chain33#1401's GenesisInit fix plus the probe that skips the two
exec-time tx.To address checks, so the from-genesis replay can get past bityuan
block 546820 and report what else it hits.

Co-authored-by: Claude <[email protected]>
bysomeone and others added 2 commits September 24, 2026 23:25
CheckAddress ranged over the driver map and kept the last error it saw, so for
an address that several drivers reject the error returned depended on Go's
randomized map iteration -- measured on one affected address through
system/dapp.CheckAddress: 5x ErrInvalidEthAddr, 1x ErrAddressType, 2x tolerated.

Callers decide whether a legacy address format may be tolerated by comparing
that error against specific values: system/dapp.CheckAddress tolerates
ErrCheckVersion before ForkMultiSignAddress and ErrAddressChecksum before
ForkBase58AddressCheck. An arbitrary error therefore defeated those fork gates
at random, and a from-genesis replay diverged wherever it landed on the
rejecting side.

Visit the drivers in ascending id order, the legacy base58 drivers first, and
report the first failure. An address that only fails the legacy checks now
reports ErrCheckVersion or ErrAddressChecksum, which is what the gates match on.

Co-authored-by: Claude <[email protected]>
execCheckTx validated tx.To with the raw address.CheckAddress, which has no
fork tolerance, while Exec validated the same address with the dapp wrapper
that tolerates the legacy formats below ForkMultiSignAddress /
ForkBase58AddressCheck. The two paths disagreed about the same address, and the
strict one runs first: a transaction to a legacy address that its block was
accepted with is rejected here, its state root no longer matches, and a
from-genesis sync stops at that block with ErrCheckStateHash (bityuan: 546820).

Check with the same wrapper Exec uses. Below those fork heights the legacy
formats are tolerated, as the configuration intends and as the blocks that are
already on the chain were executed; at or above them the address is still
rejected, now deterministically.

Co-authored-by: Claude <[email protected]>
@bysomeone bysomeone changed the title fix(account): keep GenesisInit's amount bound where its callers already were fix: make from-genesis replay work again (GenesisInit amount bound, exec-time address check) Sep 26, 2026
@33cn

33cn commented Sep 28, 2026

Copy link
Copy Markdown
Owner

Review(审查对象 a9a8c7ad)

已实际拉取 head 到独立 worktree 编译并运行相关用例,验证后已清理,未改动本仓库工作区。结论先说:两个修复方向都成立(bityuan 全量 replay 的证据很有分量),但地址侧"确定性"的目标被 checkAddressCache 抵消,并且 executor/execenv.go 那一行并不是 546820 的修复路径。建议合并前处理问题 1、2。

验证情况

  • go test ./account/... ./common/address/... → PASS;go test ./system/dapp/coins/executor/ → PASS(该包需先 GOPROXY=https://mirrors.aliyun.com/goproxy 补依赖,本地默认代理超时,与本 PR 无关)
  • 另在临时 worktree 写了探针用例实测缓存与 driver 顺序行为(探针随 worktree 删除,未提交);下面引用的 33cn/plugin 代码为 master 行号
  • 本 PR 无并发改动,未跑 make race;完整 make linter 未跑,只对上述包做了编译与用例验证

做对的地方

  • GenesisInit 的论证我逐条核对过,成立。 plugin/dapp/token/executor/tokendb.go:292 确实在每次 finishCreate 调 tokenAccount.GenesisInit(token.Owner, token.GetTotal()),而 preCreate 的上界是 types.MaxTokenBalance=9e18(tokendb.go:163;mint 同一界,:88),比 CheckAmount 的 MaxCoin*coinPrecision=1e17 宽 90 倍;仓库内唯一调用方 Coins.Exec_Genesis(system/dapp/coins/executor/exec.go:62)只在 height==0 且已过 CheckAmount。所以"收紧上界使历史块不可 replay"成立,amount < 0 也保住了 fix(account): reject non-positive amount in GenesisInit #1378 真正要防的负余额(safeAdd 对负数不设防)。
  • 排序改动确实消掉了随机性:改造后探针连续 5 次调用返回同一 error。
  • PR 描述里的验证方式(主网全量 replay + 与健康节点对比 6000000 的 hash)是这次改动最有说服力的部分。

问题 1(中,既有问题但与本次目标直接冲突):缓存让"结果只由高度决定"不成立

common/address/address.go:122-127 的 checkAddressCache 只用 addr 做 key,而返回值依赖 blockHeight(isEnable 决定哪些 driver 参与,common/address/driver.go:165-174)。按默认配置(types/defaultcfg.go:17-18 eth=-2)实测:

先 -1 后真实高度:
  CheckAddress("0x1111...11", -1)      = <nil>
  CheckAddress("0x1111...11", 5000000) = <nil>            ← 命中缓存,但 eth 在该高度是禁用的
反序(另一进程):
  CheckAddress("0x1111...11", 5000000) = ErrAddressLength
  CheckAddress("0x1111...11", -1)      = ErrAddressLength

同一个地址的返回值取决于"进程内谁先调用",不再取决于高度。触发源是现成的:-1 会让 isEnable 对所有 driver 返回 true(含配置里显式禁用的,driver.go:167-169),而节点侧 RPC 大量用 -1 调用——rpc/client/client.go:56 CreateRawTransaction、:310 GetAddrOverview、:356 GetAllExecBalance。一旦某个查询把 nil 写进缓存,Exec(executor/execenv.go:270)在真实高度上就会接受这个地址,没被查询过的节点则拒绝:同一笔交易在两台节点上 receipts / state root 可能不同。这正是本 PR 要消灭的那类不确定性,只是触发源从 map 迭代换成了调用顺序(8 进程实验能测出前者,测不出后者)。

缓存逻辑早于本 PR,不是本次引入;但既然本 PR 的卖点就是确定性,建议一并处理:key 改为 (addr, 该高度下启用的 driver 集合签名) 或至少 (addr, blockHeight < 0),并补一个回归测试把"同一地址在 -1 与真实高度下结果不同"固定下来。

问题 2(中):execenv.go:223 不是 replay 的修复路径

这一行在本仓库(以及 33cn/plugin、bityuan/bityuan)里都不构成 546820 的修复路径:

  • EventCheckTx 全仓库只有 system/mempool/check.go:39 一个发送方;区块执行走 procExecTxList → execTx → Exec,而 Exec 用的本来就是 wrapper(execenv.go:270)。注释里 "the strict path runs first ... rejected during replay" 描述的调用关系不存在。
  • mempool 内顺序是 eventTx → checkTxs → checkTx(system/mempool/check.go:70 的裸 address.CheckAddress)→ mem.in(eventprocess.go:122-124)→ checkSign → checkTxRemote(eventprocess.go:47 → check.go:199 发 EventCheckTx)。裸检查在前,而 wrapper 相对裸调用只是更宽松(IsDriverAddress → nil、fork gate 内额外容忍),所以在 legacy 地址场景里,新代码不改变 mempool 的接受/拒绝结果。

也就是说 546820 是排序改动(作用于 Exec 已在用的 wrapper)修好的,这一行对 replay 没有贡献。若目的是让 mempool 也容忍 legacy 地址,要改的是 system/mempool/check.go:70(描述里说有意不动它,这与"execCheckTx 必须改"相互矛盾);若是为平行链转发交易(checkTxs 对 IsForward2MainChainTx 直接 return、跳过裸检查)而改,请写明是哪条路径并补测试。否则建议删掉——共识路径上的改动,没有测试也没有因果证据。

另外:wrapper 对任何已注册 executor 地址(IsDriverAddress,system/dapp/register.go:115-124)直接返回 nil、完全跳过格式校验,且该分支受 executor 自身 enable height 控制,所以"fork 高度以上行为不变"在这个分支上不严格成立(好在按上面的分析它只影响 mempool)。

问题 3(中低):排序只覆盖部分地址形态,且"id 0 优先"是注册顺序的巧合

CheckBase58Address 有两条不同的 checksum 分支:长度 25 时返回 ErrCheckChecksum("Address Checksum error",address.go:204-210),长度 >25 时返回 ErrAddressChecksum("address checksum error",address.go:211-217);而 system/dapp/driver.go:285-290 只匹配 ErrCheckVersion 与 ErrAddressChecksum。探针实测:一个"版本字节正确、25 字节校验和错误"的地址,driver 0 返回 ErrCheckChecksum(5/5 一致),而 driver 1(btcMultiSign)会返回 ErrCheckVersion(被 gate 容忍)。旧代码在随机顺序下有机会落在后者从而放行,新代码固定落在前者、必然拒绝——对这类地址是把"偶发通过"变成"必然失败"。bityuan 全量 replay 说明链上没有这类块,但对平行链/测试链/其它 fork 配置不自动成立;若不希望依赖这个经验结论,可以让 gate 也容忍 ErrCheckChecksum,或把容忍判断从"错误身份"改成"地址形态"。

另外"id 小 = legacy base58 优先"只是 ids 0/1 恰好是 btc / btcMultiSign(system/address/btc/address.go:32-33),而 driver id 4~7 仍可被插件注册;建议显式排序(如 default driver 或 base58 系优先),否则换个注册顺序语义就悄悄变了。

这块是本次最接近共识的改动,却没有测试(CLAUDE.md:新代码必须有测试)。建议补:(a) 多 driver 都拒绝时多次调用返回同一 error;(b) dapp.CheckAddress 对同一 legacy 地址在 fork 高度前后分别容忍/拒绝。

问题 4(低,建议写进 PR 描述):GenesisInit 的放宽对所有高度生效

地址那条只影响 fork 高度以下的历史(>=fork 时两个 gate 都关闭、行为等价),而 GenesisInit 的上界是所有高度一起放宽:升级后新产生的区块里,total ∈ [1e17, 9e18] 的 token create 现在会执行成功,未升级节点会得到 ErrAmount、算出不同 state root 从而拒绝该块。PR 里 "No new fork height is needed" 那段只论证了地址改动。以我的判断这个取舍本身自洽(恢复与 token dapp preCreate 相同的界;把严格检查挂到未来 fork 高度上并不能救历史块),但请显式写下升级期混合版本的预期。

问题 5(nit):新增用例的断言与说明不一致

account/genesis_checkamount_test.go:57 的拒绝来自 safeAdd 的和上界(上一步已把 1e17 记到 addr1,1e17 + (MaxTokenBalance+1) > MaxTokenBalance),不是说明里写的"amount 超过 MaxTokenBalance"。而 preCreate 允许 total 恰好等于 MaxTokenBalance(tokendb.go:163 是 >),replay 真正需要的边界是"新账户 + amount == MaxTokenBalance 必须成功",这条路径目前没覆盖。建议拆成三条:新账户 GenesisInit(MaxTokenBalance) 成功、新账户 GenesisInit(MaxTokenBalance+1) 返回 ErrAmount、balance+amount 超界返回 ErrAmount。

其他

  • account/genesis.go:21-33 的注释有 13 行,executor/execenv.go:218-222 里还写死了 bityuan 高度 2270000;信息量主要来自 PR 描述,框架仓库里建议压缩到 3~4 行并去掉链特定高度,细节留在描述里。
  • account/genesis.go 注释引用的调用方在 33cn/plugin,本仓库读者无法验证,建议把 plugin/dapp/token/executor/tokendb.go 这个路径写进注释。

结论: 方向正确,GenesisInit 一侧的论证经核对成立。建议合并前:问题 2 删掉或说明并补测试,问题 1 至少在注释/描述里写明缓存这个残留缺口(能按高度分桶更好),问题 3 补一个 CheckAddress 的回归测试;问题 4 请补进 PR 描述。

bysomeone and others added 4 commits September 28, 2026 14:22
execCheckTx is not on the path a block is replayed through, so this change
contributed nothing to the 546820 fix: EventCheckTx has exactly one sender,
system/mempool/check.go, and the mempool checks the same address earlier with
the raw address.CheckAddress, which is stricter than the wrapper. Anything that
reaches execCheckTx had already passed the stricter check, so the wrapper could
not change the outcome there.

The replay is fixed by visiting the address drivers in a fixed order, which
makes Exec's check -- the one that runs during a replay -- report the legacy
errors the fork gates tolerate. Keeping the execCheckTx change would be a
consensus-path edit with no causal evidence and no test.

Co-authored-by: Claude <[email protected]>
The cached answer depends on the height -- isEnable enables every driver when
the height is negative -- but the cache was keyed by the address alone, and the
callers that pass -1 (rpc/client, rpc/jrpchandler, wallet, the CLI commands)
run in the same process as the ones that pass a real height. One of those
queries could therefore decide a later check at a real height: the mempool
check in system/mempool/check.go passes a real height, would take the cached
nil, and accept a transaction the other nodes reject. That transaction can be
mined, and the nodes that did not answer such a query reject the block -- the
same class of divergence this change set is about, with the trigger moved from
the driver map's iteration order to the call order.

Include the drivers enabled at the height in the key. ValidateAddr does not
take a height, so the address and the enabled set are the whole input, and a
set is also what a later address.Init would invalidate.

Co-authored-by: Claude <[email protected]>
CheckBase58Address reports ErrCheckChecksum for a 25-byte address whose checksum
fails (the older check, kept for compatibility) and ErrAddressChecksum for
longer ones, and both are the same legacy form. Before the drivers were visited
in a fixed order, such an address could come back as ErrCheckVersion instead --
from a driver that expects another version byte -- which the gate above already
tolerates. Now it reports ErrCheckChecksum every time, so leaving the gate as it
was would flip these addresses from "sometimes accepted" to "always rejected"
below the fork, where the blocks on the chain were executed.

Tolerate both errors below ForkBase58AddressCheck. At and above it nothing
changes, so this only affects the replay of heights below the fork.

Co-authored-by: Claude <[email protected]>
The upper-bound test rejected MaxTokenBalance+1 on an account that already held
MaxCoin*coinPrecision, so it passed on safeAdd's sum check rather than on the
amount, and the boundary a replay actually depends on was not covered: the
token executor's preCreate allows total == MaxTokenBalance, so a fresh account
initialized with exactly that has to succeed.

Cover the three cases separately -- between the two bounds, exactly
MaxTokenBalance on an empty account, and the sum bound -- and cut the comment in
GenesisInit down to the part a reader of this repository can check, pointing at
the caller in 33cn/plugin by path.

Co-authored-by: Claude <[email protected]>
@bysomeone

Copy link
Copy Markdown
Collaborator Author

感谢 review,逐条回一下。1/2/3/5 都改了,注释与描述那两条也一起处理了,改动分 4 个提交追加在本 PR 上(5319122 / 9f230f1 / 5663043 / f6eedba)。

问题 2:execenv.go 不是修复路径 —— 你说得对,那处已删除

我核了三点:EventCheckTx 全仓只有 system/mempool/check.go:39 一个发送方(33cn/plugin 里没有);区块执行走 Exec,而 Exec 用的本来就是 wrapper;且 mempool 在更早的 check.go:70 用的是裸 address.CheckAddress(更严),所以能走到 execCheckTx 的交易早就过了那道更严的检查,wrapper 在那里不可能改变结果。546820 是排序改动修好的(排序后确定性返回 driver 0 的 ErrCheckVersion,被 gate 容忍)。

那个 hunk 已 revert(revert(executor): drop the exec-time address check)——共识路径上只保留有因果证据的改动。我原来那段注释里"strict path runs first"的因果关系确实不成立,一并删掉了。

问题 1:缓存 —— 已修,不是只写注释

先确认了你的实测:-1 让 isEnable 对所有 driver 返回 true(common/address/driver.go:167),而传 -1 的调用点共 9 处,全在非共识路径(rpc/client 3 处、rpc/jrpchandler、wallet/sendtx、account、4 个 CLI 命令,另加 plugin 的 lightclient)。危害路径是完整的:某节点被查询污染缓存 → 它 mempool 的检查(传真实高度)取到缓存的 nil → 接受一笔别人拒绝的交易 → 若它出块,就产生一个别人拒绝的块。

改法:key 从 addr 变成 addr + "|" + 该高度启用的 driver id 列表。ValidateAddr 不吃高度,所以"地址 + 启用集合"就是全部输入;用集合做 key 还顺带免疫 address.Init 之后改 enableHeight。回归用例 TestCheckAddressCacheFollowsHeight(先 CheckAddress(ethAddr, -1) 再 CheckAddress(ethAddr, 0)):改动前失败、改动后通过。

问题 3:两条 checksum 错误 —— 按"让 gate 也容忍 ErrCheckChecksum"处理

已确认 CheckBase58Address 长度 25 走 ErrCheckChecksum、长度 >25 走 ErrAddressChecksum(address.go:204-217)。排序之后 25 字节这类地址必然报 ErrCheckChecksum,所以 gate 里加上了它,仍只在 ForkBase58AddressCheck 以下容忍 —— 方向上把"偶发通过"变成"必然通过",不依赖 bityuan 的经验结论。

关于"id 小 = legacy 优先是巧合":0/1 是 system/address/btc 注册的固定 id(NormalAddressID/MultiSignAddressID),插件只能注册 4~7(MaxID = 7),所以"升序 = legacy base58 优先"对任何插件组合都成立。这一点写进注释了。

用例:TestCheckAddressToleratesLegacyFormBelowFork(546820 那个地址,fork 前容忍 / fork 后拒绝)、TestCheckAddressToleratesLegacyChecksumBelowFork(25 字节校验和错误,同样两边都断言)、TestCheckAddressErrorIsDeterministic(57 个变体必须返回同一个 error)。三条在改动前都会失败,逐个验过。

顺带一个和你探针同源的实测数据,对"随机性"的量级可能有用:拿一个合法地址、只改最后一个字符(长度与版本字节都还对),在未修复的 upstream 代码上这 57 个地址返回四种不同 error —— ErrInvalidEthAddr 35、ErrAddressType 8、ErrCheckChecksum 7、ErrCheckVersion 7;修复后 57 个全部是 ErrCheckChecksum。

问题 4:GenesisInit 是所有高度放宽 —— 已写进 PR 描述

明确写出:升级窗口期内,total ∈ [1e17, 9e18] 的 token create 在已升级节点上执行成功、未升级节点上 ErrAmount,这是状态分叉而非仅重放差异,需要全网一起升级。挂到未来 fork 高度这个选项也写了为什么不采用(救不了已经在链上的块)。

问题 5:用例断言与说明不一致 —— 改了

原来那条的拒绝确实来自 safeAdd 的和上界(账户上已有 amount),不是 amount 本身。现在拆成三条独立断言:介于两界之间的 amount 成功、空账户 + amount == MaxTokenBalance 成功(这才是 preCreate 允许、replay 真正需要的边界)、以及和上界仍然生效。

其他

execenv.go 里写死的 bityuan 高度随 hunk 一起删了;account/genesis.go 的注释压到 5 行,调用方按你的建议写成 33cn/plugin dapp/token/executor/tokendb.go 路径;新增用例的说明都与断言对齐。

关于要不要重跑全量 replay:不需要

这次的改动在重放路径上要么无效、要么只会更宽 —— 缓存 key 在"进程内没有 -1 调用"时逐字节等价(重放就是这种进程);gate 放宽只发生在 fork 以下,只会让更多块被接受,不可能让原本通过的块失败;删掉的 execenv hunk 本就不在那条路径上。

已经跑过的那一轮(带排序修复的候选二进制,从创世重放)到 9,719,794 高度:394223 / 595494 / 688446 / 1473857 / 546820 全部通过,跨过 2270000,零 ErrCheckStateHash,2270000 与 6000000 的区块 hash 与健康节点逐字节一致。这一轮还顺带回答了另一个问题:干净进程(没有 -1 调用、缓存不会被污染)能一路重放到底,说明这段历史里不存在"靠被污染的缓存才被接受"的块 —— 也就是问题 1 那个洞没有污染过已经上链的历史,但它在运行期仍然是个分叉源(所以还是要修)。

The deterministic driver ordering built a fresh id slice, sorted it and composed a
string cache key on every CheckAddress call, which took the cache hit path from
40ns/0 allocs to 500ns/264B/7 allocs -- and that path runs for every transaction
(executor/execenv.go, system/mempool/check.go).

RegisterDriver now maintains driverOrder, the registered drivers with ascending id,
rebuilt with a plain scan (ids run 0..MaxID, and there are at most MaxID+1 drivers).
CheckAddress walks it directly, so no per-call slice or sort is left.

The cache keeps the address as its key and stores the enabled driver set in the
entry, as a bitmask over driverOrder, comparing it on the hit path instead of
composing it into the key. Reuse is still refused when the enabled set differs, so
a check at -1 still cannot decide a later one at a real height; an entry whose set
differs is replaced, which costs the callers that alternate between -1 and a real
height a recomputation rather than correctness.

Measured with a temporary benchmark (not committed), cache hit path:
upstream 40ns/0 allocs, before this commit 500ns/7 allocs, after 45ns/0 allocs.

Co-authored-by: Claude <[email protected]>
@bysomeone

Copy link
Copy Markdown
Collaborator Author

自查后又推了一个提交,改动集中在 common/address,行为语义不变:

1. 把确定性排序引入的热路径分配退回去。 前一版每次调用都会新建并排序一份 id 切片、再拼字符串缓存 key,命中路径从 40ns/0 alloc 变成 500ns/264B/7 alloc(每笔交易都走 executor/execenv.go、system/mempool/check.go)。现在 driverOrder 在 RegisterDriver 时维护好升序表,CheckAddress 直接遍历;缓存仍以地址为 key,把"该高度启用的驱动集合"以位掩码存进条目里、命中时比对,而不是拼进 key。跨高度不复用的性质保持不变(集合不同就不复用),代价只是 -1 与真实高度交替调用时会重算。实测回到 45ns/0 alloc。

2. amount == 0 保留放宽,并把理由写清楚了。 上一版描述里"零金额已在 Coins.Exec_Genesis 拦住"这句话只对创世路径成立。#1378 之前 GenesisInit 完全没有校验(链上那些块就是在这个规则下执行的),本 PR 退掉的是 #1378 顺手带进来的两处(== 0 和 1e17 上界),只保留它本意的"拒负"。若把下界也一起收紧,等于新增一个拒绝面——而"新增拒绝"正是本 PR 要修的失效模式:重放遇到一个 genesis_init(addr, 0) 的块就会停下。调用方共三个:coins Exec_Genesis(仅 height == 0,入口自带校验)、token tokenFinishCreate(上界的来由)、js genesis_init(任意高度、无金额守卫,amount == 0 只有从这里可达)。

按你的"排除升级窗口分叉"这条口径重新核过一遍 amount == 0 的可达性:bityuan 上不可达 —— preCreate 已经 token.GetTotal() <= 0 直接拒(tokendb.go:163),所以 token 路径传进来的 total 必然 > 0;而 js 这个唯一无守卫的调用方没有链接进比特元的二进制(plugin/dapp/init/init.go 的列表里没有 dapp/js,全仓也搜不到)。所以比特元的新增接受面只有 token 的 total ∈ [1e17, 9e18],而那个正是修复本身(不放宽就还是 394223 卡死)。这一段也写进描述里了。

3. 窗口内的一处行为变更改为披露。 在 [ForkMultiSignAddress, ForkBase58AddressCheck) 之间,这类地址的确定性错误是 ErrCheckVersion,而它在 ForkMultiSignAddress 之上不再被容忍,所以"只坏在旧格式校验"的地址从"取决于随机遍历顺序、偶尔被容忍"变成"恒被拒"。要恢复窗口内的旧容忍,把 ErrCheckVersion 的容忍期延到 ForkBase58AddressCheck 即可——但这属于 fork 语义,我没有擅自改。bityuan 两个 fork 都是 2270000,窗口为空、够不到,受影响的 546820 也远在两个高度之下。

重启验证(从创世重放)跑的就是放宽后的规则;<= 0 会引入上面说的新拒绝面,需要再跑一轮才能验,所以没有采用。

@bysomeone
bysomeone requested a review from libangzhu September 30, 2026 08:46
@bysomeone
bysomeone merged commit e039b87 into 33cn:master Sep 30, 2026
13 checks passed
@33cn

33cn commented Sep 30, 2026

Copy link
Copy Markdown
Owner

🎉 This PR is included in version 1.72.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

@33cn 33cn added the released label Sep 30, 2026
bysomeone added a commit to bysomeone/chain33 that referenced this pull request Oct 7, 2026
The gate's two fork heights are separate knobs and are equal only on chains that say
so -- bityuan sets both to 2270000, the chain33 defaults are 1298600 and 1800000 -- so
a test that sets them to the same value cannot see the window between them. Inside
[ForkMultiSignAddress, ForkBase58AddressCheck) the version error stops being tolerated
while the checksum error still is, so the legacy form at 546820 is rejected there:
that is the behaviour change 33cn#1401 disclosed and left in place. The 25-byte checksum
failure does not depend on ForkMultiSignAddress and is rejected at every height.

The 25-byte assertions fail if ErrCheckChecksum is tolerated again below the fork.

Co-authored-by: Claude <[email protected]>
33cn pushed a commit that referenced this pull request Oct 8, 2026
The gate's two fork heights are separate knobs and are equal only on chains that say
so -- bityuan sets both to 2270000, the chain33 defaults are 1298600 and 1800000 -- so
a test that sets them to the same value cannot see the window between them. Inside
[ForkMultiSignAddress, ForkBase58AddressCheck) the version error stops being tolerated
while the checksum error still is, so the legacy form at 546820 is rejected there:
that is the behaviour change #1401 disclosed and left in place. The 25-byte checksum
failure does not depend on ForkMultiSignAddress and is rejected at every height.

The 25-byte assertions fail if ErrCheckChecksum is tolerated again below the fork.

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GenesisInit's CheckAmount upper bound makes already-executed tokenFinishCreate blocks unreplayable

3 participants