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/packages/cli/src/index.ts b/packages/cli/src/index.ts index fde6a27..75c1346 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'; @@ -337,11 +338,13 @@ txCmd(program.command('send')) .argument('', '金额') .option('--memo ', '附带一段备注(上链可查)', '') .option('--fee ', `手续费(gas;省略则自动算:max(${MIN_FEE}, 金额×0.1%))`) - .description('转账(需付 金额 + 手续费/gas)') + .option('--burn ', '为超长备注附带的销毁额(消息门槛激活后长备注付款需要;省略则按备注长度自动补足)') + .description('转账(需付 金额 + 手续费/gas;长备注还需按门槛销毁)') .action(async (to, amount, o) => { const amt = Number(amount); const fee = o.fee !== undefined ? Number(o.fee) : minFeeFor(amt); - const r = await api(o,'POST', '/send', { to, amount: amt, memo: o.memo, fee }); + const burn = o.burn !== undefined ? Number(o.burn) : undefined; // 省略 → 节点按备注长度自动补足 + const r = await api(o,'POST', '/send', { to, amount: amt, memo: o.memo, fee, burn }); console.log(c.green('✅ 交易已广播'), c.dim('txid='), r.txid, c.dim(`手续费=${fee}`)); if (o.wait) await waitConfirm(o, r.txid); }); @@ -349,15 +352,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); }); @@ -546,6 +551,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/core/src/blockchain.ts b/packages/core/src/blockchain.ts index e12c98d..a3e09dc 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,16 @@ function redOpError( if (!mintActive && m.startsWith(REDEEM_PREFIX) && tx.amount === 0) { return `铸币厂尚未激活(激活高度 ${MINT_ACTIVATION_HEIGHT})`; } + const identityActive = atHeight >= IDENTITY_ACTIVATION_HEIGHT; + // 只拦「amount=0 且 burn=0 的解锁新边界」——这正是真实 IDRELEASE 的形态(见下方 275 行强制 burn=0)。 + // 关键:必须同时要求 burn===0,否则会误伤「amount=0 + burn>0 的普通链上消息,正文恰好以 IDRELEASE| 开头」—— + // 那种消息在本 PR 之前旧节点是合法接受的(verifyTransaction 里 burn>0 即非空操作,照收),若这里无条件按 + // amount=0 拒绝,validateChain 重放历史块时就会把这条老消息判非法 → loadChain/replaceChain 拒绝整条链 + // (retroactive 分叉)。加 burn===0 后:真实解锁(amount=0+burn=0)仍被拦(与旧节点一致地拒空操作), + // 而 IDRELEASE| 开头的历史消息(burn>0)照常放行,两端节点行为一致、不分叉。 + if (!identityActive && m.startsWith(IDRELEASE_PREFIX) && tx.amount === 0 && (tx.burn ?? 0) === 0) { + return `身份质押尚未激活(激活高度 ${IDENTITY_ACTIVATION_HEIGHT})`; + } // ---- 铸币厂操作(DEPOSIT/REDEEM):与质押 STAKE/SLASH 同款合法性校验 ---- if (mintActive && m.startsWith(REDEEM_PREFIX)) { if ((tx.burn ?? 0) > 0) return '铸币兑现不能附带销毁'; @@ -251,6 +275,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 +330,12 @@ function redOpError( if (tx.amount < meta.count) return '红包总额须 ≥ 份数(每份至少 1)'; return null; } + // ---- 消息防刷底线:收紧校验,不引入新状态机,故放最后(真消息不命中前面任何托管/前缀分支)---- + if (atHeight >= MIN_MESSAGE_BURN_ACTIVATION_HEIGHT && isRealMessage({ ...tx, atHeight })) { + 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 +437,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 +584,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)); @@ -533,6 +642,14 @@ function applySelect(tx: Transaction, st: ChainState, atHeight: number, blockDif export class Blockchain { chain: Block[]; mempool: Transaction[] = []; + /** + * revalidateMempool 剔除交易时的回调(可选,供上层如 V0idNode 挂载):同步清理调用方自己的 + * P2P 去重缓存(如 seenTx),避免被剔除交易的 txid 仍被当作“已处理过”而静默吞掉外部客户端的 + * 重新提交。用回调而非返回值/共享字段:revalidateMempool 可能在挖矿(PoW 异步计算期间)与 + * P2P 收块两条路径交错触发,共享字段会被后触发的一次覆盖、丢失前一次的清理信息;回调在 + * revalidateMempool 内部同步调用,不依赖调用方“事后”读取的时机,没有这个竞态风险。 + */ + onMempoolDropped?: (txids: string[]) => void; constructor() { this.chain = [genesisBlock()]; @@ -554,7 +671,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 +706,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,10 +720,27 @@ 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(); - // 红包/质押/铸币操作合法性:池存在/未抢完/未重复领/已过期、质押锁定期/度量者权限、铸币储备等(按 height+1 估算) - const redErr = redOpError(tx, pools, stakes, mintReserve, this.height + 1, this.tipDifficulty()); + const r = this.admitAgainstState(tx, this.computeState()); + if (!r.ok) return r; + this.mempool.push(tx); + return { ok: true }; + } + + /** + * addTransaction 里“依赖链上状态”的那部分校验(红包/质押/铸币/身份合法性 + nonce 顺序 + 余额), + * 抽成独立方法只为了让 revalidateMempool 能**复用同一份 computeState() 快照**给 mempool 里的每一笔 + * 旧交易判断,而不是像原先那样每笔都各自重新 computeState() 一遍(见 revalidateMempool 的性能注释)。 + * `state` 由调用方传入(正常提交路径传新鲜的 this.computeState();批量重验路径传共享的那一份)。 + */ + private admitAgainstState(tx: Transaction, state: ChainState): { ok: boolean; error?: string } { + const { balances, nonces, pools, stakes, mintReserve, identityClaims, identityByPseudonym } = state; + const burn = tx.burn ?? 0; + // 红包/质押/铸币/身份操作合法性:池存在/未抢完/未重复领/已过期、质押锁定期/度量者权限、铸币储备、 + // 身份假名是否已被占用等(按 height+1 估算,与 selectMempoolTxs 的口径一致) + const redErr = redOpError(tx, pools, stakes, mintReserve, identityClaims, identityByPseudonym, this.height + 1, this.tipDifficulty()); if (redErr) return { ok: false, error: redErr }; + // pending 仍按“当前已在 this.mempool 里的同地址交易”统计——revalidateMempool 会在循环中逐步重建 + // this.mempool,这个统计口径天然随重建进度演进,语义与原先逐笔重新 addTransaction 完全一致。 const pending = this.mempool.filter((t) => t.from === tx.from); const expectedNonce = (nonces.get(tx.from) ?? 0) + pending.length; if (tx.nonce !== expectedNonce) { @@ -619,7 +754,6 @@ export class Blockchain { const extra = burn > 0 ? `手续费 ${tx.fee} + 销毁 ${burn}` : `手续费 ${tx.fee}`; return { ok: false, error: `余额不足:可用 ${available},需要 ${need}(含${extra})` }; } - this.mempool.push(tx); return { ok: true }; } @@ -646,7 +780,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) ) { @@ -684,7 +818,14 @@ export class Blockchain { } // ---- 上链 / 共识 ---- - /** 追加一个区块(必须是当前链顶的下一块,且整体合法) */ + /** + * 追加一个区块(必须是当前链顶的下一块,且整体合法)。 + * dropMined 之后再 revalidateMempool:不是所有“未被打进这块”的 mempool 交易都只是在排队——像 + * IDCLAIM 认领假名这类有“排他性”的操作,一旦被抢先打进链,队列里同名的另一笔就**永久**不再合法 + * (不像红包/质押那样,落选只是暂时的、下一块还有机会)。若不清掉,selectMempoolTxs 会一直跳过它、 + * 卡住该地址后续所有交易的 nonce 序列,直到手动清 mempool。revalidateMempool 本就在 replaceChain + * (reorg)用于同样目的,这里复用同一套机制覆盖“正常出块”路径。 + */ addBlock(block: Block): { ok: boolean; error?: string } { if (block.index !== this.height + 1) return { ok: false, error: '区块高度不连续' }; if (block.prevHash !== this.latest.hash) return { ok: false, error: 'prevHash 不匹配' }; @@ -692,6 +833,7 @@ export class Blockchain { if (!v.ok) return { ok: false, error: v.error }; this.chain.push(block); this.dropMined(block); + this.revalidateMempool(); return { ok: true }; } @@ -733,10 +875,27 @@ export class Blockchain { this.mempool = this.mempool.filter((t) => !mined.has(t.txid)); } + /** + * 用最新已确认状态重新过滤 mempool:剔除因新区块而不再合法的交易(如两笔互斥的 IDCLAIM 抢同一 + * 假名,一笔上链后另一笔永久失效,见 addBlock 的调用点注释)。 + * + * ⚠️ 性能要点:只调用**一次** computeState()(重放整条链)供本轮所有旧 mempool 交易共用,而非 + * 像之前那样对每笔交易各自调用一次 addTransaction(后者内部每次都重新 computeState)——mempool + * 上限 5000、每块只能打包 50 笔,若每次出块都对剩余的成千上万笔各重放一次全链,链一长就会让每次 + * 出块/收块都变得极慢(O(mempool × chainLength))。签名/格式自洽性不必重查:mempool 里已存的交易 + * 在最初 addTransaction 时已经过完整校验,这些字段不会因为新区块而改变;只有依赖链上状态的部分 + * (redOpError/nonce/余额,即 admitAgainstState)需要用新状态重新判一遍。 + */ private revalidateMempool(): void { const old = this.mempool; this.mempool = []; - for (const tx of old) this.addTransaction(tx); // 失效的会被自动丢弃 + const state = this.computeState(); // 只算一次,供本轮所有旧交易共用 + const dropped: string[] = []; + for (const tx of old) { + if (this.admitAgainstState(tx, state).ok) this.mempool.push(tx); + else dropped.push(tx.txid); + } + if (dropped.length > 0) this.onMempoolDropped?.(dropped); } // ---- 整链校验(共识的唯一权威)---- @@ -754,7 +913,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 +969,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/browser.ts b/packages/core/src/browser.ts index ea30f33..e30931b 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -18,6 +18,7 @@ export * from './messages.js'; export * from './names.js'; export * from './relays.js'; export * from './redpacket.js'; +export * from './identity.js'; export * from './pets.js'; export * from './fishing.js'; export * from './farm.js'; diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 0c9c3df..39cb744 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> @@ -272,3 +267,100 @@ 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); +} + +/** + * “给别人转账 + 备注”的免费备注长度额度(Unicode 码点)。这类交易(from≠to、有价值转移)产品上允许自由 + * 附言、不当消息收进收件箱,故默认不受消息销毁门槛约束——但若不设上限,攻击者可用「A→自己控制的第二个 + * 钱包 amount=1 + 超长 memo」把消息伪装成转账、只付 minFeeFor 就在链上塞满 MAX_MEMO(512) 文本,绕开整条 + * 消息防刷底线(链上无法证明两地址是否同一人,无法靠 from/to 区分真实付款与自我接力)。 + * + * 折中:给**任意带 memo 的交易**一个免费额度——≤ 该长度的备注完全免费(真实付款的一行短附言不受影响); + * 一旦 memo 超过它,无论 from/to/amount,都落入 isMemoSpamCandidate 候选、按 minMessageBurnFor(全长) 收 + * 销毁门槛。这样:真实付款的短备注零成本;超长文本不管怎么路由(自转/发消息/双钱包接力)都统一受约束; + * 接力攻击每笔最多只能免费夹带 64 码点(要塞满 512 需 8 笔交易+8 份手续费+8 次来回),灌水成本被抬高约 8×。 + * + * 取 64:足够一行真实附言(中英文一句话都够),又远小于 MAX_MEMO(512)、不足以当有效灌水载体。可按需调整 + * (改它即软分叉,须全网一致)。注意:协议层 memo(RELAY/PLANT 等本就可能 >64)仍由 isProtocolMemo 单独 + * 豁免,不受此额度影响。与消息门槛同挂 MIN_MESSAGE_BURN_ACTIVATION_HEIGHT,不引入新的共识激活边界。 + */ +export const MESSAGE_FREE_MEMO_CHARS = 64; + +/** + * 消息防刷底线共识激活高度。该高度前,消息销毁额仍只按旧规则校验(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; + +/** + * 房间布局发布协议前缀(game-web 房间装饰功能:自转 1 + `ROOM|<布局hash>`,burn 恒为 0,consensus + * 不校验,合法性由 game-server 事后比对布局字节的 hash 判定)。放在 core 而非 game-web/src/room.ts + * (原定义处),是因为 messages.ts 的 isProtocolMemo 豁免判定需要在 core 内引用它——core 不能反向依赖 + * game-web。room.ts 现从 `@v0idchain/core/browser` 导入这个常量,不再本地定义,避免两处漂移。 + */ +export const ROOM_PREFIX = 'ROOM|'; + +/** + * 系统/协议地址集合(非真人账户):虚空/销毁地址 + 红包托管地址 + 质押托管地址 + 铸币厂托管地址 + 身份托管地址。 + * 供 UI / 新人发现等处把它们与真实用户区分(如不把托管地址误报成“🆕 新地址首次上链”,或不让铸币兑现打给托管地址)。 + * 放在文件末尾(所有托管地址常量声明完毕之后)以避免引用尚未初始化的 const(暂时性死区)。 + */ +export const SYSTEM_ADDRESSES: ReadonlySet = new Set([ + NULL_ADDRESS, + RED_ESCROW_ADDRESS, + STAKE_ESCROW_ADDRESS, + MINT_ESCROW_ADDRESS, + IDENTITY_ESCROW_ADDRESS, +]); diff --git a/packages/core/src/feed.ts b/packages/core/src/feed.ts index e5c3af6..57d3375 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, atHeight: b.index })) { out.push({ type: 'message', ...base, text: m }); continue; } diff --git a/packages/core/src/identity.ts b/packages/core/src/identity.ts new file mode 100644 index 0000000..39ec28c --- /dev/null +++ b/packages/core/src/identity.ts @@ -0,0 +1,129 @@ +// 质押身份(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 { isCoinbase } from './transaction.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) { + // coinbase(矿工出块奖励)的 to/amount 矿工可自由设置、memo 也不受 verifyTransaction 约束 + // (coinbase 校验只看 fee===0/burn===0/amount>0,不查 memo)——恶意矿工可以绕过 createCoinbase + // 这个辅助函数,手写一笔 to=IDENTITY_ESCROW_ADDRESS、memo=IDCLAIM| 的 coinbase,把出块奖励 + // 凑够 IDENTITY_STAKE_MIN。真正的共识 computeState 在处理 coinbase 时直接 continue、从不进 + // applyTx,永远不会创建对应的 identityClaims 记录;这里的只读展示层若不排除 coinbase,会把这 + // 笔交易误记成一条合法认领,让 UI/API 显示该假名“已被占用”,即便链上共识状态里根本没有此记录 + // (真实用户之后仍能成功认领,只是中间会有一段误导性的展示)。 + if (isCoinbase(tx)) continue; + 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..22e4200 100644 --- a/packages/core/src/messages.ts +++ b/packages/core/src/messages.ts @@ -2,11 +2,62 @@ // 任何节点扫一遍链,就能把这些交易还原成消息列表 —— 消息随链全网同步、永久可查,零中心化服务器。 // 收件箱 = 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 { PET_PREFIX, PETX_PREFIX, PETBREED_PREFIX, PETEVO_PREFIX, PETFARM_PREFIX, PETUNSTATION_PREFIX } from './pets.js'; +import { + NULL_ADDRESS, + MESSAGE_FREE_MEMO_CHARS, + RED_PREFIX, + RED_ESCROW_ADDRESS, + CLAIM_PREFIX, + REFUND_PREFIX, + STAKE_PREFIX, + STAKE_ESCROW_ADDRESS, + STAKING_ACTIVATION_HEIGHT, + UNSTAKE_PREFIX, + SLASH_PREFIX, + IDCLAIM_PREFIX, + IDENTITY_ESCROW_ADDRESS, + IDENTITY_ACTIVATION_HEIGHT, + IDENTITY_STAKE_MIN, + IDRELEASE_PREFIX, + ROOM_PREFIX, +} from './config.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, + makeMineDiscovery, + makeMineMaterial, +} from './mining.js'; +import { NAME_PREFIX, isValidName } from './names.js'; +import { MKT_PREFIX, BUY_PREFIX, DEL_PREFIX, MAX_TITLE } from './market.js'; +import { RELAY_PREFIX, parseRelayMemo, buildRelayMemo } from './relays.js'; +import { parseRedCreate } from './redpacket.js'; export interface ChainMessage { txid: string; @@ -18,41 +69,272 @@ export interface ChainMessage { height: number; // 所在区块高度 } -/** 一笔交易是否“链上消息”:不转币(amount 0)但销毁了币(burn>0)。coinbase/创世天然不满足,自动排除。 */ +/** 一笔交易是否“链上消息”(用于收件箱展示):不转币(amount 0)但销毁了币(burn>0)。coinbase/创世天然不满足,自动排除。 */ export function isMessageTx(tx: { amount: number; burn?: number }): boolean { return tx.amount === 0 && (tx.burn ?? 0) > 0; } /** - * 某 memo 是否属于“协议层约定”(崽/红包/钓鱼/农场等建在 memo 上的子系统),而非真人私信正文。 - * 这些子系统也会发出 `amount=0 + burn>0` 形态的交易(如孵崽 `PET|`、铸渔获 `FISH|`、买地 `LAND|`/种植 `PLANT|` 都是自转烧币), - * 与链上消息的形态撞型 → 不排除的话会被 parseMessages 误收进收件箱。集中在此判定,便于以后新增子系统时一处维护。 + * 是否“该受消息防刷底线约束”的候选交易。三条**并列**规则(or,任一命中即候选): * - * ⚠️ 刻意**不含** `ENC|`(端到端加密私信,本就是私信正文,必须留在收件箱)与 `NAME|`(抢注是 burn=0 的自转, - * 形态上压根不是消息,isMessageTx 已天然排除,无需也不该在此列)。 + * (1) isMessageTx(amount=0 + burn>0):最常见的“张三发消息给李四”,本就是消息,全额受门槛。 + * (2) 自转(from===to)+ memo 非空(amount 可 >0):把消息伪装成“自己转给自己 N 个币 + 附言”—— + * 钱转回自己手里、无真实价值转移、唯一目的是塞内容,经济实质就是一条消息,必须受门槛。 + * (3) memo 长度 > MESSAGE_FREE_MEMO_CHARS:**任意**带超长备注的交易,无论 from/to/amount。这条封堵 + * “双钱包接力”——A→自己控制的第二个钱包 amount=1 + 超长 memo,因既非 amount=0 消息、又非自转, + * 漏过 (1)(2) 只付 minFeeFor(amount) 就塞满 512 码点。链上无法证明两地址是否同属一人,无法靠 + * from/to 区分真实付款与自我接力,故改用**长度**判:≤ 额度的备注(真实付款的一行短附言)完全免费; + * 超额则不管怎么路由都受门槛。接力攻击每笔最多免费夹带 MESSAGE_FREE_MEMO_CHARS 码点,灌水成本抬高。 + * + * ⚠️ (1) 必须保留、不能被后两条替代:否则最常见的“发消息给别人”(from!==to、可能 <额度)会漏出候选。 + * + * 真实的“转账给别人 + **短**备注”(from!==to、amount>0、memo ≤ 额度)仍完全不受约束——有价值转移的合法 + * 场景,产品上允许自由(短)附言。只有当备注长到 > 额度、更像灌水载体而非附言时,才一并受门槛。 + * + * 协议层 memo(RELAY/PLANT 等本就可能 >额度)由 isProtocolMemo 在 isRealMessage 里单独豁免,不受 (3) 误伤。 + * 用于展示(收件箱)的语义仍由 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) || - memo.startsWith(STAKE_PREFIX) || +export function isMemoSpamCandidate(tx: { amount: number; burn?: number; from: string; to: string; memo: string }): boolean { + if (isMessageTx(tx)) return true; // (1) + if (tx.from === tx.to && tx.memo.length > 0) return true; // (2) + return [...tx.memo].length > MESSAGE_FREE_MEMO_CHARS; // (3) 按 Unicode 码点计,同 MAX_MEMO/minMessageBurnFor 口径 +} + +const HEX64 = /^[0-9a-f]{64}$/; +// 数字类 payload(地块号/区块号/格位号)的位数上限:9 位(最大 999999999)远超任何真实场景可能达到的 +// 数量级,纯粹用来堵住“塞几百位数字当幌子”的套壳——真正的业务范围校验仍在 parseFarm 里。 +const DIGITS = /^\d{1,9}$/; + +// 全部协议层 memo 前缀(用于「历史字符串签名」的纯前缀判定——见 isProtocolMemo 的字符串入参分支)。 +// 与下面 isProtocolMemo 对象分支覆盖的前缀保持一致;ENC| 刻意不含(端到端私信正文,本就该进收件箱)。 +const PROTOCOL_MEMO_PREFIXES = [ + UNSTAKE_PREFIX, SLASH_PREFIX, CLAIM_PREFIX, REFUND_PREFIX, + STAKE_PREFIX, RED_PREFIX, IDCLAIM_PREFIX, IDRELEASE_PREFIX, + PET_PREFIX, PETX_PREFIX, PETBREED_PREFIX, PETEVO_PREFIX, PETFARM_PREFIX, PETUNSTATION_PREFIX, + FISH_PREFIX, CROPX_PREFIX, LAND_PREFIX, ZONE_PREFIX, PLANT_PREFIX, HARVEST_PREFIX, MINE_PREFIX, + NAME_PREFIX, MKT_PREFIX, BUY_PREFIX, DEL_PREFIX, RELAY_PREFIX, ROOM_PREFIX, +]; + +/** + * 某笔交易是否属于“协议层约定”(崽/红包/钓鱼/农场/矿洞/昵称/集市/中继等建在 memo 上的子系统), + * 而非真人私信正文。这些子系统也会发出与链上消息撞型的交易——不排除的话会被 parseMessages 误收进 + * 收件箱,激活消息防刷底线后还会被误判成可以豁免烧币下限。此处按四类前缀区别对待: + * + * ① CLAIM/REFUND/UNSTAKE/SLASH(id 引用类,amount=0,consensus 拒绝条件不依赖 amount):**共识层** + * (blockchain.ts redOpError)校验时完全不看 `to`(只认 memo 里引用的 id + 发起人权限),且未激活/ + * 格式错/权限不对时**无条件**拒绝(不看 amount 是多少),不合法形态根本进不了链,`startsWith` 足够。 + * ② STAKE/RED/IDCLAIM(转托管创建类):consensus 只在 `to === 对应托管地址` 时才校验其合法性——若发往 + * 别的地址,redOpError 的对应分支根本不会触发,交易会落到 NORMAL 兜底,此时若仍判定为“协议层”会让它 + * 绕过消息门槛。故必须额外核对 `to === 对应托管地址`。STAKE/IDCLAIM 还各自有共识激活高度(RED 从创世 + * 即生效,没有)——**激活前它们是 amount>0 的普通转账,会被 consensus 直接接受但不会被当协议操作**, + * 此时仍判定为协议层会让它免费绕过消息门槛,故还需核对 `atHeight >= 对应激活高度`。RED/IDCLAIM 还须 + * 核对 `amount` 达标(RED 用 parseRedCreate 反推的 `count`;IDCLAIM 用 `IDENTITY_STAKE_MIN`)—— + * `amount` 不足时 applyTx 的门槛判断恒假,consensus 不会当真操作接受,只是普通烧币到托管地址,此时 + * 若仍判定为协议层,会让「合法前缀 + burn>0 + amount 不足」组合免费绕开消息门槛(真正 amount 达标、 + * 发给托管地址的合法创建操作是有价值转移的第三方转账,天然不落入 isMemoSpamCandidate 的候选范围, + * 不受此处影响)。STAKE 因其门槛 `computeStakeMin` 依赖区块难度、此处拿不到,暂不做同款收紧 + * (已知缺口,留待后续单独处理——本轮改动范围外)。 + * ③ IDRELEASE(id 引用类,但拒绝条件依赖 amount,单独一类,不能归进①):**redOpError 的未激活门控只在 + * `tx.amount === 0` 时才触发**(`!identityActive && startsWith(IDRELEASE) && amount===0` 才拒绝), + * 这是刻意设计——避免 retroactive 拒绝激活前 amount>0 的历史普通转账。副作用是「amount≠0 + 未激活」 + * 这个组合会漏过 redOpError 所有 IDRELEASE 专属分支、落到 NORMAL 当普通转账接受,此时若 isProtocolMemo + * 仍无条件按前缀判定为协议操作,就会被免费用来绕过消息门槛(自转 1 币 + 超长 IDRELEASE| 内容 + burn=0)。 + * 故必须核对 `atHeight >= IDENTITY_ACTIVATION_HEIGHT && amount === 0`——真正会被 consensus 当解锁 + * 处理的唯一形态。 + * ④ PET/FISH/LAND/ZONE/PLANT/HARVEST/CROPX/MINE 系:共识层完全不管,合法性全靠各自 parseXxx(chain) 在 + * 展示层重放判定,且几乎全部要求 `from === to`(自转烧币,见各模块 `selfBurn`/`tx.from!==tx.to` 判断), + * `PETX`(送崽转移)例外——要求 `to !== from` 且 `amount>0`(转账)。纯 `startsWith` 或只核对 payload/ + * 烧币额仍不够:不满足自转/转移语境的“伪装成协议操作的普通转账”也会被误判排除;数字类 payload + * (地块号/区块号/格位号)若不限长度,也能被塞成任意长度的数字串当垃圾载体(这几个字段的真实业务 + * 范围校验在 parseFarm 里,此处只做“像不像一个正常数字标识符”的语法粗筛,用 DIGITS 卡位数上限)。 + * 故这里同时核对 payload 格式 + 该操作要求的精确/达标烧币额 + `from`/`to`/`amount` 语境,**复用各 + * 模块导出的现成常量/函数**而非在此重复定义一套规则,避免与游戏模块演进脱节。 + * ⑤ NAME/MKT/DEL/RELAY/ROOM(自转、burn 恒为 0 的纯展示层约定):昵称抢注/集市上架/集市撤单/中继发布/ + * 房间布局发布都是“自转 1 币 + memo,burn=0”。isMemoSpamCandidate 收紧为“自转+memo 非空”(不再要求 + * amount=0)后,这几个不能再像旧版那样靠“burn=0 被 isMessageTx 天然排除”蒙混过关,必须显式核对 + * payload 格式(复用 names.ts/market.ts/relays.ts 的现成校验;ROOM 无独立 core 模块,直接核对 + * 64-hex hash)+ `burn === 0`(真实形态恒定,容不得套壳夹带垃圾还绕开门槛——套壳者只要 burn>0 + * 就已经不是这几个协议的合法形态,会落回消息门槛)。 + * + * ⚠️ 刻意**不含** `ENC|`(端到端加密私信,本就是私信正文,必须留在收件箱)。 + * + * 入参兼容:本函数历史签名是 `isProtocolMemo(memo: string)`,做**收件箱展示过滤**用(判断一条 memo 像不像 + * 协议 memo、以便不当私信显示)。本轮加固后改为需要完整交易语境的对象签名,做**经济门槛豁免**判定。二者 + * 的“保守方向”正好相反:展示层缺信息时应偏向“当协议、别显示”(纯前缀命中即真),经济层缺信息时应偏向 + * “当真消息、照收门槛”。故字符串入参**沿用历史纯前缀语义**(命中任一协议前缀即 true,见 PROTOCOL_MEMO_PREFIXES)—— + * 精确复刻旧版展示行为、不把 PET/FISH 等协议 memo 泄进收件箱;对象入参才走下面完整的语境化豁免判定 + * (消息门槛只由对象调用点 isRealMessage→redOpError 驱动,永远传对象,故字符串分支绝不参与经济门槛、 + * 不会因纯前缀放行而在门槛侧开绕过口子)。字符串调用方若需精确豁免判定应改传完整交易。 + */ +export function isProtocolMemo( + tx: + | string + | { + memo: string; + burn?: number; + from?: string; + to?: string; + amount?: number; + atHeight?: number; + }, +): boolean { + // 历史字符串签名(展示过滤):纯前缀判定,命中任一协议前缀即视为协议 memo(与旧版逐字一致)。 + if (typeof tx === 'string') return PROTOCOL_MEMO_PREFIXES.some((p) => tx.startsWith(p)); + const { memo, from, to } = tx; + const burn = tx.burn ?? 0; + const amount = tx.amount ?? 0; + const atHeight = tx.atHeight ?? Infinity; // 未传高度(如旧调用点/纯单元测试)时按“已激活”处理,不新增拒绝面 + const selfTransfer = from !== undefined && to !== undefined && from === to; + + // ① id 引用类(拒绝条件不依赖 amount):consensus 不看 to、未激活/格式错/权限不对时无条件拒绝,startsWith 足够 + if ( memo.startsWith(UNSTAKE_PREFIX) || memo.startsWith(SLASH_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(CLAIM_PREFIX) || + memo.startsWith(REFUND_PREFIX) + ) { + return true; + } + + // ② 转托管创建类:只有真的发往对应托管地址 + 已过激活高度,consensus 才会校验/接纳,否则等于普通转账 + if (memo.startsWith(STAKE_PREFIX)) return to === STAKE_ESCROW_ADDRESS && atHeight >= STAKING_ACTIVATION_HEIGHT; + if (memo.startsWith(RED_PREFIX)) { + // 红包从创世即生效,无激活高度;但 amount 须达标(applyTx 要求 amount >= meta.count)才是真红包。 + const meta = parseRedCreate(memo); + return to === RED_ESCROW_ADDRESS && meta !== null && amount >= meta.count; + } + if (memo.startsWith(IDCLAIM_PREFIX)) { + return to === IDENTITY_ESCROW_ADDRESS && atHeight >= IDENTITY_ACTIVATION_HEIGHT && amount >= IDENTITY_STAKE_MIN; + } + + // ③ IDRELEASE:拒绝条件依赖 amount(未激活门控只拦 amount=0 新边界),故不能归进①的无条件 startsWith; + // 只有「已激活 + amount=0」才是真正会被 consensus 当解锁处理的形态,其余(含未激活+amount≠0)不豁免。 + if (memo.startsWith(IDRELEASE_PREFIX)) return atHeight >= IDENTITY_ACTIVATION_HEIGHT && amount === 0; + + // ④ 仅应用层校验的游戏前缀:payload 格式 + 烧币额 + from/to/amount 语境,防「伪装成协议操作」绕过消息门槛 + // 孵化/铸渔获:parsePets/parseFish 都要求 burn>0(见各自 `tx.from!==tx.to || (tx.burn??0)<=0` 早退); + // 此前这里没核对 burn,burn=0 的精确 `PET|`/`FISH|` 自转会被误判成真实铸造而免费绕开消息门槛(虽然 + // 因是精确匹配、无 payload 可塞,spam 规模有限,但判定本身错误——它根本不是一次真实铸造)。 + if (memo === PET_PREFIX) return selfTransfer && burn > 0; // 孵化:memo 精确、自转、burn>0(无固定值),无 payload 可塞 + if (memo === FISH_PREFIX) return selfTransfer && burn > 0; // 铸渔获:同上 + if (memo === CROPX_PREFIX) return selfTransfer; // 预留前缀(尚未实现转移逻辑,parseFarm 无条件忽略,无 burn 要求),精确匹配最保守 + + if (memo.startsWith(PETX_PREFIX)) { + // 送崽:转移给别人(非自转)+ 真的转了币,payload=petId。amount=0 的「转移」不是真实语义,不豁免。 + return !selfTransfer && amount > 0 && HEX64.test(memo.slice(PETX_PREFIX.length)); + } + if (memo.startsWith(PETUNSTATION_PREFIX)) return selfTransfer && HEX64.test(memo.slice(PETUNSTATION_PREFIX.length)); // 召回:自转、免费,payload=petId + if (memo.startsWith(PETBREED_PREFIX)) { + const parts = memo.slice(PETBREED_PREFIX.length).split('|'); + return selfTransfer && parts.length === 2 && parts.every((p) => HEX64.test(p)) && burn === PET_BREED_COST; + } + if (memo.startsWith(PETEVO_PREFIX)) { + return selfTransfer && 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 selfTransfer && parts.length === 2 && parts.every((p) => HEX64.test(p)) && burn === PETFARM_COST; + } + if (memo.startsWith(LAND_PREFIX)) { + // 地价随链上状态浮动(下限需重放 soldTotal/velocity 才能算出),此处无法精确核验金额; + // 但 payload 收紧为「≤9 位数字 + 自转」已杜绝夹带任意长度字符串,真实地价下限仍由 parseFarm/共识层把关。 + // parseFarm 的 selfBurn 门槛要求 burn>0(地价恒为正)——此前这里没核对,burn=0 的「LAND|」 + // 自转会被误判成真实买地而免费绕开消息门槛。 + return selfTransfer && burn > 0 && DIGITS.test(memo.slice(LAND_PREFIX.length)); + } + if (memo.startsWith(ZONE_PREFIX)) { + if (!selfTransfer) return false; + 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 DIGITS.test(plotN) && (ZONE_TYPES as readonly string[]).includes(type) && burn === ZONE_COST; + } + if (memo.startsWith(PLANT_PREFIX)) { + if (!selfTransfer) return false; + 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) || !DIGITS.test(slot)) return false; + return burn === SEED_COST[crop as Crop]; + } + if (memo.startsWith(HARVEST_PREFIX)) { + return selfTransfer && HEX64.test(memo.slice(HARVEST_PREFIX.length)) && burn === HARVEST_BURN; + } + if (memo.startsWith(MINE_PREFIX)) { + if (!selfTransfer) return false; + const m = parseMineMemo(memo); + if (!m) return false; + // 深度/坐标/数量字段用 Number() 归一化,前导零填充可把 memo 撑满 512 码点仍解析出合法小值; + // 用同一套 make* 建造函数反推规范 memo 做逐字节比对,堵住这条填充绕开消息门槛的路。 + const rebuilt = m.type === 'discovery' ? makeMineDiscovery(m.depth, m.x, m.y, m.kind) : makeMineMaterial(m.kind, m.count); + if (!rebuilt.ok || rebuilt.memo !== memo) return false; + const needed = m.type === 'discovery' ? mineDiscoveryBurn(m.depth, m.kind) : mineMaterialBurn(m.kind, m.count); + return burn >= needed; + } + + // ⑤ 自转、burn 恒为 0 的纯展示层约定:昵称抢注 / 集市上架 / 集市撤单 / 中继发布 + if (memo.startsWith(NAME_PREFIX)) { + if (!selfTransfer || burn !== 0) return false; + // 不 trim:parseNames 会 trim 后再判定合法性(容忍意外首尾空白),但这里若照抄 trim,会让「真实 + // 内容只有 1 个字符、靠几百个空格填充撑满 512 码点」的套壳被 isValidName 放过;NAME_RE 本就不含 + // 空白字符,不 trim 直接判定既保留大小写不敏感、又堵死这条空白填充路径。 + const name = memo.slice(NAME_PREFIX.length).toLowerCase(); + return isValidName(name); + } + if (memo.startsWith(MKT_PREFIX)) { + if (!selfTransfer || burn !== 0) return false; + const rest = memo.slice(MKT_PREFIX.length); + const sep = rest.indexOf('|'); + if (sep < 0) return false; + const priceToken = rest.slice(0, sep); + const price = Number(priceToken); + const title = rest.slice(sep + 1); + // 价格字段须是其数值的规范十进制形式(无前导零/正号/科学计数法等),否则可用几百位填充撑满 512 + // 码点、仍被 Number() 归一成合法小价格,绕开消息门槛。 + return ( + Number.isInteger(price) && price > 0 && String(price) === priceToken && + title.length > 0 && [...title].length <= MAX_TITLE + ); + } + if (memo.startsWith(DEL_PREFIX)) { + // 撤单:V0idNode.marketDelist() 固定自转 1 币 + burn=0;payload = 上架交易 txid(64-hex)。 + return selfTransfer && burn === 0 && HEX64.test(memo.slice(DEL_PREFIX.length)); + } + if (memo.startsWith(BUY_PREFIX)) { + // ⑥ 集市购买:唯一「付款给别人(非自转)」形态的协议 memo——V0idNode.marketBuy() 付 price 给卖家 + // (from≠to、amount>0、burn=0),payload = 上架交易 txid(64-hex,定长不可填充)。`BUY|<64hex>` 恒为 + // 68 码点 > 免费额度,若不在此豁免,激活后每一笔集市购买都会被消息门槛误杀(#1 的长度网副作用)。 + // payload 定长 + hex-only,无法当灌水载体,豁免安全。金额是否 ≥ 标价由 parseMarket 结合状态判,此处 + // 只核结构形态。 + return !selfTransfer && amount > 0 && burn === 0 && HEX64.test(memo.slice(BUY_PREFIX.length)); + } + if (memo.startsWith(RELAY_PREFIX)) { + if (!selfTransfer || burn !== 0) return false; + const m = parseRelayMemo(memo); + // port 等数字字段同样能被前导零填充撑满 512 码点仍解析出合法小值;用 buildRelayMemo 反推规范 + // memo 逐字节比对,堵住这条绕开消息门槛的路。 + return m !== null && buildRelayMemo(m.onionPubHex, m.host, m.port, m.bandwidth, m.stakeTxid) === memo; + } + if (memo.startsWith(ROOM_PREFIX)) { + // 房间布局发布:game-web publishRoom() 固定自转 1 币 + burn=0;payload = 布局 hash(64-hex)。 + return selfTransfer && burn === 0 && HEX64.test(memo.slice(ROOM_PREFIX.length)); + } + + return false; +} + +/** 是否“真实消息”(应受消息防刷底线约束,见 config.ts minMessageBurnFor):消息防刷候选 + 非协议层 memo。 */ +export function isRealMessage(tx: { + amount: number; + burn?: number; + memo: string; + from: string; + to: string; + atHeight: number; +}): boolean { + return isMemoSpamCandidate(tx) && !isProtocolMemo(tx); } /** 扫整条链,把所有消息交易还原成消息列表(最新在前)。协议层 memo(PET/RED/FISH…)不算私信,跳过。 */ @@ -61,7 +343,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, atHeight: b.index })) continue; out.push({ txid: tx.txid, from: tx.from, diff --git a/packages/core/src/pets.ts b/packages/core/src/pets.ts index 982145b..1786615 100644 --- a/packages/core/src/pets.ts +++ b/packages/core/src/pets.ts @@ -252,7 +252,10 @@ export function parsePets(chain: Block[]): Pet[] { const id = m.slice(PETX_PREFIX.length); const pet = pets.get(id); // 只有当前主人能转;接收方须是合法地址;派驻农场中的崽被锁,不能转移(须先召回)。 - if (pet && tx.from === pet.owner && !pet.stationedZone && isValidAddress(tx.to) && tx.to !== tx.from) { + // 须 amount>0(送崽 = 转 1 币给对方,见文件头约定)——与 isProtocolMemo 的 PETX 判定一致: + // amount=0 的“转移”不是真实语义,不该被当有效送崽(否则解析器认它有效、而消息门槛却因它不达 + // isProtocolMemo 的 amount>0 条件把它当真消息误杀,两侧分类冲突)。 + if (pet && tx.amount > 0 && tx.from === pet.owner && !pet.stationedZone && isValidAddress(tx.to) && tx.to !== tx.from) { pet.owner = tx.to; } } diff --git a/packages/core/src/relays.ts b/packages/core/src/relays.ts index 701d8a0..ee0f1ed 100644 --- a/packages/core/src/relays.ts +++ b/packages/core/src/relays.ts @@ -8,7 +8,9 @@ // 重发即更新(换 host / 轮换 onion 公钥)→ 取该地址最后一笔合法描述符。 // - **okey 无需链上单独证明归属**:描述符里的 onion 公钥由中继自报。若它谎报了别人的 okey, // 也只是造出一个**无法完成 ntor 握手**的中继(它没有对应私钥 → 客户端 AUTH 校验失败、换中继),损人不利己、无害。 -// - **不进 isProtocolMemo**:RELAY 是 burn=0 的自转,isMessageTx 天然排除,压根不是消息——与 NAME| 一致(见 messages.ts 注释)。 +// - **进 isProtocolMemo 白名单**(messages.ts 复用本文件 parseRelayMemo 做格式校验 + burn===0 判断): +// RELAY 是自转,消息防刷底线上线后「自转+memo非空」都会被当成消息候选,必须显式排除, +// 不能再像旧版那样仅靠 burn=0 被 isMessageTx 天然排除。 import type { Block } from './block.js'; import { MAX_MEMO } from './config.js'; import { computeStakeState } from './staking.js'; @@ -60,8 +62,8 @@ export function makeRelayClaim( return { ok: true, memo }; } -/** 解析单条 memo → 描述符字段(不含 address,由调用方补 tx.from)。非法返回 null。 */ -function parseRelayMemo(memo: string): Omit | null { +/** 解析单条 memo → 描述符字段(不含 address,由调用方补 tx.from)。非法返回 null。也供 messages.ts 复用做格式校验。 */ +export function parseRelayMemo(memo: string): Omit | null { if (!memo.startsWith(RELAY_PREFIX)) return null; const parts = memo.slice(RELAY_PREFIX.length).split('|'); if (parts.length !== 4) return null; diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index 9224839..173c8c6 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'; @@ -55,23 +56,28 @@ export function createTransaction( nonce: number, memo = '', fee?: number, + burn = 0, ): Transaction { const actualFee = fee ?? minFeeFor(amount); - const base: TransactionPayload = { from: wallet.address, to, amount, fee: actualFee, nonce, timestamp: Date.now(), memo }; + // burn 默认 0(与升级前逐字节一致:transactionPayloadHash 仅在 burn>0 时计入,故 burn=0 的哈希不变)。 + // 支持 burn>0 是为「付款附带较长备注」——消息门槛激活后长 memo 转账需按 minMessageBurnFor 销毁,见 node.send。 + const base: TransactionPayload = { from: wallet.address, to, amount, fee: actualFee, nonce, timestamp: Date.now(), memo, burn }; const txid = transactionPayloadHash(base); return { ...base, signature: sign(txid, wallet.privateKey), txid }; } /** - * 链上消息:由钱包签名。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 +129,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; diff --git a/packages/game-web/src/room.ts b/packages/game-web/src/room.ts index 53df0e9..cd67cd3 100644 --- a/packages/game-web/src/room.ts +++ b/packages/game-web/src/room.ts @@ -1,11 +1,9 @@ // 房间布局:序列化 → sha256 → 发布(链上只记版本 hash,字节存服务器)。串门时按 hash 校验。 -import { Wallet, createTransaction, sha256Hex } from '@v0idchain/core/browser'; +import { Wallet, createTransaction, sha256Hex, ROOM_PREFIX } from '@v0idchain/core/browser'; import type { FurnitureItem } from './engine/scene'; import type { RoomThemeId } from './engine/tileset'; import { api } from './api'; -export const ROOM_PREFIX = 'ROOM|'; - export interface RoomLayout { theme: RoomThemeId; furniture: FurnitureItem[]; diff --git a/packages/node/src/api.ts b/packages/node/src/api.ts index 39e949f..e8e2cf9 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()); @@ -188,7 +198,9 @@ export function startHttpApi(node: V0idNode, port: number, token: string, roles? if (!isValidAddress(String(body.to))) return json(400, { error: '收款地址格式无效' }); const amount = Number(body.amount); const fee = body.fee === undefined ? minFeeFor(amount) : Number(body.fee); - const r = node.send(String(body.to), amount, String(body.memo ?? ''), fee); + // burn 省略 → node.send 按备注长度自动补足消息门槛所需销毁额;显式传则覆盖。 + const burn = body.burn === undefined ? undefined : Number(body.burn); + const r = node.send(String(body.to), amount, String(body.memo ?? ''), fee, burn); return r.ok ? json(200, { txid: r.tx!.txid }) : json(400, { error: r.error }); } case '/message': { @@ -231,6 +243,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..b7c36f3 100644 --- a/packages/node/src/node.ts +++ b/packages/node/src/node.ts @@ -10,6 +10,9 @@ import { FEE_RATE_BPS, minFeeFor, MESSAGE_BURN, + minMessageBurnFor, + MIN_MESSAGE_BURN_ACTIVATION_HEIGHT, + isRealMessage, MAX_MEMO, NULL_ADDRESS, SYSTEM_ADDRESSES, @@ -44,6 +47,13 @@ import { computeStakeMin, STAKE_ESCROW_ADDRESS, UNSTAKE_PREFIX, + makeIdentityClaim, + computeIdentityState, + resolveIdentityOwner, + IDENTITY_ESCROW_ADDRESS, + IDENTITY_STAKE_MIN, + IDENTITY_ACTIVATION_HEIGHT, + IDRELEASE_PREFIX, writeWalletFile, blockHeaders, recentBlockWindow, @@ -113,6 +123,13 @@ export class V0idNode { this.opts = opts; this.wallet = loadOrCreateWallet(opts.dataDir); this.bc = loadChain(opts.dataDir); + // 每次 addBlock/replaceChain 内部的 revalidateMempool 剔除交易时,同步从 seenTx 去重缓存里 + // 忘掉这些 txid——否则外部客户端(IDCLAIM 竞态落败方等场景)重新广播同一笔已签名交易时, + // acceptTx/onTx 会因为“见过这个 txid”直接短路返回 ok,实际上永远不会重新进 mempool、也不会 + // confirm,用户会一直卡在“提交成功但没反应”,直到本节点进程重启(seenTx 清空)才会恢复正常。 + this.bc.onMempoolDropped = (txids) => { + for (const id of txids) this.seenTx.delete(id); + }; // 启动时把现有链里的地址全部记为“已知”,并把扫描指针对齐链顶 —— 之后只对新涌现的地址报“新人”,不刷屏历史 this.knownAddresses = collectAddresses(this.bc.chain); this.lastScanHeight = this.bc.height; @@ -144,19 +161,31 @@ export class V0idNode { } // ---- 钱包动作 ---- - /** 本节点发起转账:算好 nonce、签名、进池、广播。fee 省略时自动按比例计算(minFeeFor(amount))。 */ - send(to: string, amount: number, memo = '', fee?: number): { ok: boolean; tx?: Transaction; error?: string } { - return this.submit(this.wallet, to, amount, memo, fee ?? minFeeFor(amount)); + /** + * 本节点发起转账:算好 nonce、签名、进池、广播。fee 省略时自动按比例计算(minFeeFor(amount))。 + * burn 省略时**自动**处理消息防刷底线:若这笔转账会落入消息门槛(自转带备注 / 备注超免费额度, + * 且非协议 memo),激活后 redOpError 会要求按 minMessageBurnFor 销毁——此处自动补上所需销毁额, + * 否则长备注付款会被静默拒收且钱包无从补烧(普通短备注付款 burn 保持 0,不受影响)。可显式传 burn 覆盖。 + */ + send(to: string, amount: number, memo = '', fee?: number, burn?: number): { ok: boolean; tx?: Transaction; error?: string } { + const atHeight = this.bc.height + 1; // 这笔大约会被打进的高度 + const floored = + atHeight >= MIN_MESSAGE_BURN_ACTIVATION_HEIGHT && + isRealMessage({ amount, burn: burn ?? 0, memo, from: this.wallet.address, to, atHeight }); + const actualBurn = burn ?? (floored ? minMessageBurnFor([...memo].length) : 0); + return this.submit(this.wallet, to, amount, memo, fee ?? minFeeFor(amount), actualBurn); } /** * 给某地址发一条链上消息:不转币、烧 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 } { @@ -167,9 +196,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); @@ -258,6 +288,41 @@ export class V0idNode { .map(([id, p]) => ({ id, ...p })); } + // ---- 质押身份(Phase 3B:纯资金锁仓反女巫,无罚没、无仲裁者)---- + /** + * 认领:转给身份托管地址 + memo `IDCLAIM|`,锁定 IDENTITY_STAKE_MIN 押金。 + * ⚠️ 激活高度前必须拒绝:consensus 只在 atHeight >= IDENTITY_ACTIVATION_HEIGHT 时才把这笔转账当作 + * 认领记录进 identityClaims;提前发送会被当成一笔普通转账吃进托管地址,永久锁死却没有任何认领记录、 + * 无法 IDRELEASE 取回。api.ts 的 HTTP 路由已有同款守卫,这里补一份是防御性重复——直接内嵌调用本方法 + * (而非只走 HTTP)的场景(如未来 CLI 直连模式、被当库嵌入)不能只靠 HTTP 层这一道闸。 + */ + claimIdentity(pseudonym: string): { ok: boolean; tx?: Transaction; error?: string } { + if (this.bc.height < IDENTITY_ACTIVATION_HEIGHT) { + return { ok: false, error: `身份质押尚未激活(当前高度 ${this.bc.height},激活高度 ${IDENTITY_ACTIVATION_HEIGHT})` }; + } + 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 结算后自然填充。 @@ -368,10 +433,11 @@ export class V0idNode { amount: number, memo: string, fee: number, + burn = 0, ): { ok: boolean; tx?: Transaction; error?: string } { const pending = this.bc.mempool.filter((t) => t.from === wallet.address).length; const nonce = this.bc.nonceOf(wallet.address) + pending; - const tx = createTransaction(wallet, to, amount, nonce, memo, fee); + const tx = createTransaction(wallet, to, amount, nonce, memo, fee, burn); 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 6d1c54c..9a029a7 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() { + +
@@ -505,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); @@ -516,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) }); @@ -545,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" />