From 0a8b6327d2a994a9da9888d2603d22759f4de260 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:15:32 -0700 Subject: [PATCH 01/27] =?UTF-8?q?feat(core):=20L1=20=E5=8F=8D=E6=BB=A5?= =?UTF-8?q?=E7=94=A8=E5=85=B1=E8=AF=86=E5=86=85=E6=A0=B8=E2=80=94=E2=80=94?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E9=98=B2=E5=88=B7=E5=BA=95=E7=BA=BF(?= =?UTF-8?q?=E9=9A=8F=E9=95=BF=E5=BA=A6=E9=80=92=E5=A2=9E)+=20=E8=B4=A8?= =?UTF-8?q?=E6=8A=BC=E5=BC=8F=E5=8C=BF=E5=90=8D=E8=BA=AB=E4=BB=BD(?= =?UTF-8?q?=E7=BA=AF=E9=94=81=E4=BB=93=E6=97=A0=E7=BD=9A=E6=B2=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个新的 memo 约定软分叉,均遵循 staking.ts/redpacket.ts 的既有模式(config 常量 + 纯函数解析 文件 + blockchain.ts 三处 redOpError/applyTx/applySelect)。 消息防刷底线: - minMessageBurnFor(memoLength) = MESSAGE_BURN + floor(memoLength / MESSAGE_BURN_PER_CHAR_UNIT), 整数除法同 minFeeFor 写法。校验落点是 redOpError(带 atHeight 参数)而非 verifyTransaction(无 height 参数,无法做激活高度门控)。只对 isRealMessage(真消息,非协议层 memo)生效,不影响 PET/FISH 等本就低烧币的协议操作。createMessage 默认 burn 同步改为按长度算,避免不知情客户端 激活后发长消息被拒。MIN_MESSAGE_BURN_ACTIVATION_HEIGHT=40000(占位,部署前须核对实时链高)。 质押式匿名身份(新文件 identity.ts): - IDCLAIM| 转托管认领 / IDRELEASE| 解锁,纯资金锁仓反女巫,无 SLASH、 无仲裁者。IDENTITY_LOCK_BLOCKS=240(中继质押锁定期 20 倍——没有罚没,锁仓时长本身就是全部代价)。 独立新原语,不挂在 names.ts 昵称系统上(昵称已实网免费上线,追加质押要求会打破既有承诺)。 语义:押金须持续持有才算拥有身份——IDRELEASE 后假名立刻可被别人重新认领,而非一次性入场费。 复用 names.ts 的 isValidName/RESERVED_NAMES 字符集规则。IDENTITY_ACTIVATION_HEIGHT=45000(占位)。 IDENTITY_ESCROW_ADDRESS=…4(红包…1/质押…2/铸币…3 之后下一个)。 交叉 wiring:isProtocolMemo 排除 IDCLAIM/IDRELEASE 前缀,防止被消息防刷误判。 Co-Authored-By: Claude Fable 5 --- packages/core/src/blockchain.ts | 119 ++++++++++++++++++++++++++++-- packages/core/src/config.ts | 59 +++++++++++++++ packages/core/src/identity.ts | 120 +++++++++++++++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/src/messages.ts | 19 ++++- packages/core/src/transaction.ts | 14 ++-- 6 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/identity.ts diff --git a/packages/core/src/blockchain.ts b/packages/core/src/blockchain.ts index e12c98d..5a5164d 100644 --- a/packages/core/src/blockchain.ts +++ b/packages/core/src/blockchain.ts @@ -54,11 +54,21 @@ import { REDEEM_PREFIX, MINT_ADDRESS, SYSTEM_ADDRESSES, + MIN_MESSAGE_BURN_ACTIVATION_HEIGHT, + minMessageBurnFor, + IDENTITY_ESCROW_ADDRESS, + IDCLAIM_PREFIX, + IDRELEASE_PREFIX, + IDENTITY_STAKE_MIN, + IDENTITY_LOCK_BLOCKS, + IDENTITY_ACTIVATION_HEIGHT, } from './config.js'; import { isValidAddress, merkleRoot } from './crypto.js'; import { parseRedCreate, parseClaimId, parseRefundId, computeShare, redSeed, type RedMode } from './redpacket.js'; import { parseStakeCreate, parseUnstakeId, parseSlash, computeStakeMin, type StakePool } from './staking.js'; import { parseRedeem, redeemSplit, isMintDeposit } from './mint.js'; +import { isRealMessage } from './messages.js'; +import { parseIdentityClaim, parseIdentityRelease, type IdentityStake } from './identity.js'; /** 创世区块:不做 PoW,参数全固定 → 所有节点算出同一个 hash。 */ export function genesisBlock(): Block { @@ -167,6 +177,8 @@ export interface ChainState { // 刻意与「托管地址原始余额」区分——激活前误入 …3 托管的普通转账不计入可兑现储备(否则会被当成无对应券的储备被兑走, // 且与公开视图 computeMintState 不一致)。兑现校验 gross ≤ mintReserve 即以此为准 → 链上储备 ≡ computeMintState。 mintReserve: number; + identityClaims: Map; // 身份质押池:id(IDCLAIM 交易 txid)→ 质押(Phase 3B,无罚没) + identityByPseudonym: Map; // 假名 → 当前活跃 claimId(IDRELEASE 后移除,假名重新可认领) } export interface ChainJSON { @@ -185,6 +197,8 @@ function redOpError( pools: Map, stakes: Map, mintReserve: number, + identityClaims: Map, + identityByPseudonym: Map, atHeight: number, blockDifficulty = GENESIS_DIFFICULTY, ): string | null { @@ -198,6 +212,11 @@ function redOpError( if (!mintActive && m.startsWith(REDEEM_PREFIX) && tx.amount === 0) { return `铸币厂尚未激活(激活高度 ${MINT_ACTIVATION_HEIGHT})`; } + const identityActive = atHeight >= IDENTITY_ACTIVATION_HEIGHT; + // 只拦「amount=0 的解锁新边界」;激活前带 IDRELEASE| 前缀但 amount>0 的普通转账仍按历史普通交易处理。 + if (!identityActive && m.startsWith(IDRELEASE_PREFIX) && tx.amount === 0) { + return `身份质押尚未激活(激活高度 ${IDENTITY_ACTIVATION_HEIGHT})`; + } // ---- 铸币厂操作(DEPOSIT/REDEEM):与质押 STAKE/SLASH 同款合法性校验 ---- if (mintActive && m.startsWith(REDEEM_PREFIX)) { if ((tx.burn ?? 0) > 0) return '铸币兑现不能附带销毁'; @@ -251,6 +270,28 @@ function redOpError( if (tx.amount < minStake) return `质押额须 ≥ 该角色最低押金 ${minStake}(当前难度动态值)`; return null; } + // ---- 身份质押操作(IDCLAIM/IDRELEASE):纯资金锁仓反女巫,无罚没、无仲裁者,与质押 STAKE/UNSTAKE 同款 ---- + if (identityActive && m.startsWith(IDRELEASE_PREFIX)) { + if ((tx.burn ?? 0) > 0) return '身份操作不能附带销毁'; + if (tx.amount !== 0) return '解锁金额须为 0'; + const id = parseIdentityRelease(m); + if (!id) return '解锁格式无效'; + const c = identityClaims.get(id); + if (!c) return '身份质押不存在'; + if (tx.from !== c.staker) return '只有质押人能解锁'; + if (c.released) return '该身份质押已解锁'; + if (atHeight < c.lockedUntil) return `身份质押锁定中(需到第 ${c.lockedUntil} 块)`; + return null; + } + // 认领 = 转给身份托管地址(旧节点也当普通转账锁进托管 → 不静默分叉)。发往托管地址的交易**必须**是合法认领。 + if (identityActive && tx.to === IDENTITY_ESCROW_ADDRESS) { + if ((tx.burn ?? 0) > 0) return '身份操作不能附带销毁'; + const meta = parseIdentityClaim(m); + if (!meta) return '发往身份托管地址的交易必须是合法认领(IDCLAIM|)'; + if (tx.amount < IDENTITY_STAKE_MIN) return `身份押金须 ≥ 最低押金 ${IDENTITY_STAKE_MIN}`; + if (identityByPseudonym.has(meta.pseudonym)) return `假名“${meta.pseudonym}”已被认领中`; + return null; + } // ---- 红包操作(RED/CLAIM/REFUND)---- if (m.startsWith(CLAIM_PREFIX)) { if ((tx.burn ?? 0) > 0) return '红包交易不能附带销毁'; @@ -284,6 +325,12 @@ function redOpError( if (tx.amount < meta.count) return '红包总额须 ≥ 份数(每份至少 1)'; return null; } + // ---- 消息防刷底线:收紧校验,不引入新状态机,故放最后(真消息不命中前面任何托管/前缀分支)---- + if (atHeight >= MIN_MESSAGE_BURN_ACTIVATION_HEIGHT && isRealMessage(tx)) { + const memoLen = [...tx.memo].length; // Unicode 码点,同 MAX_MEMO 口径 + const required = minMessageBurnFor(memoLen); + if ((tx.burn ?? 0) < required) return `消息销毁额过低(需 ≥ ${required},当前 ${tx.burn ?? 0})`; + } return null; // NORMAL } @@ -385,6 +432,36 @@ function applyTx(tx: Transaction, st: ChainState, blockHash: string, atHeight: n return; } } + // ---- 身份质押(Phase 3B):IDCLAIM/IDRELEASE,与质押 STAKE/UNSTAKE 同款,共识权威在此,无罚没 ---- + // 认领:转给身份托管地址 → 锁押金、记录质押。余额效果 = 普通转账到托管(旧节点也如此),额外记录。 + if (atHeight >= IDENTITY_ACTIVATION_HEIGHT && tx.to === IDENTITY_ESCROW_ADDRESS && m.startsWith(IDCLAIM_PREFIX)) { + const meta = parseIdentityClaim(m); + if (meta && tx.amount >= IDENTITY_STAKE_MIN && !st.identityByPseudonym.has(meta.pseudonym)) { + credit(tx.from, -(tx.amount + tx.fee)); + credit(IDENTITY_ESCROW_ADDRESS, tx.amount); + st.identityClaims.set(tx.txid, { + staker: tx.from, pseudonym: meta.pseudonym, amount: tx.amount, + lockedUntil: atHeight + IDENTITY_LOCK_BLOCKS, createdHeight: atHeight, released: false, + }); + st.identityByPseudonym.set(meta.pseudonym, tx.txid); + bump(); + return; + } + // 发往托管的非法 IDCLAIM → 合法链上不会发生(validateChain/redOpError 已拦);稳妥起见落到 NORMAL + } + // 解锁:质押人取回全部本金(无罚没扣减);已被 redOpError 校验过锁定期/归属/未解锁 + if (atHeight >= IDENTITY_ACTIVATION_HEIGHT && m.startsWith(IDRELEASE_PREFIX) && tx.amount === 0) { + const id = parseIdentityRelease(m); + const c = id ? st.identityClaims.get(id) : undefined; + if (c && !c.released) { + credit(tx.from, c.amount - tx.fee); // 全额退回(无罚没) + credit(IDENTITY_ESCROW_ADDRESS, -c.amount); + c.released = true; + if (st.identityByPseudonym.get(c.pseudonym) === id) st.identityByPseudonym.delete(c.pseudonym); + bump(); + return; + } + } // ---- 铸币厂充值(Phase A):转给托管地址 → 锁额、记储备。余额效果 = 普通转账到托管(旧节点也如此),额外累加 mintReserve。 // **单列此分支(而非落 NORMAL)**:只有激活后的合法 MINT|DEPOSIT 才计入可兑现储备,排除激活前误入托管的普通转账。 if (atHeight >= MINT_ACTIVATION_HEIGHT && tx.to === MINT_ESCROW_ADDRESS && isMintDeposit(m)) { @@ -502,6 +579,33 @@ function applySelect(tx: Transaction, st: ChainState, atHeight: number, blockDif return; } } + // 身份质押在选包阶段与 applyTx 完全一致(无区块 hash 依赖 → 可原样推进,杜绝选包/校验分歧) + if (atHeight >= IDENTITY_ACTIVATION_HEIGHT && tx.to === IDENTITY_ESCROW_ADDRESS && m.startsWith(IDCLAIM_PREFIX)) { + const meta = parseIdentityClaim(m); + if (meta && tx.amount >= IDENTITY_STAKE_MIN && !st.identityByPseudonym.has(meta.pseudonym)) { + credit(tx.from, -(tx.amount + tx.fee)); + credit(IDENTITY_ESCROW_ADDRESS, tx.amount); + st.identityClaims.set(tx.txid, { + staker: tx.from, pseudonym: meta.pseudonym, amount: tx.amount, + lockedUntil: atHeight + IDENTITY_LOCK_BLOCKS, createdHeight: atHeight, released: false, + }); + st.identityByPseudonym.set(meta.pseudonym, tx.txid); + bump(); + return; + } + } + if (atHeight >= IDENTITY_ACTIVATION_HEIGHT && m.startsWith(IDRELEASE_PREFIX) && tx.amount === 0) { + const id = parseIdentityRelease(m); + const c = id ? st.identityClaims.get(id) : undefined; + if (c && !c.released) { + credit(tx.from, c.amount - tx.fee); + credit(IDENTITY_ESCROW_ADDRESS, -c.amount); + c.released = true; + if (st.identityByPseudonym.get(c.pseudonym) === id) st.identityByPseudonym.delete(c.pseudonym); + bump(); + return; + } + } // 铸币充值/兑现在选包阶段与 applyTx 完全一致(无区块 hash 依赖 → 可原样推进,杜绝选包/校验分歧) if (atHeight >= MINT_ACTIVATION_HEIGHT && tx.to === MINT_ESCROW_ADDRESS && isMintDeposit(m)) { credit(tx.from, -(tx.amount + tx.fee)); @@ -554,7 +658,7 @@ export class Blockchain { // ---- 状态:重放整条链,得到余额表 / nonce 表 / 红包池(假定链已合法;校验在 validateChain)---- computeState(chain: Block[] = this.chain): ChainState { - const st: ChainState = { balances: new Map(), nonces: new Map(), pools: new Map(), stakes: new Map(), mintReserve: 0 }; + const st: ChainState = { balances: new Map(), nonces: new Map(), pools: new Map(), stakes: new Map(), mintReserve: 0, identityClaims: new Map(), identityByPseudonym: new Map() }; for (const block of chain) { for (const tx of block.transactions) { if (isCoinbase(tx)) { @@ -589,7 +693,8 @@ export class Blockchain { tx.memo.startsWith(REFUND_PREFIX) || tx.memo.startsWith(UNSTAKE_PREFIX) || tx.memo.startsWith(SLASH_PREFIX) || - tx.memo.startsWith(REDEEM_PREFIX)); + tx.memo.startsWith(REDEEM_PREFIX) || + tx.memo.startsWith(IDRELEASE_PREFIX)); if (!Number.isInteger(tx.amount) || tx.amount < 0) return { ok: false, error: '金额必须是非负整数' }; if (!Number.isInteger(burn) || burn < 0) return { ok: false, error: '销毁额必须是非负整数' }; if (tx.amount === 0 && burn === 0 && !isZeroOp) return { ok: false, error: '空交易:转账须金额>0,消息须销毁额>0' }; @@ -602,9 +707,9 @@ export class Blockchain { // 发往红包托管地址只允许合法红包(RED);其它一律拒(防误把钱锁死)。redOpError 下方统一判。 if (this.mempool.some((t) => t.txid === tx.txid)) return { ok: false, error: '交易已在池中' }; - const { balances, nonces, pools, stakes, mintReserve } = this.computeState(); + const { balances, nonces, pools, stakes, mintReserve, identityClaims, identityByPseudonym } = this.computeState(); // 红包/质押/铸币操作合法性:池存在/未抢完/未重复领/已过期、质押锁定期/度量者权限、铸币储备等(按 height+1 估算) - const redErr = redOpError(tx, pools, stakes, mintReserve, this.height + 1, this.tipDifficulty()); + const redErr = redOpError(tx, pools, stakes, mintReserve, identityClaims, identityByPseudonym, this.height + 1, this.tipDifficulty()); if (redErr) return { ok: false, error: redErr }; const pending = this.mempool.filter((t) => t.from === tx.from); const expectedNonce = (nonces.get(tx.from) ?? 0) + pending.length; @@ -646,7 +751,7 @@ export class Blockchain { // 同 validateChain 的接纳条件:nonce 对、红包操作合法、余额够付、自洽签名 → 必能通过整链校验 if ( tx.nonce === expected && - !redOpError(tx, st.pools, st.stakes, st.mintReserve, atHeight, this.tipDifficulty()) && + !redOpError(tx, st.pools, st.stakes, st.mintReserve, st.identityClaims, st.identityByPseudonym, atHeight, this.tipDifficulty()) && cost <= (st.balances.get(tx.from) ?? 0) && verifyTransaction(tx) ) { @@ -754,7 +859,7 @@ export class Blockchain { if (badCp) return { ok: false, error: `#${badCp.index} 与 checkpoint 不一致` }; // 状态机:余额/nonce/红包池。与 computeState 共用 applyTx,确保各节点一致。 - const st: ChainState = { balances: new Map(), nonces: new Map(), pools: new Map(), stakes: new Map(), mintReserve: 0 }; + const st: ChainState = { balances: new Map(), nonces: new Map(), pools: new Map(), stakes: new Map(), mintReserve: 0, identityClaims: new Map(), identityByPseudonym: new Map() }; for (let i = 0; i < chain.length; i++) { const b = chain[i]; @@ -810,7 +915,7 @@ export class Blockchain { // 普通交易不得打到空地址(销毁应走 burn 字段);发往托管地址的合法性交给 redOpError 判 if (tx.to === NULL_ADDRESS) return { ok: false, error: `#${i} 收款为空地址非法` }; // 红包操作合法性(池存在/未抢完/未重复领/发起人退款且已过期…);非红包返回 null - const redErr = redOpError(tx, st.pools, st.stakes, st.mintReserve, b.index, b.difficulty); + const redErr = redOpError(tx, st.pools, st.stakes, st.mintReserve, st.identityClaims, st.identityByPseudonym, b.index, b.difficulty); if (redErr) return { ok: false, error: `#${i} 红包/质押/铸币:${redErr}` }; // 余额够付:发送方实付 = 金额 + 手续费 + 销毁额(RED=总额+费;CLAIM/REFUND=费;收到的 share 由 applyTx 入账) const cost = tx.amount + tx.fee + (tx.burn ?? 0); diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 0c9c3df..ae00688 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -272,3 +272,62 @@ export const MINT_FEE_BPS = 500; * 否则若已被链高越过会有 retroactive 激活风险(尽管 …3 与这些 memo 历史上从未出现,实际风险极小)。 */ export const MINT_ACTIVATION_HEIGHT = 30_000; + +// ---- 消息防刷底线(软分叉:收紧校验,新版节点拒绝旧版会放行的低销毁消息)---- +/** + * 消息销毁额随备注长度(Unicode 码点)线性增长的斜率:每 MESSAGE_BURN_PER_CHAR_UNIT 个码点, + * 底线再 +1(floor 整数)。与 minFeeFor 的“基点/base”整数除法同款写法,避免浮点跨节点分叉。 + * 取 20:一条 1 字符消息门槛仍是 MESSAGE_BURN(不误伤日常寒暄),MAX_MEMO(512) 长消息门槛涨到 + * MESSAGE_BURN+25——长文/垃圾灌水成本显著上升,短消息几乎不受影响。 + */ +export const MESSAGE_BURN_PER_CHAR_UNIT = 20; + +/** + * 某条真实链上消息(isRealMessage(tx),见 messages.ts)所需的最低销毁额(整数): + * MESSAGE_BURN + floor(memoLength / MESSAGE_BURN_PER_CHAR_UNIT) + * 与 minFeeFor(amount) 同款“底 + 比例”整数写法。memoLength 按 Unicode 码点计(与 MAX_MEMO 一致口径)。 + * 协议层 memo(PET/FISH/…,经 isProtocolMemo 排除)不受此约束——它们的低销毁本就是刻意的游戏经济成本。 + */ +export function minMessageBurnFor(memoLength: number): number { + return MESSAGE_BURN + Math.floor(memoLength / MESSAGE_BURN_PER_CHAR_UNIT); +} + +/** + * 消息防刷底线共识激活高度。该高度前,消息销毁额仍只按旧规则校验(burn>0 即可,见 verifyTransaction)—— + * 避免升级节点把激活前已广播/挂在旧节点 mempool 里的低销毁消息 retroactive 判非法。 + * ⚠️ 占位值 40000 —— **合并/部署前必须确认它 ≥ 当前实时链高 + 升级窗口**(同 STAKING_ACTIVATION_HEIGHT=16000 的选法)。 + */ +export const MIN_MESSAGE_BURN_ACTIVATION_HEIGHT = 40_000; + +// ---- 质押身份(Phase 3B:纯资金锁仓反女巫,无罚没、无仲裁者,软分叉)---- +/** 身份托管地址:IDCLAIM 锁定的押金记到这里(不可花)。与红包 …1 / 质押 …2 / 铸币 …3 区分(…4)。 */ +export const IDENTITY_ESCROW_ADDRESS = '0x' + '0'.repeat(63) + '4'; + +/** 两种操作的 memo 前缀。IDCLAIM 是“转托管 amount=押金 + memo”;IDRELEASE 是 amount=0 + memo。 */ +export const IDCLAIM_PREFIX = 'IDCLAIM|'; // 认领:IDCLAIM| +export const IDRELEASE_PREFIX = 'IDRELEASE|'; // 解锁:IDRELEASE|(仅质押人、过 IDENTITY_LOCK_BLOCKS) + +/** + * 身份最低押金($V0ID)。参照 STAKE_MIN(guard=500/hsdir=300/middle=100,out of GENESIS_PREMINE=1000): + * 取 200——比中等角色 middle(100) 高(身份是永久性资产,成本应高于一次性中继角色押金), + * 但明显低于 guard(500),避免在 ~1000 币的小测试经济体里贵到没人用得起。对女巫攻击(批量注册马甲) + * 构成真实成本:想开 N 个马甲身份要锁 200N 币。教学/小算力网络的保守起点,可按需调大(改它即软分叉)。 + */ +export const IDENTITY_STAKE_MIN = 200; + +/** + * 身份押金锁定块数:认领后再过这么多块才能 IDRELEASE 解锁取回押金。 + * 取 STAKE_LOCK_BLOCKS(12)的 20×=240(约 32 分钟 @ 8s 目标出块)——本机制**没有 SLASH**, + * 唯一的反女巫成本就是“资金被锁的时长”本身,故须显著长于中继质押(后者靠罚没+锁定双重威慑, + * 锁定只需覆盖一个 EPOCH_BLOCKS 度量周期即可;这里锁定期本身就是全部代价,必须长到让批量注册马甲不划算)。 + * 教学/小算力网络的保守起点,可按需调大;所有节点须一致(改它即软分叉)。 + */ +export const IDENTITY_LOCK_BLOCKS = 240; + +/** + * 身份质押共识激活高度。该高度前,`IDCLAIM|`/`IDRELEASE|` 备注和 `…4` 托管地址都按历史普通交易处理 + * (amount=0 的 IDRELEASE 新边界仍拒),避免升级节点重放老链时把历史普通 memo/转账 retroactive 地解释成身份操作。 + * ⚠️ 占位值 45000 —— **合并/部署前必须确认它 ≥ 当前实时链高 + 升级窗口**(同 STAKING_ACTIVATION_HEIGHT=16000 的选法), + * 且与 MIN_MESSAGE_BURN_ACTIVATION_HEIGHT(40000)/STAKING_ACTIVATION_HEIGHT(16000)/MINT_ACTIVATION_HEIGHT(30000) 互不相同。 + */ +export const IDENTITY_ACTIVATION_HEIGHT = 45_000; diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts new file mode 100644 index 0000000..ea315c8 --- /dev/null +++ b/packages/core/src/identity.ts @@ -0,0 +1,120 @@ +// 质押身份(Phase 3B):纯资金锁仓反女巫,无罚没、无仲裁者。本文件只放“纯函数”——memo 解析、 +// 入参类型、只读视图。真正的状态机在 blockchain.ts,computeState 与 validateChain 共用同一套 +// applyTx,保证矿工与校验方算出完全一致的结果。与质押 staking.ts / 红包 redpacket.ts 同款分工。 +// +// 两种操作(建在普通交易 + memo 之上,旧节点不认 → 软分叉,边界同质押 STAKE/UNSTAKE): +// 认领 IDCLAIM :转给托管地址(to==IDENTITY_ESCROW_ADDRESS)amount=押金,memo `IDCLAIM|`。 +// **注意 to=托管而非自转**:旧节点会把它当普通转账锁进托管(余额效果与新节点一致)→ +// 不“静默分叉”;分叉只在 IDRELEASE(amount=0,旧节点直接拒)处发生。 +// 解锁 IDRELEASE:amount=0,memo `IDRELEASE|`。仅质押人、且过 IDENTITY_LOCK_BLOCKS 锁定期后, +// 取回**全部**本金(本机制无罚没)。解锁后该假名立刻可被任何人重新认领——押金必须 +// “持续持有”才算拥有身份,不是一次性入场费,这是唯一的反女巫成本来源。 +// +// ⚠️ 与 names.ts 的耦合:复用其 isValidName/RESERVED_NAMES(1~20 位字符集 + 保留名规则),避免用户 +// 学两套昵称规则。这意味着以后修改 names.ts 的字符集/保留名会**同时是两个功能的软分叉**,改动前须知悉。 +import type { Block } from './block.js'; +import { isValidName, RESERVED_NAMES } from './names.js'; +import { + IDCLAIM_PREFIX, + IDRELEASE_PREFIX, + IDENTITY_ESCROW_ADDRESS, + IDENTITY_ACTIVATION_HEIGHT, + IDENTITY_STAKE_MIN, + IDENTITY_LOCK_BLOCKS, +} from './config.js'; + +const TXID_RE = /^[0-9a-f]{64}$/; // 引用一笔认领交易的 txid + +/** 解析 IDCLAIM|;非法返回 null。不校验金额(金额在 blockchain 层结合 IDENTITY_STAKE_MIN 判)。 */ +export function parseIdentityClaim(memo: string): { pseudonym: string } | null { + if (!memo.startsWith(IDCLAIM_PREFIX)) return null; + const p = memo.slice(IDCLAIM_PREFIX.length).trim().toLowerCase(); + return isValidName(p) ? { pseudonym: p } : null; // 复用 names.ts 的字符集 + 保留名规则 +} + +/** IDRELEASE| → claimTxid(须像 64-hex txid);否则 null */ +export function parseIdentityRelease(memo: string): string | null { + if (!memo.startsWith(IDRELEASE_PREFIX)) return null; + const id = memo.slice(IDRELEASE_PREFIX.length); + return TXID_RE.test(id) ? id : null; +} + +/** 是否“amount=0 也合法”的身份操作(IDRELEASE)——verifyTransaction 据此放行零额交易(同质押 isZeroAmountStakeOp)。 */ +export function isZeroAmountIdentityOp(memo: string): boolean { + return memo.startsWith(IDRELEASE_PREFIX); +} + +/** 一份身份质押(共识状态的一部分;也是 computeIdentityState 的视图元素)。无 slashed 字段——本机制无罚没。 */ +export interface IdentityStake { + staker: string; // 质押人地址 + pseudonym: string; // 认领的假名 + amount: number; // 押金本金(恒定,无罚没扣减) + lockedUntil: number; // 锁定到此高度(含);IDRELEASE 须 atHeight >= lockedUntil + createdHeight: number; // 认领高度 + released: boolean; // 是否已解锁取回押金(防重复解锁) +} + +/** 校验入参并生成 memo。 */ +export function makeIdentityClaim(pseudonym: string): { ok: boolean; memo?: string; error?: string } { + const p = pseudonym.trim().toLowerCase(); + if (RESERVED_NAMES.has(p)) return { ok: false, error: `“${p}” 是保留名,禁止认领` }; + if (!isValidName(p)) return { ok: false, error: '假名需 1~20 位 小写字母/数字/_/-,且不以 0x 开头' }; + return { ok: true, memo: `${IDCLAIM_PREFIX}${p}` }; +} + +/** + * 扫整条链还原所有身份质押(只读视图:给 CLI/仪表盘展示)。这是“展示/过滤重放”,**与共识同源**—— + * 逻辑必须与 blockchain.ts applyTx 的 IDCLAIM/IDRELEASE 分支一致,共识权威仍是 applyTx。 + * 纯函数(只依赖链)→ reorg 安全;同块内按交易数组顺序,确定性。 + * + * 语义:**押金即身份**——一个 pseudonym 的当前持有者 = 其最新一笔“仍处于活跃质押状态”(未 released) + * 的 IDCLAIM 认领人;一旦该质押被 IDRELEASE 解锁,pseudonym 立刻变为可被任何人重新认领。 + */ +export function computeIdentityState(chain: Block[]): { + claims: Map; // id(IDCLAIM 交易 txid)→ 质押 + pseudonymToClaimId: Map; // 假名 → 当前活跃 claimId(release 后从此表移除) +} { + const claims = new Map(); + const pseudonymToClaimId = new Map(); + for (const b of chain) { + if (b.index < IDENTITY_ACTIVATION_HEIGHT) continue; + for (const tx of b.transactions) { + const m = tx.memo; + if (!m) continue; + // 认领 = 转给托管地址 + IDCLAIM| memo(旧节点也会把它当普通转账锁进托管 → 不静默分叉) + if (tx.to === IDENTITY_ESCROW_ADDRESS) { + const meta = parseIdentityClaim(m); + if (meta && tx.amount >= IDENTITY_STAKE_MIN && !pseudonymToClaimId.has(meta.pseudonym)) { + claims.set(tx.txid, { + staker: tx.from, + pseudonym: meta.pseudonym, + amount: tx.amount, + lockedUntil: b.index + IDENTITY_LOCK_BLOCKS, + createdHeight: b.index, + released: false, + }); + pseudonymToClaimId.set(meta.pseudonym, tx.txid); + } + continue; + } + const releaseId = parseIdentityRelease(m); + if (releaseId) { + const c = claims.get(releaseId); + if (c && !c.released && tx.from === c.staker && b.index >= c.lockedUntil) { + c.released = true; + if (pseudonymToClaimId.get(c.pseudonym) === releaseId) pseudonymToClaimId.delete(c.pseudonym); + } + } + } + } + return { claims, pseudonymToClaimId }; +} + +/** 假名 → 当前持有者地址(无活跃质押则 undefined)。给 CLI/API 解析用。 */ +export function resolveIdentityOwner( + state: ReturnType, + pseudonym: string, +): string | undefined { + const id = state.pseudonymToClaimId.get(pseudonym.trim().toLowerCase()); + return id ? state.claims.get(id)?.staker : undefined; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index bc9339c..0a373c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -20,6 +20,7 @@ export * from './names.js'; export * from './relays.js'; export * from './redpacket.js'; export * from './staking.js'; +export * from './identity.js'; export * from './mint.js'; export * from './pets.js'; export * from './fishing.js'; diff --git a/packages/core/src/messages.ts b/packages/core/src/messages.ts index 60cddf7..e7a39a9 100644 --- a/packages/core/src/messages.ts +++ b/packages/core/src/messages.ts @@ -2,7 +2,17 @@ // 任何节点扫一遍链,就能把这些交易还原成消息列表 —— 消息随链全网同步、永久可查,零中心化服务器。 // 收件箱 = to 是我的消息;发件箱 = from 是我的消息。memo 即正文,无需任何前缀。 import type { Block } from './block.js'; -import { NULL_ADDRESS, RED_PREFIX, CLAIM_PREFIX, REFUND_PREFIX, STAKE_PREFIX, UNSTAKE_PREFIX, SLASH_PREFIX } from './config.js'; +import { + NULL_ADDRESS, + RED_PREFIX, + CLAIM_PREFIX, + REFUND_PREFIX, + STAKE_PREFIX, + UNSTAKE_PREFIX, + SLASH_PREFIX, + IDCLAIM_PREFIX, + IDRELEASE_PREFIX, +} from './config.js'; import { PET_PREFIX, PETX_PREFIX, PETBREED_PREFIX, PETEVO_PREFIX, PETFARM_PREFIX, PETUNSTATION_PREFIX } from './pets.js'; import { FISH_PREFIX } from './fishing.js'; import { LAND_PREFIX, ZONE_PREFIX, PLANT_PREFIX, HARVEST_PREFIX, CROPX_PREFIX } from './farm.js'; @@ -45,6 +55,8 @@ export function isProtocolMemo(memo: string): boolean { memo.startsWith(STAKE_PREFIX) || memo.startsWith(UNSTAKE_PREFIX) || memo.startsWith(SLASH_PREFIX) || + memo.startsWith(IDCLAIM_PREFIX) || + memo.startsWith(IDRELEASE_PREFIX) || memo.startsWith(FISH_PREFIX) || memo.startsWith(LAND_PREFIX) || memo.startsWith(ZONE_PREFIX) || @@ -55,6 +67,11 @@ export function isProtocolMemo(memo: string): boolean { ); } +/** 是否“真实消息”(应受消息防刷底线约束,见 config.ts minMessageBurnFor):amount=0+burn>0 且非协议层 memo。 */ +export function isRealMessage(tx: { amount: number; burn?: number; memo: string }): boolean { + return isMessageTx(tx) && !isProtocolMemo(tx.memo); +} + /** 扫整条链,把所有消息交易还原成消息列表(最新在前)。协议层 memo(PET/RED/FISH…)不算私信,跳过。 */ export function parseMessages(chain: Block[]): ChainMessage[] { const out: ChainMessage[] = []; diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index 9224839..19a4225 100644 --- a/packages/core/src/transaction.ts +++ b/packages/core/src/transaction.ts @@ -8,12 +8,13 @@ import { MAX_MEMO, MIN_FEE, minFeeFor, - MESSAGE_BURN, + minMessageBurnFor, CLAIM_PREFIX, REFUND_PREFIX, UNSTAKE_PREFIX, SLASH_PREFIX, REDEEM_PREFIX, + IDRELEASE_PREFIX, } from './config.js'; import type { Wallet } from './wallet.js'; @@ -63,15 +64,17 @@ export function createTransaction( } /** - * 链上消息:由钱包签名。amount 恒 0(不转币),burn = 烧进虚空的 $V0ID(默认 MESSAGE_BURN),memo = 消息正文。 + * 链上消息:由钱包签名。amount 恒 0(不转币),burn = 烧进虚空的 $V0ID(默认按 minMessageBurnFor(memo 长度) + * 随长度递增,见 config.ts;短消息等于旧默认值 MESSAGE_BURN),memo = 消息正文。 * 另付 fee(gas,默认 MIN_FEE)给打包矿工。to = 收件人地址(实收 0 币,只是消息的投递目标)。 + * ⚠️ 默认值须随长度递增:消息防刷底线激活后,若仍用旧的扁平 MESSAGE_BURN 默认值发长消息会被 mempool 拒收。 */ export function createMessage( wallet: Wallet, to: string, text: string, nonce: number, - burn = MESSAGE_BURN, + burn = minMessageBurnFor([...text].length), fee = MIN_FEE, ): Transaction { const base: TransactionPayload = { from: wallet.address, to, amount: 0, fee, nonce, timestamp: Date.now(), memo: text, burn }; @@ -123,14 +126,15 @@ export function verifyTransaction(t: Transaction): boolean { if (!Number.isInteger(t.amount) || t.amount < 0 || t.amount > Number.MAX_SAFE_INTEGER) return false; if (!Number.isInteger(burn) || burn < 0 || burn > Number.MAX_SAFE_INTEGER) return false; // 空操作交易(既不转账 amount=0 又不销毁 burn=0)一律拒:转账须 amount>0,消息须 burn>0。 - // 例外:红包 CLAIM/REFUND、质押 UNSTAKE/SLASH、铸币 REDEEM 都是 amount=0(由共识从托管池支付/移交,不在本交易里转币)。 + // 例外:红包 CLAIM/REFUND、质押 UNSTAKE/SLASH、铸币 REDEEM、身份 IDRELEASE 都是 amount=0(由共识从托管池支付/移交,不在本交易里转币)。 const zeroOk = typeof t.memo === 'string' && (t.memo.startsWith(CLAIM_PREFIX) || t.memo.startsWith(REFUND_PREFIX) || t.memo.startsWith(UNSTAKE_PREFIX) || t.memo.startsWith(SLASH_PREFIX) || - t.memo.startsWith(REDEEM_PREFIX)); + t.memo.startsWith(REDEEM_PREFIX) || + t.memo.startsWith(IDRELEASE_PREFIX)); if (t.amount === 0 && burn === 0 && !zeroOk) return false; // 手续费同样必须是整数且在安全范围内(同样的浮点累积误差会撕裂共识)。此处只判范围,最低值按类型在下方判。 if (!Number.isInteger(t.fee) || t.fee < 0 || t.fee > Number.MAX_SAFE_INTEGER) return false; From 092665e236e74d3c9353a54f4451cc1252d4e4b0 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:15:46 -0700 Subject: [PATCH 02/27] =?UTF-8?q?feat(node,cli,web):=20=E8=B4=A8=E6=8A=BC?= =?UTF-8?q?=E5=BC=8F=E5=8C=BF=E5=90=8D=E8=BA=AB=E4=BB=BD=E4=B8=8B=E6=B8=B8?= =?UTF-8?q?=E6=8E=A5=E7=BA=BF=E2=80=94=E2=80=94API/CLI/Web=20=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node.ts: claimIdentity/releaseIdentity/myIdentityClaims/resolveIdentity,照抄 stake/unstake 形状。 api.ts: GET /identity(自己的质押,免token) / GET /identity/resolve(公开查询) / POST /identity/claim /POST /identity/release(Bearer 门控 + 激活高度前置校验)。 cli: 新增 identity 命令组(claim/release/list/resolve)。 web: 新增 IdentityPanel(轮询 + 认领表单 + 解锁按钮,锁定期内禁用),接入现有 poll loop。 消息防刷底线是纯共识层收紧,不需要新的下游端点。 Co-Authored-By: Claude Fable 5 --- packages/cli/src/index.ts | 38 ++++++++++++++ packages/node/src/api.ts | 27 +++++++++- packages/node/src/node.ts | 32 ++++++++++++ packages/web/src/App.tsx | 101 +++++++++++++++++++++++++++++++++++++- packages/web/src/api.ts | 11 +++++ 5 files changed, 206 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fde6a27..59c0b4f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -49,6 +49,7 @@ import { MINT_DEPOSIT_PREFIX, MINT_ADDRESS, MINT_ACTIVATION_HEIGHT, + IDENTITY_STAKE_MIN, type RelayDescriptor, } from '@v0idchain/core'; @@ -546,6 +547,43 @@ txCmd(red.command('refund')) if (o.wait) await waitConfirm(o, r.txid); }); +// ---- identity 质押身份(纯资金锁仓反女巫,无罚没、无仲裁者)---- +const identity = program.command('identity').description('质押身份:纯资金锁仓反女巫,押金须持续持有才算拥有身份'); +txCmd(identity.command('claim')) + .argument('', '想认领的假名(1~20 位 小写字母/数字/_/-)') + .description(`认领一个身份(转给托管地址 ${IDENTITY_STAKE_MIN} 押金 + memo;挖进区块后生效)`) + .action(async (pseudonym, o) => { + const r = await api(o, 'POST', '/identity/claim', { pseudonym }); + console.log(c.green('🪪 已提交认领'), c.dim('txid='), r.txid, c.dim(`(押金 ${IDENTITY_STAKE_MIN};打包确认后生效)`)); + if (o.wait) await waitConfirm(o, r.txid); + }); +txCmd(identity.command('release')) + .argument('', '认领交易 txid(IDCLAIM 交易 txid)') + .description('解锁:过锁定期后取回全部押金(无罚没);解锁后该假名立刻可被别人重新认领') + .action(async (claimTxid, o) => { + const r = await api(o, 'POST', '/identity/release', { claimTxid }); + console.log(c.green('🔓 已提交解锁'), c.dim('txid='), r.txid); + if (o.wait) await waitConfirm(o, r.txid); + }); +apiOpt(identity.command('list')) + .description('看本节点自己的身份质押') + .action(async (o) => { + const list = (await api(o, 'GET', '/identity')) as any[]; + if (!list.length) return console.log(c.dim('(暂无身份质押)')); + for (const claim of list) { + const tag = claim.released ? c.dim('[已解锁]') : c.green('[活跃]'); + console.log(`${tag} ${c.cyan('#' + claim.pseudonym)} 押金 ${claim.amount} ${SYMBOL} 锁至 #${claim.lockedUntil}`); + console.log(` ${c.dim('id ' + claim.id.slice(0, 12) + '…')}`); + } + }); +apiOpt(identity.command('resolve')) + .argument('', '假名') + .description('查某假名当前属于哪个地址') + .action(async (pseudonym, o) => { + const r = await api(o, 'GET', `/identity/resolve?pseudonym=${encodeURIComponent(pseudonym)}`); + console.log(r.owner ? `${c.cyan('#' + pseudonym)} → ${c.green(r.owner)}` : c.dim(`#${pseudonym} 当前无人持有`)); + }); + // ============================================================================ // 中继激励链下工具(Phase 3A-2/3/4):度量者 measure / 奖励 reward-epoch / 罚没 slash-epoch // —— 全部链下:measure 探测出 attestation;reward-epoch 默认只预览(有限国库池,--send 才真发); diff --git a/packages/node/src/api.ts b/packages/node/src/api.ts index 39e949f..39956c4 100644 --- a/packages/node/src/api.ts +++ b/packages/node/src/api.ts @@ -1,7 +1,7 @@ // 本地 HTTP 控制接口:CLI 子命令(send/balance/mine…)通过它和运行中的节点对话。 // 用 node:http,零额外依赖。只监听 127.0.0.1。 import { createServer, IncomingMessage, ServerResponse } from 'node:http'; -import { STAKING_ACTIVATION_HEIGHT, isValidAddress, minFeeFor, computeMintState } from '@v0idchain/core'; +import { STAKING_ACTIVATION_HEIGHT, IDENTITY_ACTIVATION_HEIGHT, isValidAddress, minFeeFor, computeMintState } from '@v0idchain/core'; import type { V0idNode } from './node.js'; import type { RoleManager } from './relay/rolemanager.js'; @@ -147,6 +147,16 @@ export function startHttpApi(node: V0idNode, port: number, token: string, roles? case '/stake': // 本节点自己的质押池(只读、无需令牌):含锁定高度 / 已罚没 / 是否已赎回。GUI 中继板块据此展示。 return json(200, node.stakes()); + case '/identity': + // 本节点自己的身份质押(只读、无需令牌):含锁定高度 / 是否已解锁。无罚没字段(本机制不罚没)。 + return json(200, node.myIdentityClaims()); + case '/identity/resolve': { + // 公开查询:假名 → 当前持有者地址(只读、无需令牌,任何人可核)。 + const pseudonym = url.searchParams.get('pseudonym') || ''; + if (!pseudonym) return json(400, { error: '缺少 pseudonym 参数' }); + const owner = node.resolveIdentity(pseudonym); + return json(200, { pseudonym, owner: owner ?? null }); + } case '/rewards': // 本节点收到的中继激励发放(只读)。引导期暂不发放 → 多半为空数组(见 INCENTIVE-PROTOCOL)。 return json(200, node.rewards()); @@ -231,6 +241,21 @@ export function startHttpApi(node: V0idNode, port: number, token: string, roles? const r = node.unstake(String(body.stakeId ?? '')); return r.ok ? json(200, { txid: r.tx!.txid }) : json(400, { error: r.error }); } + case '/identity/claim': { + // 认领:转给身份托管地址 + memo IDCLAIM|,锁定 IDENTITY_STAKE_MIN。 + // 激活高度前 `IDCLAIM|` 转托管仍会被旧/未激活共识当作普通转账, + // 所以 API 必须先挡住,避免 Bearer 客户端把押金转进托管却不生成质押记录。 + if (node.bc.height < IDENTITY_ACTIVATION_HEIGHT) { + return json(400, { error: `身份质押尚未激活(当前高度 ${node.bc.height},激活高度 ${IDENTITY_ACTIVATION_HEIGHT})` }); + } + const r = node.claimIdentity(String(body.pseudonym ?? '')); + return r.ok ? json(200, { txid: r.tx!.txid }) : json(400, { error: r.error }); + } + case '/identity/release': { + // 解锁:amount=0 + memo IDRELEASE|,过锁定期后取回全部本金(无罚没)。 + const r = node.releaseIdentity(String(body.claimTxid ?? '')); + return r.ok ? json(200, { txid: r.tx!.txid }) : json(400, { error: r.error }); + } case '/mine': { const n = Number(body.blocks ?? 1); if (!Number.isInteger(n) || n < 1) return json(400, { error: 'blocks 必须是正整数' }); diff --git a/packages/node/src/node.ts b/packages/node/src/node.ts index 8f33284..07c3632 100644 --- a/packages/node/src/node.ts +++ b/packages/node/src/node.ts @@ -44,6 +44,12 @@ import { computeStakeMin, STAKE_ESCROW_ADDRESS, UNSTAKE_PREFIX, + makeIdentityClaim, + computeIdentityState, + resolveIdentityOwner, + IDENTITY_ESCROW_ADDRESS, + IDENTITY_STAKE_MIN, + IDRELEASE_PREFIX, writeWalletFile, blockHeaders, recentBlockWindow, @@ -258,6 +264,32 @@ export class V0idNode { .map(([id, p]) => ({ id, ...p })); } + // ---- 质押身份(Phase 3B:纯资金锁仓反女巫,无罚没、无仲裁者)---- + /** 认领:转给身份托管地址 + memo `IDCLAIM|`,锁定 IDENTITY_STAKE_MIN 押金。 */ + claimIdentity(pseudonym: string): { ok: boolean; tx?: Transaction; error?: string } { + const r = makeIdentityClaim(pseudonym); + if (!r.ok) return { ok: false, error: r.error }; + return this.submit(this.wallet, IDENTITY_ESCROW_ADDRESS, IDENTITY_STAKE_MIN, r.memo!, minFeeFor(IDENTITY_STAKE_MIN)); + } + + /** 解锁:发 IDRELEASE 交易(amount=0),过锁定期后取回全部本金(无罚没)。claimTxid = IDCLAIM 交易 txid。 */ + releaseIdentity(claimTxid: string): { ok: boolean; tx?: Transaction; error?: string } { + return this.submit(this.wallet, this.wallet.address, 0, `${IDRELEASE_PREFIX}${claimTxid}`, MIN_FEE); + } + + /** 本节点地址名下的身份质押列表(只读,从链上身份状态过滤出 staker=本地址)。 */ + myIdentityClaims() { + const me = this.wallet.address; + return [...computeIdentityState(this.bc.chain).claims.entries()] + .filter(([, c]) => c.staker === me) + .map(([id, c]) => ({ id, ...c })); + } + + /** 公开查询:假名 → 当前持有者地址(无活跃质押则 undefined)。 */ + resolveIdentity(pseudonym: string): string | undefined { + return resolveIdentityOwner(computeIdentityState(this.bc.chain), pseudonym); + } + /** * 本节点收到的中继激励发放(只读):扫链上 `to==本地址` 且 memo 以 `REWARD|` 开头的交易。 * 当前引导期不发放奖励(见 INCENTIVE-PROTOCOL)→ 正常返回空数组;接入按 epoch 结算后自然填充。 diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 6d1c54c..537a6ec 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -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 { 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, type IdentityClaim } from './api'; import { loadCachedChain, putBlocks, clearCache } from './chainCache'; const short = (a: string) => (a && a.length > 14 ? `${a.slice(0, 8)}…${a.slice(-6)}` : a || ''); @@ -54,6 +54,7 @@ export default function App() { const [newcomers, setNewcomers] = useState([]); const [names, setNames] = useState({ nameToOwner: {}, addressToName: {} }); const [redPackets, setRedPackets] = useState([]); + const [identityClaims, setIdentityClaims] = useState([]); const [up, setUp] = useState(false); const [open, setOpen] = useState(null); const apiRef = useRef(api); @@ -121,7 +122,7 @@ export default function App() { const poll = useCallback(async () => { const base = apiRef.current; try { - const [i, m, mk, msg, nc, nm, rps] = await Promise.all([ + const [i, m, mk, msg, nc, nm, rps, ids] = await Promise.all([ getJSON(base, '/info'), getJSON(base, '/mempool'), getJSON(base, '/market'), @@ -129,6 +130,7 @@ export default function App() { getJSON(base, '/newcomers'), getJSON(base, '/names'), getJSON(base, '/redpackets'), + getJSON(base, '/identity'), ]); await syncChain(base); setInfo(i); @@ -139,6 +141,7 @@ export default function App() { NAMES = nm.addressToName || {}; // 刷新模块级显示名缓存(disp 读它) setNames(nm); setRedPackets(rps); + setIdentityClaims(ids); setUp(true); } catch { setUp(false); @@ -211,6 +214,8 @@ export default function App() { + +
@@ -705,6 +710,98 @@ function RedPackets({ packets, chain, api, token, onDone }: { packets: RedPacket ); } +function IdentityPanel({ + claims, + height, + chain, + api, + token, + onDone, +}: { + claims: IdentityClaim[]; + height: number; + chain: Block[]; + api: string; + token: string; + onDone: () => void; +}) { + const [pseudonym, setPseudonym] = useState(''); + const [busy, setBusy] = useState(false); + const [banner, setBanner] = useState(null); + const [pending, track] = usePending(chain); + + const act = async (path: string, body: unknown, okMsg: string): Promise => { + setBusy(true); + setBanner(null); + try { + const r = await postJSON<{ txid: string }>(api, path, body, token); + setBanner({ kind: 'ok', text: okMsg }); + track(r.txid); + onDone(); + return true; + } catch (e) { + setBanner({ kind: 'err', text: e instanceof Error ? e.message : String(e) }); + return false; + } finally { + setBusy(false); + } + }; + const claim = async () => { + if (await act('/identity/claim', { pseudonym }, '🪪 已提交认领(打包确认后生效)')) setPseudonym(''); + }; + + const active = claims.filter((c) => !c.released); + const released = claims.filter((c) => c.released); + + return ( +
+

质押身份 🪪 · 纯资金锁仓反女巫,无罚没

+
+
+ + setPseudonym(e.target.value)} placeholder="alice" spellCheck={false} /> +
+
+
+ +
+ {banner &&
{banner.text}
} + + +
+ {active.length === 0 &&
还没有活跃的身份质押
} + {active.map((c) => { + const unlockable = height >= c.lockedUntil; + return ( +
+
+ #{c.pseudonym} + 押金 {c.amount} $V0ID +
+
锁至 #{c.lockedUntil}(当前 #{height})
+
{c.id.slice(0, 12)}…
+ +
+ ); + })} +
+ {released.length > 0 && ( +
+ {released.slice(0, 8).map((c) => ( + + 🔓 已解锁 #{c.pseudonym}({c.amount} $V0ID) + + ))} +
+ )} +
+ ); +} + function Marketplace({ market, chain, api, token, onDone }: { market: Listing[]; chain: Block[]; api: string; token: string; onDone: () => void }) { const [title, setTitle] = useState(''); const [price, setPrice] = useState(''); diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index a559b1e..9eea6e4 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -58,6 +58,17 @@ export interface RedPacket { grabbedByMe: boolean; } +/** 本节点自己的一份身份质押(纯资金锁仓反女巫,无罚没;GET /identity 只返回本节点地址名下的) */ +export interface IdentityClaim { + id: string; + staker: string; + pseudonym: string; + amount: number; + lockedUntil: number; + createdHeight: number; + released: boolean; +} + /** 新成员事件:本次会话首见的对等节点 / 首次上链的地址 */ export interface Newcomer { kind: 'peer' | 'address'; From b637cc37cc5fd4f861ab06dbca1a7f213ecdc323 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:16:08 -0700 Subject: [PATCH 03/27] =?UTF-8?q?test:=20=E4=B8=A4=E4=B8=AA=E6=96=B0=20sel?= =?UTF-8?q?ftest=E2=80=94=E2=80=94message-burn-floor-selftest.ts=20/=20ide?= =?UTF-8?q?ntity-selftest.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 均照抄 staking-selftest.ts 的分层结构(配置向量→纯解析→forge-chain 廉价锻造到激活高度→ 状态机场景→computeState≡validateChain≡replaceChain 分叉安全→供应量守恒)。 message-burn-floor: 公式边界/单调/整数、isRealMessage 分类(含 IDCLAIM/IDRELEASE 交叉wiring 验证)、协议层低烧币操作不受影响、mempool 与选包两条路径独立一致拒绝(绕过 addTransaction 直塞 mempool 验证 selectMempoolTxs 也拦得住)。 identity: 配置向量(锁定期显著长于中继质押/托管地址不撞)、纯解析(复用 names.ts 字符集)、 状态机(押金不足/重复认领/锁定期内解锁/非质押人解锁/全额退回/二次解锁均被拒)、 **核心设计决定验证:解锁后假名立刻可被别人重新认领**。 本地全过;typecheck 6 包全绿;smoke/staking/onion/mint/antireplay 均无回归。 Co-Authored-By: Claude Fable 5 --- scripts/identity-selftest.ts | 218 +++++++++++++++++++++++++ scripts/message-burn-floor-selftest.ts | 203 +++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 scripts/identity-selftest.ts create mode 100644 scripts/message-burn-floor-selftest.ts diff --git a/scripts/identity-selftest.ts b/scripts/identity-selftest.ts new file mode 100644 index 0000000..f97b870 --- /dev/null +++ b/scripts/identity-selftest.ts @@ -0,0 +1,218 @@ +// 质押身份自检(Phase 3B:纯资金锁仓反女巫,无罚没、无仲裁者)。跑:corepack pnpm exec tsx scripts/identity-selftest.ts +// 覆盖:IDCLAIM 开质押/扣款、IDRELEASE 锁定期+全额退还+防重复、**解锁后假名可被别人重新认领**(核心设计决定)、 +// 分叉安全(computeState ≡ validateChain ≡ replaceChain)、激活/配置向量、纯解析器(复用 names.ts 字符集)。 +import { + Blockchain, + Wallet, + createTransaction, + parseIdentityClaim, + parseIdentityRelease, + makeIdentityClaim, + computeIdentityState, + resolveIdentityOwner, + IDCLAIM_PREFIX, + IDRELEASE_PREFIX, + IDENTITY_ESCROW_ADDRESS, + IDENTITY_STAKE_MIN, + IDENTITY_LOCK_BLOCKS, + IDENTITY_ACTIVATION_HEIGHT, + STAKE_LOCK_BLOCKS, + STAKE_MIN, + RED_ESCROW_ADDRESS, + STAKE_ESCROW_ADDRESS, + MINT_ESCROW_ADDRESS, + RESERVED_NAMES, + GENESIS_PREMINE, + BLOCK_REWARD, + MIN_FEE, + minFeeFor, + type Block, +} from '../packages/core/src/index.js'; +import { forgeTo } from './forge-chain.js'; + +let failed = 0; +function check(label: string, cond: boolean) { + console.log(`${cond ? '✅' : '❌'} ${label}`); + if (!cond) failed++; +} + +const supply = (bc: Blockchain) => [...bc.computeState().balances.values()].reduce((s, v) => s + v, 0); +const conserved = (bc: Blockchain) => supply(bc) === GENESIS_PREMINE + bc.height * BLOCK_REWARD; + +const fakeId = 'a'.repeat(64); +const funder = Wallet.generate(); +let baseSnapshot: Block[] = []; + +function activatedClone(): Blockchain { + return Blockchain.fromJSON({ chain: JSON.parse(JSON.stringify(baseSnapshot)), mempool: [] }); +} + +async function fund(bc: Blockchain, to: string, amount: number): Promise { + const tx = createTransaction(funder, to, amount, bc.nonceOf(funder.address), '', minFeeFor(amount)); + if (!bc.addTransaction(tx).ok) throw new Error('fund: 转账未进 mempool(funder 余额不足?)'); + await bc.mine(funder.address); +} + +async function main() { + console.log(`\n— 配置向量:无罚没、无仲裁者,锁定期显著长于中继质押,托管地址不撞 —`); + check('身份质押激活高度 > 当前 checkpoint 历史', IDENTITY_ACTIVATION_HEIGHT > 300); + check('托管地址 = 0x…4(与红包…1/质押…2/铸币…3 区分)', IDENTITY_ESCROW_ADDRESS === '0x' + '0'.repeat(63) + '4'); + check( + '身份托管地址与其他三个托管地址互不相同', + new Set([RED_ESCROW_ADDRESS, STAKE_ESCROW_ADDRESS, MINT_ESCROW_ADDRESS, IDENTITY_ESCROW_ADDRESS]).size === 4, + ); + check( + '身份锁定期显著长于中继质押锁定期(无罚没,锁仓时长本身是全部代价)', + IDENTITY_LOCK_BLOCKS > STAKE_LOCK_BLOCKS, + ); + check( + '身份最低押金介于中继 middle 与 guard 之间', + IDENTITY_STAKE_MIN > STAKE_MIN.middle && IDENTITY_STAKE_MIN < STAKE_MIN.guard, + ); + + console.log(`\n— 纯解析器:parseIdentityClaim / parseIdentityRelease / makeIdentityClaim(复用 names.ts 规则)—`); + check('IDCLAIM|alice → {pseudonym:alice}', parseIdentityClaim(`${IDCLAIM_PREFIX}alice`)?.pseudonym === 'alice'); + check('IDCLAIM|Alice 归一化为小写', parseIdentityClaim(`${IDCLAIM_PREFIX}Alice`)?.pseudonym === 'alice'); + check('IDCLAIM|0xabc 非法(避免与地址混淆)→ null', parseIdentityClaim(`${IDCLAIM_PREFIX}0xabc`) === null); + check('IDCLAIM|(空假名)→ null', parseIdentityClaim(IDCLAIM_PREFIX) === null); + check('IDCLAIM|太长(21位) → null', parseIdentityClaim(`${IDCLAIM_PREFIX}${'a'.repeat(21)}`) === null); + check('IDRELEASE|<64hex> → id', parseIdentityRelease(`${IDRELEASE_PREFIX}${fakeId}`) === fakeId); + check('IDRELEASE|短id → null', parseIdentityRelease(`${IDRELEASE_PREFIX}abc`) === null); + check('makeIdentityClaim(alice) 返回 ok + memo', makeIdentityClaim('alice').ok === true && typeof makeIdentityClaim('alice').memo === 'string'); + const reservedSample = [...RESERVED_NAMES][0]; + check(`makeIdentityClaim(保留名 "${reservedSample}") 被拒`, makeIdentityClaim(reservedSample).ok === false); + + console.log(`\n— 激活门控:激活前不把历史 memo/托管地址转账解释成身份操作 —`); + { + const pre = new Blockchain(); + const user = Wallet.generate(); + for (let i = 0; i < 6; i++) await pre.mine(user.address); + const ordinaryToEscrow = createTransaction( + user, IDENTITY_ESCROW_ADDRESS, 1, pre.nonceOf(user.address), 'historical transfer', MIN_FEE, + ); + check('激活前普通转账到身份托管地址可按普通历史交易处理', pre.addTransaction(ordinaryToEscrow).ok); + await pre.mine(user.address); + check('激活前不会创建身份质押', pre.computeState().identityClaims.size === 0); + check( + '激活前 IDRELEASE 零额新边界被拒', + !pre.addTransaction(createTransaction(user, user.address, 0, pre.nonceOf(user.address), `${IDRELEASE_PREFIX}${fakeId}`, MIN_FEE)).ok, + ); + check('激活前链整链校验通过', Blockchain.validateChain(pre.chain).ok); + } + + console.log(`\n— 把基底链便宜地 forge 到激活高度 ${IDENTITY_ACTIVATION_HEIGHT}(非真 PoW)…耐心几秒 —`); + { + const base = new Blockchain(); + await forgeTo(base, funder.address, IDENTITY_ACTIVATION_HEIGHT); + baseSnapshot = base.chain; + check(`基底链已达激活高度(height=${base.height} ≥ ${IDENTITY_ACTIVATION_HEIGHT})`, base.height >= IDENTITY_ACTIVATION_HEIGHT); + check('基底链整链 validateChain 通过', Blockchain.validateChain(base.chain).ok); + check('基底链全链守恒', conserved(base)); + } + + console.log(`\n— IDCLAIM 校验:押金不足被拒、押金足够被接受、托管入账、状态正确记录 —`); + const bc = activatedClone(); + const alice = Wallet.generate(); + await fund(bc, alice.address, IDENTITY_STAKE_MIN + 20); + check( + '押金 < IDENTITY_STAKE_MIN 被拒', + !bc.addTransaction(createTransaction(alice, IDENTITY_ESCROW_ADDRESS, IDENTITY_STAKE_MIN - 1, bc.nonceOf(alice.address), `${IDCLAIM_PREFIX}alice`, MIN_FEE)).ok, + ); + const aliceBefore = bc.balanceOf(alice.address); + const claimTx = createTransaction(alice, IDENTITY_ESCROW_ADDRESS, IDENTITY_STAKE_MIN, bc.nonceOf(alice.address), `${IDCLAIM_PREFIX}alice`, MIN_FEE); + check('IDCLAIM 进池(转给身份托管地址)', bc.addTransaction(claimTx).ok); + await bc.mine(alice.address); + const claimId = claimTx.txid; + const claimHeight = bc.height; + check( + '质押人被扣 押金+手续费(手续费作为矿工又赚回出块奖励+费)', + bc.balanceOf(alice.address) === aliceBefore - IDENTITY_STAKE_MIN - MIN_FEE + (BLOCK_REWARD + MIN_FEE), + ); + check('押金锁进身份托管地址', bc.balanceOf(IDENTITY_ESCROW_ADDRESS) === IDENTITY_STAKE_MIN); + const idState1 = computeIdentityState(bc.chain); + const claim1 = idState1.claims.get(claimId); + check('质押记录正确:staker/pseudonym/amount', claim1?.staker === alice.address && claim1?.pseudonym === 'alice' && claim1?.amount === IDENTITY_STAKE_MIN); + check('lockedUntil = 认领高度 + IDENTITY_LOCK_BLOCKS', claim1?.lockedUntil === claimHeight + IDENTITY_LOCK_BLOCKS); + check('resolveIdentityOwner("alice") 指向质押人', resolveIdentityOwner(idState1, 'alice') === alice.address); + check('IDCLAIM 后全链守恒', conserved(bc)); + + console.log(`\n— 同假名重复认领被拒(认领中不可二次占用)—`); + const eve = Wallet.generate(); + await fund(bc, eve.address, IDENTITY_STAKE_MIN + 20); + check( + '不同地址认领同一活跃假名 "alice" 被拒', + !bc.addTransaction(createTransaction(eve, IDENTITY_ESCROW_ADDRESS, IDENTITY_STAKE_MIN, bc.nonceOf(eve.address), `${IDCLAIM_PREFIX}alice`, MIN_FEE)).ok, + ); + + console.log(`\n— IDRELEASE:锁定期内被拒、非质押人被拒、过锁定期后全额退回、二次解锁被拒 —`); + check( + '锁定期内解锁被拒', + !bc.addTransaction(createTransaction(alice, alice.address, 0, bc.nonceOf(alice.address), `${IDRELEASE_PREFIX}${claimId}`, MIN_FEE)).ok, + ); + check( + '非质押人(eve)解锁被拒(即便挂着 alice 的 claimId)', + !bc.addTransaction(createTransaction(eve, eve.address, 0, bc.nonceOf(eve.address), `${IDRELEASE_PREFIX}${claimId}`, MIN_FEE)).ok, + ); + while (bc.height < claimHeight + IDENTITY_LOCK_BLOCKS) await bc.mine(alice.address); + const aliceBeforeRelease = bc.balanceOf(alice.address); + const releaseTx = createTransaction(alice, alice.address, 0, bc.nonceOf(alice.address), `${IDRELEASE_PREFIX}${claimId}`, MIN_FEE); + check('过锁定期后解锁进池', bc.addTransaction(releaseTx).ok); + await bc.mine(alice.address); + check( + '全额退回本金(无罚没扣减)减解锁手续费(手续费又作矿工赚回)', + bc.balanceOf(alice.address) === aliceBeforeRelease - MIN_FEE + IDENTITY_STAKE_MIN + (BLOCK_REWARD + MIN_FEE), + ); + check('身份托管清零(本金已退出)', bc.balanceOf(IDENTITY_ESCROW_ADDRESS) === 0); + const idState2 = computeIdentityState(bc.chain); + check('质押记录标记 released', idState2.claims.get(claimId)?.released === true); + check( + '二次解锁被拒(已 released)', + !bc.addTransaction(createTransaction(alice, alice.address, 0, bc.nonceOf(alice.address), `${IDRELEASE_PREFIX}${claimId}`, MIN_FEE)).ok, + ); + check('IDRELEASE 后全链守恒(无烧毁无罚没,纯锁仓解仓)', conserved(bc)); + check('含 IDCLAIM+IDRELEASE 的链整链校验通过', Blockchain.validateChain(bc.chain).ok); + + console.log(`\n— 核心设计决定验证:解锁后假名立刻可被别人重新认领(押金须持续持有才算拥有身份)—`); + check('解锁后 resolveIdentityOwner("alice") 无主', resolveIdentityOwner(computeIdentityState(bc.chain), 'alice') === undefined); + const eveClaimTx = createTransaction(eve, IDENTITY_ESCROW_ADDRESS, IDENTITY_STAKE_MIN, bc.nonceOf(eve.address), `${IDCLAIM_PREFIX}alice`, MIN_FEE); + check('eve 现在可以成功认领同一假名 "alice"(release 释放了它)', bc.addTransaction(eveClaimTx).ok); + await bc.mine(eve.address); + const idState3 = computeIdentityState(bc.chain); + check('resolveIdentityOwner("alice") 现在指向 eve', resolveIdentityOwner(idState3, 'alice') === eve.address); + check('重新认领后全链守恒', conserved(bc)); + check('含重新认领的链整链校验通过', Blockchain.validateChain(bc.chain).ok); + + console.log(`\n— 分叉安全:computeState ≡ validateChain ≡ replaceChain(余额与身份状态逐项一致)—`); + { + const vc = Blockchain.validateChain(bc.chain); + check('Blockchain.validateChain(bc.chain).ok === true', vc.ok === true); + const replica = Blockchain.fromJSON({ chain: JSON.parse(JSON.stringify(bc.chain)), mempool: [] }); + const balA = bc.computeState().balances; + const balB = replica.computeState().balances; + let balancesIdentical = balA.size === balB.size; + for (const [k, v] of balA) if (balB.get(k) !== v) balancesIdentical = false; + check('computeState 余额表在重放副本上逐项一致(无分叉)', balancesIdentical); + const idA = bc.computeState().identityClaims; + const idB = replica.computeState().identityClaims; + let identityIdentical = idA.size === idB.size; + for (const [k, v] of idA) { + const w = idB.get(k); + if (!w || w.staker !== v.staker || w.pseudonym !== v.pseudonym || w.amount !== v.amount || w.released !== v.released) { + identityIdentical = false; + } + } + check('identityClaims 状态在重放副本上逐项一致(无分叉)', identityIdentical); + const fresh = new Blockchain(); + const rep = fresh.replaceChain(JSON.parse(JSON.stringify(bc.chain))); + check('含身份质押的整链经 replaceChain 被全新节点接受', rep.replaced === true); + check('被接受后 eve 余额与源链一致', fresh.balanceOf(eve.address) === bc.balanceOf(eve.address)); + } + + console.log(failed === 0 ? `\n🎉 全部通过 ALL PASS\n` : `\n💥 ${failed} 项失败 ${failed} FAILED\n`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error('崩溃:', e); + process.exit(1); +}); diff --git a/scripts/message-burn-floor-selftest.ts b/scripts/message-burn-floor-selftest.ts new file mode 100644 index 0000000..d3d61b9 --- /dev/null +++ b/scripts/message-burn-floor-selftest.ts @@ -0,0 +1,203 @@ +// 消息防刷底线自检(Phase:消息销毁额随备注长度递增)。跑:corepack pnpm exec tsx scripts/message-burn-floor-selftest.ts +// 覆盖:minMessageBurnFor 公式(边界/单调/整数)、isRealMessage 分类(协议层 memo 绝不受约束,含身份 IDCLAIM/IDRELEASE +// 交叉wiring)、激活高度门控(forge-chain 便宜锻造)、mempool 与选包两条路径一致拒绝、协议层低销毁操作不受影响、 +// 分叉安全(computeState ≡ validateChain ≡ replaceChain)、供应量守恒。 +import { + Blockchain, + Wallet, + createTransaction, + createMessage, + minMessageBurnFor, + isRealMessage, + MESSAGE_BURN, + MESSAGE_BURN_PER_CHAR_UNIT, + MIN_MESSAGE_BURN_ACTIVATION_HEIGHT, + MAX_MEMO, + FISH_PREFIX, + PET_PREFIX, + IDCLAIM_PREFIX, + IDRELEASE_PREFIX, + STAKE_PREFIX, + RED_PREFIX, + GENESIS_PREMINE, + BLOCK_REWARD, + MIN_FEE, + minFeeFor, + type Block, +} from '../packages/core/src/index.js'; +import { forgeTo } from './forge-chain.js'; + +let failed = 0; +function check(label: string, cond: boolean) { + console.log(`${cond ? '✅' : '❌'} ${label}`); + if (!cond) failed++; +} + +const supply = (bc: Blockchain) => [...bc.computeState().balances.values()].reduce((s, v) => s + v, 0); +const conserved = (bc: Blockchain) => supply(bc) === GENESIS_PREMINE + bc.height * BLOCK_REWARD; + +const fakeId = 'a'.repeat(64); +const funder = Wallet.generate(); +let baseSnapshot: Block[] = []; + +function activatedClone(): Blockchain { + return Blockchain.fromJSON({ chain: JSON.parse(JSON.stringify(baseSnapshot)), mempool: [] }); +} + +async function fund(bc: Blockchain, to: string, amount: number): Promise { + const tx = createTransaction(funder, to, amount, bc.nonceOf(funder.address), '', minFeeFor(amount)); + if (!bc.addTransaction(tx).ok) throw new Error('fund: 转账未进 mempool(funder 余额不足?)'); + await bc.mine(funder.address); +} + +async function main() { + console.log(`\n— 配置向量:激活高度晚于历史 checkpoint,斜率为正 —`); + check('消息防刷激活高度 > 当前 checkpoint 历史', MIN_MESSAGE_BURN_ACTIVATION_HEIGHT > 300); + check('斜率为正', MESSAGE_BURN_PER_CHAR_UNIT > 0); + check('minMessageBurnFor(0) === MESSAGE_BURN(空备注门槛不变)', minMessageBurnFor(0) === MESSAGE_BURN); + + console.log(`\n— 纯公式测试:minMessageBurnFor 边界值/单调不减/全整数 —`); + check('1 字符门槛仍是 MESSAGE_BURN(不误伤日常寒暄)', minMessageBurnFor(1) === MESSAGE_BURN); + check( + `恰好一个斜率单位(${MESSAGE_BURN_PER_CHAR_UNIT} 码点)门槛 +1`, + minMessageBurnFor(MESSAGE_BURN_PER_CHAR_UNIT) === MESSAGE_BURN + 1, + ); + check( + `MAX_MEMO(${MAX_MEMO}) 长消息门槛 = MESSAGE_BURN + floor(MAX_MEMO/斜率)`, + minMessageBurnFor(MAX_MEMO) === MESSAGE_BURN + Math.floor(MAX_MEMO / MESSAGE_BURN_PER_CHAR_UNIT), + ); + { + const lengths = [0, 1, 19, 20, 21, 199, 200, 511, 512]; + let monotonic = true; + let allInt = true; + for (let i = 0; i < lengths.length; i++) { + const v = minMessageBurnFor(lengths[i]); + if (!Number.isInteger(v)) allInt = false; + if (i > 0 && v < minMessageBurnFor(lengths[i - 1])) monotonic = false; + } + check('minMessageBurnFor 在抽样长度上单调不减', monotonic); + check('minMessageBurnFor 在抽样长度上全整数输出(禁浮点跨节点分叉)', allInt); + } + + console.log(`\n— isRealMessage 分类:协议层 memo(含身份 IDCLAIM/IDRELEASE)绝不算真消息,即便 burn 很低 —`); + check('PET| 前缀不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${PET_PREFIX}egg1` })); + check('FISH| 前缀不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${FISH_PREFIX}1` })); + check('STAKE| 前缀(形态测试)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${STAKE_PREFIX}guard` })); + check('RED| 前缀(形态测试)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${RED_PREFIX}10|r` })); + check( + 'IDCLAIM| 前缀不算真消息(Feature A×B 交叉wiring)', + !isRealMessage({ amount: 0, burn: 1, memo: `${IDCLAIM_PREFIX}alice` }), + ); + check( + 'IDRELEASE| 前缀不算真消息(Feature A×B 交叉wiring)', + !isRealMessage({ amount: 0, burn: 1, memo: `${IDRELEASE_PREFIX}${fakeId}` }), + ); + check('普通正文才算真消息', isRealMessage({ amount: 0, burn: 5, memo: 'hello there' })); + + console.log(`\n— 激活门控:激活前旧规则放行低销毁长消息,不 retroactive 拒绝已广播交易 —`); + { + const pre = new Blockchain(); + const user = Wallet.generate(); + for (let i = 0; i < 6; i++) await pre.mine(user.address); + const longMemo = 'x'.repeat(200); + const lowBurnLongMsg = createMessage(user, user.address, longMemo, pre.nonceOf(user.address), 1, MIN_FEE); + check('激活前:200 字长消息 burn=1 仍被接受(旧规则只要求 burn>0)', pre.addTransaction(lowBurnLongMsg).ok); + await pre.mine(user.address); + check('激活前链整链校验通过', Blockchain.validateChain(pre.chain).ok); + } + + console.log(`\n— 把基底链便宜地 forge 到激活高度 ${MIN_MESSAGE_BURN_ACTIVATION_HEIGHT}(非真 PoW)…耐心几秒 —`); + { + const base = new Blockchain(); + await forgeTo(base, funder.address, MIN_MESSAGE_BURN_ACTIVATION_HEIGHT); + baseSnapshot = base.chain; + check( + `基底链已达激活高度(height=${base.height} ≥ ${MIN_MESSAGE_BURN_ACTIVATION_HEIGHT})`, + base.height >= MIN_MESSAGE_BURN_ACTIVATION_HEIGHT, + ); + check('基底链整链 validateChain 通过', Blockchain.validateChain(base.chain).ok); + check('基底链全链守恒', conserved(base)); + } + + console.log(`\n— 场景:短消息门槛不变、长消息旧默认值被拒、长消息按新公式烧够则放行 —`); + const bc = activatedClone(); + const alice = Wallet.generate(); + await fund(bc, alice.address, 1000); + + const shortMemo = 'hi'; + check( + '短消息 burn=MESSAGE_BURN 被接受(门槛未涨)', + bc.addTransaction(createMessage(alice, alice.address, shortMemo, bc.nonceOf(alice.address), MESSAGE_BURN, MIN_FEE)).ok, + ); + await bc.mine(alice.address); // 立即挖矿,保持 nonceOf 与实际已确认状态同步(不同 tx 间不留悬空 mempool) + check( + '短消息 burn=MESSAGE_BURN-1 被拒(低于门槛)', + !bc.addTransaction(createMessage(alice, alice.address, shortMemo, bc.nonceOf(alice.address), MESSAGE_BURN - 1, MIN_FEE)).ok, + ); + const longMemo = 'y'.repeat(200); + const longRequired = minMessageBurnFor(200); + check(`200 字长消息门槛应 > 旧默认值(${longRequired} > ${MESSAGE_BURN})`, longRequired > MESSAGE_BURN); + const rejLong = bc.addTransaction(createMessage(alice, alice.address, longMemo, bc.nonceOf(alice.address), MESSAGE_BURN, MIN_FEE)); + check('激活后:200 字长消息仍用旧默认值 burn=MESSAGE_BURN 被拒(核心场景:以前合法、现在不合法)', !rejLong.ok); + check('拒绝原因点明消息销毁额过低', String(rejLong.error).includes('消息销毁额过低')); + check( + '同一条长消息按新公式 minMessageBurnFor(200) 烧够则被接受', + bc.addTransaction(createMessage(alice, alice.address, longMemo, bc.nonceOf(alice.address), longRequired, MIN_FEE)).ok, + ); + await bc.mine(alice.address); + check('消息场景后全链守恒', conserved(bc)); + check('含消息的链整链校验通过', Blockchain.validateChain(bc.chain).ok); + + console.log(`\n— 协议层低销毁操作不受消息防刷影响(isProtocolMemo 排除生效)—`); + const bob = Wallet.generate(); + await fund(bc, bob.address, 100); + // 形态同「链上消息」(amount=0+burn>0)但 memo 走协议前缀,且 burn=2 远低于 MESSAGE_BURN=5: + // 若 isRealMessage 排除失效,这笔会被 Feature A 的门槛误杀。 + const fishTx = createMessage(bob, bob.address, `${FISH_PREFIX}1`, bc.nonceOf(bob.address), 2, MIN_FEE); + check('FISH| 协议层操作(burn=2,远低于消息门槛)激活后依然被接受', bc.addTransaction(fishTx).ok); + await bc.mine(bob.address); + check('协议层操作场景后全链守恒', conserved(bc)); + + console.log(`\n— mempool 与选包两条路径一致拒绝:绕过 addTransaction 直塞进 mempool 也不会被打包 —`); + { + const sc = activatedClone(); + const carol = Wallet.generate(); + await fund(sc, carol.address, 100); + const badMemo = 'z'.repeat(200); + const badTx = createMessage(carol, carol.address, badMemo, sc.nonceOf(carol.address), MESSAGE_BURN, MIN_FEE); + check('该低销毁长消息确实会被 addTransaction 拒绝(前置条件)', !sc.addTransaction(badTx).ok); + // 绕过 addTransaction 直接塞进公开的 mempool 数组,模拟“不知怎的溜进池子”的边缘情况。 + (sc as unknown as { mempool: unknown[] }).mempool.push(badTx); + const before = sc.height; + const mined = await sc.mine(carol.address); + check('mine() 成功出块(selectMempoolTxs 应跳过坏交易而非卡死)', mined !== null); + check('高度确实前进了一块', sc.height === before + 1); + const included = mined!.transactions.some((t) => t.txid === badTx.txid); + check('selectMempoolTxs 独立拒绝了该低销毁长消息(未被打进块)', !included); + check('该块整链校验通过', Blockchain.validateChain(sc.chain).ok); + } + + console.log(`\n— 分叉安全:computeState ≡ validateChain ≡ replaceChain(余额逐项一致)—`); + { + const vc = Blockchain.validateChain(bc.chain); + check('Blockchain.validateChain(bc.chain).ok === true', vc.ok === true); + const replica = Blockchain.fromJSON({ chain: JSON.parse(JSON.stringify(bc.chain)), mempool: [] }); + const balA = bc.computeState().balances; + const balB = replica.computeState().balances; + let balancesIdentical = balA.size === balB.size; + for (const [k, v] of balA) if (balB.get(k) !== v) balancesIdentical = false; + check('computeState 余额表在重放副本上逐项一致(无分叉)', balancesIdentical); + const fresh = new Blockchain(); + const rep = fresh.replaceChain(JSON.parse(JSON.stringify(bc.chain))); + check('含消息防刷交易的整链经 replaceChain 被全新节点接受', rep.replaced === true); + check('被接受后 alice 余额与源链一致', fresh.balanceOf(alice.address) === bc.balanceOf(alice.address)); + } + + console.log(failed === 0 ? `\n🎉 全部通过 ALL PASS\n` : `\n💥 ${failed} 项失败 ${failed} FAILED\n`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((e) => { + console.error('崩溃:', e); + process.exit(1); +}); From 9665dec1f9660c7c94d63ec99036489c726cd0a4 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:16:28 -0700 Subject: [PATCH 04/27] =?UTF-8?q?fix(smoke):=20=E5=8A=A0=E5=AF=86=E7=A7=81?= =?UTF-8?q?=E4=BF=A1=E5=9C=BA=E6=99=AF=E8=A1=A5=E8=B6=B3=E6=8C=96=E7=9F=BF?= =?UTF-8?q?=E6=9C=AC=E9=87=91=20+=20=E6=A3=80=E6=9F=A5=20addTransaction=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=80=BC;ci:=20=E7=BA=B3=E5=85=A5=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E6=96=B0=20selftest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smoke.ts 加密私信场景原挖 8 块(旧扁平 MESSAGE_BURN=5 够用),改成随长度递增的默认烧币值后 (136 码点密文默认烧 11+手续费1=12)本金不足,交易静默进不了 mempool、下一行访问 parseMessages(...)[0] 直接崩溃——这是我这次改动合理暴露的测试夹具问题,非共识 bug。 修复:挖 16 块给足余量,并给 addTransaction 补返回值检查(用 check() 报出清晰失败而非崩溃)。 ci.yml 新增 message-burn-floor-selftest.ts / identity-selftest.ts 两步(各锻造到 40000/45000 高度,本地测得均在 CI 15 分钟预算内)。 Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 6 ++++++ scripts/smoke.ts | 11 +++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3970638..4d36019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,12 @@ jobs: - name: Relay anti-replay test run: pnpm exec tsx scripts/antireplay-test.ts + - name: Message burn floor selftest + run: pnpm exec tsx scripts/message-burn-floor-selftest.ts + + - name: Identity staking selftest + run: pnpm exec tsx scripts/identity-selftest.ts + docker: name: docker image builds runs-on: ubuntu-latest diff --git a/scripts/smoke.ts b/scripts/smoke.ts index 60e8349..34a85e7 100644 --- a/scripts/smoke.ts +++ b/scripts/smoke.ts @@ -363,15 +363,18 @@ check('密文被篡改 → 认证失败返回 null', decryptMemo(enc.slice(0, -2 check('512 码点 memo 合法(MAX_MEMO 已抬到 512)', verifyTransaction(createTransaction(bob, alice.address, 1, 0, 'x'.repeat(512)))); check('513 码点 memo 仍被拒', !verifyTransaction(createTransaction(bob, alice.address, 1, 0, 'x'.repeat(513)))); // 加密私信整条上链 + 解析 + 解密(密文 >128 码点,顺带验证 512 上限放行) +// 挖 16 块给 alice 攒本金:密文默认烧币额随 memo 长度递增(minMessageBurnFor),比旧的扁平 MESSAGE_BURN 更贵, +// 8 块(旧值够用)不足以覆盖新默认值 + 手续费,需要更宽的余量。 const encBc = new Blockchain(); -for (let i = 0; i < 8; i++) await encBc.mine(alice.address); +for (let i = 0; i < 16; i++) await encBc.mine(alice.address); const encMsg = createMessage(alice, bob.address, encryptMemo('链上加密第一条 🔐', bob.address, alice.privateKey), encBc.nonceOf(alice.address)); check('加密消息交易自洽(签名/txid/memo长度)', verifyTransaction(encMsg)); -encBc.addTransaction(encMsg); +const encAdd = encBc.addTransaction(encMsg); +check('加密消息进池(余额足够覆盖随长度递增的默认销毁额+手续费)', encAdd.ok); await encBc.mine(alice.address); const pm = parseMessages(encBc.chain)[0]; -check('加密私信上链且密文不可读', isEncryptedMemo(pm.text) && !pm.text.includes('加密第一条')); -check('收件人从链上密文解出明文', decryptMemo(pm.text, pm.from, bob.privateKey) === '链上加密第一条 🔐'); +check('加密私信上链且密文不可读', !!pm && isEncryptedMemo(pm.text) && !pm.text.includes('加密第一条')); +check('收件人从链上密文解出明文', !!pm && decryptMemo(pm.text, pm.from, bob.privateKey) === '链上加密第一条 🔐'); console.log(`\n— 链上昵称:全网唯一抢注(先到先得)+ 改名 + 自转约束 —`); const nm = new Blockchain(); From fc6e292e2433b25d92d196e001eecba86d9dea86 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:51:30 -0700 Subject: [PATCH 05/27] =?UTF-8?q?fix(core):=20isProtocolMemo=20=E6=94=B6?= =?UTF-8?q?=E7=B4=A7=E4=B8=BA=E7=B2=BE=E7=A1=AE=E6=A0=A1=E9=AA=8C=EF=BC=8C?= =?UTF-8?q?=E5=A0=B5=E4=BD=8F=E5=A5=97=E5=A3=B3=E7=BB=95=E8=BF=87=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=98=B2=E5=88=B7=E5=BA=95=E7=BA=BF=E7=9A=84=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex review P2 发现:isProtocolMemo 只用 startsWith 判断前缀,而 PET/FISH/LAND/ZONE/PLANT/ HARVEST/CROPX/MINE 这些游戏协议前缀的合法性完全由各自 parseXxx(chain) 在应用层重放判定, 共识层(blockchain.ts)从不校验——所以在消息防刷底线上线前,"前缀对、payload是垃圾"这种套壳 交易本就语义无效,但也无害(反正整体也不受约束)。上线后就不一样了:套壳交易能靠命中前缀逃过 新的消息烧币下限,用 burn=1 塞任意长度的垃圾内容,比老老实实发消息还便宜——这是本次改动新开的口子。 修复:isProtocolMemo 按两类前缀区别处理: - STAKE/UNSTAKE/SLASH/RED/CLAIM/REFUND/IDCLAIM/IDRELEASE:共识层已原生校验合法性,不合法形态 根本进不了链,startsWith 不变。 - PET/FISH/LAND/ZONE/PLANT/HARVEST/CROPX/MINE:逐一核对 payload 格式 + 该操作要求的精确/达标 烧币额(复用各模块导出的现成常量/函数:PET_BREED_COST/PET_EVO_COST/PETFARM_COST/ZONE_COST/ SEED_COST/HARVEST_BURN/CROPS/ZONE_TYPES/parseMineMemo/mineDiscoveryBurn/mineMaterialBurn, 不在此重复定义一套规则,避免与游戏模块演进脱节)。孵化 PET|、铸渔获 FISH| 精确匹配(无 payload 可塞);预留未启用的 CROPX| 同样精确匹配(保守收紧);地价随链上状态浮动、mining 只有下限, payload 收紧为纯数字/复用现成成本函数即可,无需重放全链状态。 isProtocolMemo 签名从 (memo: string) 改为 (tx: {memo, burn}) 以支持烧币值校验,同步改 feed.ts 调用点。message-burn-floor-selftest.ts 修正此前依据旧宽松行为写的测试用例(FISH|1 类不精确 payload 本就不是真实协议操作),并新增套壳攻击必须受消息门槛约束的核心回归测试。 Co-Authored-By: Claude Fable 5 --- packages/core/src/feed.ts | 2 +- packages/core/src/messages.ts | 129 +++++++++++++++++++------ scripts/message-burn-floor-selftest.ts | 52 +++++++--- 3 files changed, 140 insertions(+), 43 deletions(-) diff --git a/packages/core/src/feed.ts b/packages/core/src/feed.ts index e5c3af6..93762b0 100644 --- a/packages/core/src/feed.ts +++ b/packages/core/src/feed.ts @@ -143,7 +143,7 @@ export function deriveFeed(chain: Block[], limit = 80): FeedEvent[] { } // 私信:amount=0 + burn>0 + 非协议 memo(与 parseMessages 一致)。 - if (isMessageTx(tx) && !isProtocolMemo(m)) { + if (isMessageTx(tx) && !isProtocolMemo(tx)) { out.push({ type: 'message', ...base, text: m }); continue; } diff --git a/packages/core/src/messages.ts b/packages/core/src/messages.ts index e7a39a9..bd5c641 100644 --- a/packages/core/src/messages.ts +++ b/packages/core/src/messages.ts @@ -13,10 +13,32 @@ import { IDCLAIM_PREFIX, IDRELEASE_PREFIX, } from './config.js'; -import { PET_PREFIX, PETX_PREFIX, PETBREED_PREFIX, PETEVO_PREFIX, PETFARM_PREFIX, PETUNSTATION_PREFIX } from './pets.js'; +import { + PET_PREFIX, + PETX_PREFIX, + PETBREED_PREFIX, + PETEVO_PREFIX, + PETFARM_PREFIX, + PETUNSTATION_PREFIX, + PET_BREED_COST, + PET_EVO_COST, + PETFARM_COST, +} from './pets.js'; import { FISH_PREFIX } from './fishing.js'; -import { LAND_PREFIX, ZONE_PREFIX, PLANT_PREFIX, HARVEST_PREFIX, CROPX_PREFIX } from './farm.js'; -import { MINE_PREFIX } from './mining.js'; +import { + LAND_PREFIX, + ZONE_PREFIX, + PLANT_PREFIX, + HARVEST_PREFIX, + CROPX_PREFIX, + ZONE_TYPES, + CROPS, + SEED_COST, + ZONE_COST, + HARVEST_BURN, + type Crop, +} from './farm.js'; +import { MINE_PREFIX, parseMineMemo, mineDiscoveryBurn, mineMaterialBurn } from './mining.js'; export interface ChainMessage { txid: string; @@ -33,43 +55,96 @@ export function isMessageTx(tx: { amount: number; burn?: number }): boolean { return tx.amount === 0 && (tx.burn ?? 0) > 0; } +const HEX64 = /^[0-9a-f]{64}$/; + /** - * 某 memo 是否属于“协议层约定”(崽/红包/钓鱼/农场等建在 memo 上的子系统),而非真人私信正文。 + * 某笔交易是否属于“协议层约定”(崽/红包/钓鱼/农场/矿洞等建在 memo 上的子系统),而非真人私信正文。 * 这些子系统也会发出 `amount=0 + burn>0` 形态的交易(如孵崽 `PET|`、铸渔获 `FISH|`、买地 `LAND|`/种植 `PLANT|` 都是自转烧币), - * 与链上消息的形态撞型 → 不排除的话会被 parseMessages 误收进收件箱。集中在此判定,便于以后新增子系统时一处维护。 + * 与链上消息的形态撞型 → 不排除的话会被 parseMessages 误收进收件箱。消息防刷底线上线后,这个判定还多了一层 + * 经济含义:被排除的交易不受烧币下限约束,故此处按两类前缀区别对待: + * + * ① STAKE/UNSTAKE/SLASH/RED/CLAIM/REFUND/IDCLAIM/IDRELEASE:**共识层**(blockchain.ts redOpError)已原生 + * 校验合法性,篡改 burn/payload 的交易根本进不了链,`startsWith` 足够,不构成绕过消息门槛的风险。 + * ② PET/FISH/LAND/ZONE/PLANT/HARVEST/CROPX/MINE 系:共识层完全不管,合法性全靠各自 parseXxx(chain) 在 + * 展示层重放判定——纯 `startsWith` 会被“前缀相同、payload 塞垃圾”套壳绕过消息烧币下限(新增的经济边界), + * 故逐一核对 payload 格式 + 该操作要求的精确/达标烧币额,**复用各模块导出的现成常量/函数**而非在此重复 + * 定义一套规则,避免与游戏模块演进脱节。 * * ⚠️ 刻意**不含** `ENC|`(端到端加密私信,本就是私信正文,必须留在收件箱)与 `NAME|`(抢注是 burn=0 的自转, * 形态上压根不是消息,isMessageTx 已天然排除,无需也不该在此列)。 */ -export function isProtocolMemo(memo: string): boolean { - return ( - memo.startsWith(PET_PREFIX) || - memo.startsWith(PETX_PREFIX) || - memo.startsWith(PETBREED_PREFIX) || - memo.startsWith(PETEVO_PREFIX) || - memo.startsWith(PETFARM_PREFIX) || - memo.startsWith(PETUNSTATION_PREFIX) || - memo.startsWith(RED_PREFIX) || - memo.startsWith(CLAIM_PREFIX) || - memo.startsWith(REFUND_PREFIX) || +export function isProtocolMemo(tx: { memo: string; burn?: number }): boolean { + const { memo } = tx; + const burn = tx.burn ?? 0; + + // ① 共识层已原生校验,不合法形态进不了链,startsWith 足够 + if ( memo.startsWith(STAKE_PREFIX) || memo.startsWith(UNSTAKE_PREFIX) || memo.startsWith(SLASH_PREFIX) || + memo.startsWith(RED_PREFIX) || + memo.startsWith(CLAIM_PREFIX) || + memo.startsWith(REFUND_PREFIX) || memo.startsWith(IDCLAIM_PREFIX) || - memo.startsWith(IDRELEASE_PREFIX) || - memo.startsWith(FISH_PREFIX) || - memo.startsWith(LAND_PREFIX) || - memo.startsWith(ZONE_PREFIX) || - memo.startsWith(PLANT_PREFIX) || - memo.startsWith(HARVEST_PREFIX) || - memo.startsWith(CROPX_PREFIX) || - memo.startsWith(MINE_PREFIX) - ); + memo.startsWith(IDRELEASE_PREFIX) + ) { + return true; + } + + // ② 仅应用层校验,逐一核对 payload + 烧币额,防「前缀相同+塞垃圾」绕过消息门槛 + if (memo === PET_PREFIX) return true; // 孵化:memo 精确、burn 只要求 >0(无固定值),无 payload 可塞 + if (memo === FISH_PREFIX) return true; // 铸渔获:同上 + if (memo === CROPX_PREFIX) return true; // 预留前缀(尚未实现转移逻辑),无已知格式,精确匹配最保守 + + if (memo.startsWith(PETX_PREFIX)) return HEX64.test(memo.slice(PETX_PREFIX.length)); // 送崽:amount>0 不烧币,payload=petId + if (memo.startsWith(PETUNSTATION_PREFIX)) return HEX64.test(memo.slice(PETUNSTATION_PREFIX.length)); // 召回:免费,payload=petId + if (memo.startsWith(PETBREED_PREFIX)) { + const parts = memo.slice(PETBREED_PREFIX.length).split('|'); + return parts.length === 2 && parts.every((p) => HEX64.test(p)) && burn === PET_BREED_COST; + } + if (memo.startsWith(PETEVO_PREFIX)) { + return HEX64.test(memo.slice(PETEVO_PREFIX.length)) && burn === PET_EVO_COST; + } + if (memo.startsWith(PETFARM_PREFIX)) { + const parts = memo.slice(PETFARM_PREFIX.length).split('|'); + return parts.length === 2 && parts.every((p) => HEX64.test(p)) && burn === PETFARM_COST; + } + if (memo.startsWith(LAND_PREFIX)) { + // 地价随链上状态浮动(下限需重放 soldTotal/velocity 才能算出),此处无法精确核验金额; + // 但 payload 收紧为纯数字已杜绝夹带任意字符串,真实地价下限仍由 parseFarm/共识层的经济防线把关。 + return /^\d+$/.test(memo.slice(LAND_PREFIX.length)); + } + if (memo.startsWith(ZONE_PREFIX)) { + const rest = memo.slice(ZONE_PREFIX.length); + const sep = rest.indexOf('|'); + if (sep < 0) return false; + const plotN = rest.slice(0, sep); + const type = rest.slice(sep + 1); + return /^\d+$/.test(plotN) && (ZONE_TYPES as readonly string[]).includes(type) && burn === ZONE_COST; + } + if (memo.startsWith(PLANT_PREFIX)) { + const parts = memo.slice(PLANT_PREFIX.length).split('|'); + if (parts.length !== 3) return false; + const [zoneId, crop, slot] = parts; + if (!HEX64.test(zoneId) || !(CROPS as readonly string[]).includes(crop) || !/^\d+$/.test(slot)) return false; + return burn === SEED_COST[crop as Crop]; + } + if (memo.startsWith(HARVEST_PREFIX)) { + return HEX64.test(memo.slice(HARVEST_PREFIX.length)) && burn === HARVEST_BURN; + } + if (memo.startsWith(MINE_PREFIX)) { + const m = parseMineMemo(memo); + if (!m) return false; + const needed = m.type === 'discovery' ? mineDiscoveryBurn(m.depth, m.kind) : mineMaterialBurn(m.kind, m.count); + return burn >= needed; + } + + return false; } /** 是否“真实消息”(应受消息防刷底线约束,见 config.ts minMessageBurnFor):amount=0+burn>0 且非协议层 memo。 */ export function isRealMessage(tx: { amount: number; burn?: number; memo: string }): boolean { - return isMessageTx(tx) && !isProtocolMemo(tx.memo); + return isMessageTx(tx) && !isProtocolMemo(tx); } /** 扫整条链,把所有消息交易还原成消息列表(最新在前)。协议层 memo(PET/RED/FISH…)不算私信,跳过。 */ @@ -78,7 +153,7 @@ export function parseMessages(chain: Block[]): ChainMessage[] { for (const b of chain) { for (const tx of b.transactions) { if (!isMessageTx(tx)) continue; - if (isProtocolMemo(tx.memo)) continue; + if (isProtocolMemo(tx)) continue; out.push({ txid: tx.txid, from: tx.from, diff --git a/scripts/message-burn-floor-selftest.ts b/scripts/message-burn-floor-selftest.ts index d3d61b9..d6068be 100644 --- a/scripts/message-burn-floor-selftest.ts +++ b/scripts/message-burn-floor-selftest.ts @@ -1,7 +1,9 @@ // 消息防刷底线自检(Phase:消息销毁额随备注长度递增)。跑:corepack pnpm exec tsx scripts/message-burn-floor-selftest.ts -// 覆盖:minMessageBurnFor 公式(边界/单调/整数)、isRealMessage 分类(协议层 memo 绝不受约束,含身份 IDCLAIM/IDRELEASE -// 交叉wiring)、激活高度门控(forge-chain 便宜锻造)、mempool 与选包两条路径一致拒绝、协议层低销毁操作不受影响、 -// 分叉安全(computeState ≡ validateChain ≡ replaceChain)、供应量守恒。 +// 覆盖:minMessageBurnFor 公式(边界/单调/整数)、isRealMessage 分类(真正合法的协议层操作绝不受约束, +// 含身份 IDCLAIM/IDRELEASE 交叉wiring;**套壳攻击——前缀匹配但 payload/burn 不合法——必须受约束**, +// 这是 isProtocolMemo 从纯 startsWith 收紧为「payload 格式 + 精确/达标烧币值」双重校验的核心回归测试)、 +// 激活高度门控(forge-chain 便宜锻造)、mempool 与选包两条路径一致拒绝、分叉安全 +// (computeState ≡ validateChain ≡ replaceChain)、供应量守恒。 import { Blockchain, Wallet, @@ -15,6 +17,8 @@ import { MAX_MEMO, FISH_PREFIX, PET_PREFIX, + PETBREED_PREFIX, + PET_BREED_COST, IDCLAIM_PREFIX, IDRELEASE_PREFIX, STAKE_PREFIX, @@ -79,21 +83,39 @@ async function main() { check('minMessageBurnFor 在抽样长度上全整数输出(禁浮点跨节点分叉)', allInt); } - console.log(`\n— isRealMessage 分类:协议层 memo(含身份 IDCLAIM/IDRELEASE)绝不算真消息,即便 burn 很低 —`); - check('PET| 前缀不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${PET_PREFIX}egg1` })); - check('FISH| 前缀不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${FISH_PREFIX}1` })); - check('STAKE| 前缀(形态测试)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${STAKE_PREFIX}guard` })); - check('RED| 前缀(形态测试)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${RED_PREFIX}10|r` })); + console.log(`\n— isRealMessage 分类:真正合法的协议层操作不算真消息(精确 payload + 达标烧币值)—`); + check('PET| 精确孵化 memo(无后缀)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: PET_PREFIX })); + check('FISH| 精确铸渔获 memo(无后缀)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: FISH_PREFIX })); check( - 'IDCLAIM| 前缀不算真消息(Feature A×B 交叉wiring)', + 'PETBREED|<64hex>|<64hex> 且 burn=PET_BREED_COST 不算真消息', + !isRealMessage({ amount: 0, burn: PET_BREED_COST, memo: `${PETBREED_PREFIX}${fakeId}|${fakeId}` }), + ); + check('STAKE| 前缀(形态测试,共识层已保护)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${STAKE_PREFIX}guard` })); + check('RED| 前缀(形态测试,共识层已保护)不算真消息', !isRealMessage({ amount: 0, burn: 1, memo: `${RED_PREFIX}10|r` })); + check( + 'IDCLAIM| 前缀不算真消息(Feature A×B 交叉wiring,共识层已保护)', !isRealMessage({ amount: 0, burn: 1, memo: `${IDCLAIM_PREFIX}alice` }), ); check( - 'IDRELEASE| 前缀不算真消息(Feature A×B 交叉wiring)', + 'IDRELEASE| 前缀不算真消息(Feature A×B 交叉wiring,共识层已保护)', !isRealMessage({ amount: 0, burn: 1, memo: `${IDRELEASE_PREFIX}${fakeId}` }), ); check('普通正文才算真消息', isRealMessage({ amount: 0, burn: 5, memo: 'hello there' })); + console.log(`\n— 核心回归:套壳攻击(前缀匹配但 payload 是垃圾或 burn 不达标)必须算真消息、受烧币下限约束 —`); + check( + 'FISH| 后面接垃圾内容(非精确匹配)算真消息(旧漏洞:曾被 startsWith 误放行)', + isRealMessage({ amount: 0, burn: 1, memo: `${FISH_PREFIX}${'x'.repeat(300)}` }), + ); + check( + 'PET| 后面接垃圾内容(非精确匹配)算真消息', + isRealMessage({ amount: 0, burn: 1, memo: `${PET_PREFIX}${'x'.repeat(300)}` }), + ); + check( + 'PETBREED| 格式合法但 burn 不等于 PET_BREED_COST 时算真消息(套壳想蹭排除但没付真实协议成本)', + isRealMessage({ amount: 0, burn: 1, memo: `${PETBREED_PREFIX}${fakeId}|${fakeId}` }), + ); + console.log(`\n— 激活门控:激活前旧规则放行低销毁长消息,不 retroactive 拒绝已广播交易 —`); { const pre = new Blockchain(); @@ -148,13 +170,13 @@ async function main() { check('消息场景后全链守恒', conserved(bc)); check('含消息的链整链校验通过', Blockchain.validateChain(bc.chain).ok); - console.log(`\n— 协议层低销毁操作不受消息防刷影响(isProtocolMemo 排除生效)—`); + console.log(`\n— 协议层低销毁操作不受消息防刷影响(真正合法的 memo,isProtocolMemo 精确匹配放行)—`); const bob = Wallet.generate(); await fund(bc, bob.address, 100); - // 形态同「链上消息」(amount=0+burn>0)但 memo 走协议前缀,且 burn=2 远低于 MESSAGE_BURN=5: - // 若 isRealMessage 排除失效,这笔会被 Feature A 的门槛误杀。 - const fishTx = createMessage(bob, bob.address, `${FISH_PREFIX}1`, bc.nonceOf(bob.address), 2, MIN_FEE); - check('FISH| 协议层操作(burn=2,远低于消息门槛)激活后依然被接受', bc.addTransaction(fishTx).ok); + // 形态同「链上消息」(amount=0+burn>0)但 memo **精确等于** FISH_PREFIX(真实铸渔获的合法形态,无后缀), + // 且 burn=2 远低于 MESSAGE_BURN=5:若 isRealMessage 排除失效,这笔会被 Feature A 的门槛误杀。 + const fishTx = createMessage(bob, bob.address, FISH_PREFIX, bc.nonceOf(bob.address), 2, MIN_FEE); + check('FISH| 真实协议操作(精确 memo,burn=2,远低于消息门槛)激活后依然被接受', bc.addTransaction(fishTx).ok); await bc.mine(bob.address); check('协议层操作场景后全链守恒', conserved(bc)); From 4f019dc630b69da082c45f560fd88f0c1a4f3a1d Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:51:44 -0700 Subject: [PATCH 06/27] =?UTF-8?q?fix(core):=20=E8=A1=A5=20IDENTITY=5FESCRO?= =?UTF-8?q?W=5FADDRESS=20=E8=BF=9B=20SYSTEM=5FADDRESSES?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex review P2 发现:identity 功能新增了 IDENTITY_ESCROW_ADDRESS 托管地址,但漏加进 SYSTEM_ADDRESSES 集合——铸币兑现的收款校验、节点新地址发现等处都依赖这个集合区分"系统/协议 地址"与真实用户地址,遗漏会导致铸币兑现能打给身份托管地址、或身份托管地址被误报成"新地址上链"。 SYSTEM_ADDRESSES 的定义顺带挪到文件末尾(在所有托管地址常量声明完毕之后):IDENTITY_ESCROW_ADDRESS 的声明本身在文件后段,若不挪动会引用尚未初始化的 const(暂时性死区)。 Co-Authored-By: Claude Fable 5 --- packages/core/src/config.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index ae00688..32d5e0d 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -141,11 +141,6 @@ export const MINT_ESCROW_ADDRESS = '0x' + '0'.repeat(63) + '3'; */ export const STAKING_ACTIVATION_HEIGHT = 16_000; -/** - * 系统/协议地址集合(非真人账户):虚空/销毁地址 + 红包托管地址 + 质押托管地址 + 铸币厂托管地址。 - * 供 UI / 新人发现等处把它们与真实用户区分(如不把托管地址误报成“🆕 新地址首次上链”)。 - */ -export const SYSTEM_ADDRESSES: ReadonlySet = new Set([NULL_ADDRESS, RED_ESCROW_ADDRESS, STAKE_ESCROW_ADDRESS, MINT_ESCROW_ADDRESS]); /** 三种红包操作的 memo 前缀。RED 是“自转 amount=总额 + memo”;CLAIM/REFUND 是 amount=0 + memo。 */ export const RED_PREFIX = 'RED|'; // 发红包:RED|<份数>|(r=拼手气随机, e=均分) export const CLAIM_PREFIX = 'CLAIM|'; // 抢红包:CLAIM|<红包txid> @@ -331,3 +326,16 @@ export const IDENTITY_LOCK_BLOCKS = 240; * 且与 MIN_MESSAGE_BURN_ACTIVATION_HEIGHT(40000)/STAKING_ACTIVATION_HEIGHT(16000)/MINT_ACTIVATION_HEIGHT(30000) 互不相同。 */ export const IDENTITY_ACTIVATION_HEIGHT = 45_000; + +/** + * 系统/协议地址集合(非真人账户):虚空/销毁地址 + 红包托管地址 + 质押托管地址 + 铸币厂托管地址 + 身份托管地址。 + * 供 UI / 新人发现等处把它们与真实用户区分(如不把托管地址误报成“🆕 新地址首次上链”,或不让铸币兑现打给托管地址)。 + * 放在文件末尾(所有托管地址常量声明完毕之后)以避免引用尚未初始化的 const(暂时性死区)。 + */ +export const SYSTEM_ADDRESSES: ReadonlySet = new Set([ + NULL_ADDRESS, + RED_ESCROW_ADDRESS, + STAKE_ESCROW_ADDRESS, + MINT_ESCROW_ADDRESS, + IDENTITY_ESCROW_ADDRESS, +]); From 3ebcc5475841bd2cc94c9b6203943fb8b43fe5ab Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Thu, 2 Jul 2026 19:52:02 -0700 Subject: [PATCH 07/27] =?UTF-8?q?fix(node,cli,web):=20=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E7=83=A7=E5=B8=81=E5=80=BC=E7=9C=9F=E6=AD=A3?= =?UTF-8?q?=E6=8C=89=E9=95=BF=E5=BA=A6=E8=AE=A1=E7=AE=97=EF=BC=8C=E8=80=8C?= =?UTF-8?q?=E9=9D=9E=E5=9C=A8=E6=9B=B4=E6=97=A9=E7=9A=84=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E7=82=B9=E5=B0=B1=E8=A2=AB=E6=89=81=E5=B9=B3=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC=E9=94=81=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex review P2 发现:上一轮我把 transaction.ts 的 createMessage 默认 burn 参数从 MESSAGE_BURN(扁平)改成 minMessageBurnFor(长度),但这个默认值在实践中从未被触发—— V0idNode.message() 自己的参数默认值仍是 burn=MESSAGE_BURN(扁平),CLI 的 --burn 选项默认 串死 String(MESSAGE_BURN),web 消息表单的 burn 输入框初始值也是 defaultBurn(同样扁平)。 这三处都会在调用 createMessage 之前就把 burn 解析成一个具体数字,createMessage 自己的默认 参数永远轮不到触发。消息防刷底线激活后,用户只要不手动算更高的 burn,发 20+ 码点的消息就会 被 mempool 拒收——这正是当初改默认值想避免的情况,改了个寂寞。 修复: - node.ts message():burn 参数改为可选(undefined),在函数体内、算出最终上链 memo(含加密 后的密文,而非明文)之后,用 burn ?? minMessageBurnFor(最终body长度) 计算实际值。 - api.ts /message 端点本就支持 burn=undefined 透传(无需改)。 - CLI msg 命令:--burn 去掉固定默认值,未传时保持 undefined 一路透传到 node.message()。 - web 消息表单:burn 输入框留空表示"按长度自动",不再预填一个扁平初始值;发送时留空则不显式 传 burn(undefined),交给节点按最终正文长度计算。 三处改动后计算逻辑唯一权威仍在 node.ts message(),CLI/web 只负责"要不要覆盖默认值",不重复 实现公式。 Co-Authored-By: Claude Fable 5 --- packages/cli/src/index.ts | 8 +++++--- packages/node/src/node.ts | 8 ++++++-- packages/web/src/App.tsx | 17 ++++++++++------- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 59c0b4f..5a71313 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -350,15 +350,17 @@ txCmd(program.command('send')) txCmd(program.command('msg')) .argument('', '收件人地址') .argument('', '消息正文') - .option('--burn ', `烧进虚空的 $V0ID(永久销毁;默认 ${MESSAGE_BURN},越多越壕)`, String(MESSAGE_BURN)) + .option('--burn ', `烧进虚空的 $V0ID(永久销毁;不指定则按正文长度自动算,默认起步 ${MESSAGE_BURN}、越长烧越多,越多越壕)`) .option('--fee ', `手续费(gas,给打包矿工,至少 ${MIN_FEE})`, String(MIN_FEE)) .option('-e, --encrypt', '端到端加密(只有收件人能解;发件人也能解自己发的)', false) .description('给一个地址发链上消息(不转币,烧掉一点 $V0ID;-e 加密)') .action(async (to, text, o) => { - const burn = Number(o.burn); + // 不传 --burn 时保持 undefined(而非拼一个固定默认值):让节点按最终 memo 长度(加密则是密文长度)自动算, + // 消息防刷底线激活后固定默认值对长消息会不够烧、被拒收。 + const burn = o.burn === undefined ? undefined : Number(o.burn); const r = await api(o, 'POST', '/message', { to, text: text.join(' '), burn, fee: Number(o.fee), encrypt: !!o.encrypt }); const lock = o.encrypt ? c.cyan(' 🔒加密') : ''; - console.log(c.green('✉️ 消息已广播') + lock, c.dim('txid='), r.txid, c.dim(`🔥烧=${burn} 手续费=${Number(o.fee)}`)); + console.log(c.green('✉️ 消息已广播') + lock, c.dim('txid='), r.txid, c.dim(`🔥烧=${burn ?? '(按长度自动)'} 手续费=${Number(o.fee)}`)); console.log(c.dim('(打包确认后,对方 `v0id inbox` 即可看到)')); if (o.wait) await waitConfirm(o, r.txid); }); diff --git a/packages/node/src/node.ts b/packages/node/src/node.ts index 07c3632..e238c22 100644 --- a/packages/node/src/node.ts +++ b/packages/node/src/node.ts @@ -10,6 +10,7 @@ import { FEE_RATE_BPS, minFeeFor, MESSAGE_BURN, + minMessageBurnFor, MAX_MEMO, NULL_ADDRESS, SYSTEM_ADDRESSES, @@ -158,11 +159,13 @@ export class V0idNode { /** * 给某地址发一条链上消息:不转币、烧 burn 个 $V0ID 进虚空、付 fee 给矿工。算好 nonce、签名、进池、广播。 * encrypt=true → 用收件人公钥端到端加密正文(只有收发双方能解),密文以 `ENC|` 上链。 + * burn 省略(undefined)时按**最终上链 memo 长度**(加密后是密文长度,不是明文长度)算默认值—— + * 必须在这里算,不能用固定参数默认值:消息防刷底线激活后,固定默认值对稍长的消息会不够烧、被 mempool 拒收。 */ message( to: string, text: string, - burn = MESSAGE_BURN, + burn?: number, fee = MIN_FEE, encrypt = false, ): { ok: boolean; tx?: Transaction; error?: string } { @@ -173,9 +176,10 @@ export class V0idNode { return { ok: false, error: `加密后超长(${[...body].length}>${MAX_MEMO}),消息太长` }; } } + const actualBurn = burn ?? minMessageBurnFor([...body].length); const pending = this.bc.mempool.filter((t) => t.from === this.wallet.address).length; const nonce = this.bc.nonceOf(this.wallet.address) + pending; - const tx = createMessage(this.wallet, to, body, nonce, burn, fee); + const tx = createMessage(this.wallet, to, body, nonce, actualBurn, fee); const r = this.bc.addTransaction(tx); if (!r.ok) return { ok: false, error: r.error }; this.markSeen(tx.txid); diff --git a/packages/web/src/App.tsx b/packages/web/src/App.tsx index 537a6ec..9a029a7 100644 --- a/packages/web/src/App.tsx +++ b/packages/web/src/App.tsx @@ -510,7 +510,9 @@ function Messaging({ }) { const [to, setTo] = useState(''); const [text, setText] = useState(''); - const [burn, setBurn] = useState(String(defaultBurn)); + // 留空 = 让节点按最终 memo 长度(加密则是密文长度)自动算烧币额;不预填固定默认值—— + // 消息防刷底线激活后,固定默认值对长消息会不够烧、被拒收。placeholder 仍展示起步参考值。 + const [burn, setBurn] = useState(''); const [enc, setEnc] = useState(false); const [tab, setTab] = useState<'in' | 'out'>('in'); const [busy, setBusy] = useState(false); @@ -521,12 +523,13 @@ function Messaging({ setBusy(true); setBanner(null); try { - const r = await postJSON<{ txid: string }>(api, '/message', { to: to.trim(), text, burn: Number(burn), fee: minFee, encrypt: enc }, token); - setBanner({ kind: 'ok', text: `已广播${enc ? ' 🔒加密' : ''} · 烧 ${burn} 🔥 · txid ${r.txid.slice(0, 20)}…` }); + const burnValue = burn.trim() === '' ? undefined : Number(burn); + const r = await postJSON<{ txid: string }>(api, '/message', { to: to.trim(), text, burn: burnValue, fee: minFee, encrypt: enc }, token); + setBanner({ kind: 'ok', text: `已广播${enc ? ' 🔒加密' : ''} · 烧 ${burnValue ?? '(按长度自动)'} 🔥 · txid ${r.txid.slice(0, 20)}…` }); track(r.txid); setTo(''); setText(''); - setBurn(String(defaultBurn)); + setBurn(''); onDone(); } catch (e) { setBanner({ kind: 'err', text: e instanceof Error ? e.message : String(e) }); @@ -550,13 +553,13 @@ function Messaging({ setText(e.target.value)} placeholder="在链上给 TA 留句话…" maxLength={512} />
- - setBurn(e.target.value)} placeholder={String(defaultBurn)} inputMode="numeric" /> + + setBurn(e.target.value)} placeholder={`自动(起步 ${defaultBurn})`} inputMode="numeric" />