From 88f64a2b67b3d32c0040d59d07bd8d4b2c7d4ff1 Mon Sep 17 00:00:00 2001 From: v0id-byte Date: Tue, 30 Jun 2026 18:22:46 -0700 Subject: [PATCH 1/2] feat(core): add client protocol reference helpers --- docs/blockchain/CLIENT-PROTOCOL.md | 3 + packages/core/src/browser.ts | 1 + packages/core/src/client-protocol.ts | 240 +++++++++++++++++++++++++++ packages/core/src/crypto.ts | 21 ++- packages/core/src/index.ts | 1 + packages/core/src/transaction.ts | 28 ++-- scripts/client-protocol-test.ts | 86 ++++++++++ scripts/light-sync-test.ts | 27 +++ 8 files changed, 389 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/client-protocol.ts create mode 100644 scripts/client-protocol-test.ts diff --git a/docs/blockchain/CLIENT-PROTOCOL.md b/docs/blockchain/CLIENT-PROTOCOL.md index e946c15..da3f2c1 100644 --- a/docs/blockchain/CLIENT-PROTOCOL.md +++ b/docs/blockchain/CLIENT-PROTOCOL.md @@ -3,6 +3,7 @@ > 写**任何**非 Node 客户端(Swift / Kotlin / Rust …)都必须严格照此实现。 > **核心铁律:你的客户端算出的 `txid` 和签名,必须与 `packages/core` 逐字节一致**,否则签出的交易全网校验不过、直接被丢弃。 > 权威参考实现:`packages/core/src/{crypto,transaction,block,blockchain,messages}.ts`。本规范若与代码冲突,**以代码为准**——但下面的金标准测试向量可让你自检是否对齐。 +> 客户端协议参考层:`packages/core/src/client-protocol.ts`(余额/nonce 回放、headers/recent/proof 验证、轻同步消息构造)与 `scripts/client-protocol-test.ts`(§9 金标准向量)。 --- @@ -154,6 +155,8 @@ merkleRoot(txids): 两两 sha256Hex(a+b) 逐层归并,奇数复制末尾;空 3. 用户导入老地址或打开历史页时,发 `QUERY_ADDRESS_PROOFS` 回填该地址历史;每条 proof 用区块 header 的 `merkleRoot` 验证交易存在。 4. 若要查单笔交易,发 `QUERY_TX_PROOF`;若要浏览旧区块,发 `QUERY_BLOCK_RANGE` 按高度段拉取。 +JS/TS 客户端可直接复用 `recentSyncWindow()`、`verifyLightSyncSnapshot()`、`replayAddressProofs()`、`replayClientState()`;Swift/Kotlin/Rust 客户端照这些纯函数移植即可。 + 安全边界:Merkle proof 是**存在证明**,不是“无遗漏证明”。如果钱包不拉全链,最好向多个节点交叉请求同一地址历史;真正的单节点余额证明需要未来共识层加入状态承诺。 --- diff --git a/packages/core/src/browser.ts b/packages/core/src/browser.ts index fc9ef6e..69890f7 100644 --- a/packages/core/src/browser.ts +++ b/packages/core/src/browser.ts @@ -10,6 +10,7 @@ export * from './wallet.js'; export * from './transaction.js'; export * from './block.js'; export * from './light.js'; +export * from './client-protocol.js'; export * from './blockchain.js'; export * from './market.js'; export * from './messages.js'; diff --git a/packages/core/src/client-protocol.ts b/packages/core/src/client-protocol.ts new file mode 100644 index 0000000..6009388 --- /dev/null +++ b/packages/core/src/client-protocol.ts @@ -0,0 +1,240 @@ +import type { Block } from './block.js'; +import { genesisBlock } from './blockchain.js'; +import { CHECKPOINTS, NULL_ADDRESS } from './config.js'; +import { isValidAddress } from './crypto.js'; +import { blockHeader, blockMerkleRootMatches, type BlockHeader, type TxInclusionProof, verifyHeaderChain, verifyTxInclusionProof } from './light.js'; +import { isCoinbase, type Transaction, verifyTransaction } from './transaction.js'; + +export const CLIENT_RECENT_BLOCKS = 10_000; +export const CLIENT_RECENT_MS = 3 * 24 * 60 * 60 * 1000; + +export interface ClientReplayState { + balances: Map; + nonces: Map; +} + +export interface ClientAddressReplay { + address: string; + balance: number; + nonce: number; + proofs: TxInclusionProof[]; +} + +export type ClientProtocolResult = { ok: true; value: T } | { ok: false; error: string }; + +export interface HeaderVerifyOptions { + requireGenesis?: boolean; + checkpoints?: { index: number; hash: string }[]; +} + +export interface RecentWindow { + maxBlocks: number; + minTimestamp: number; +} + +export interface LightSyncSnapshot { + headers: BlockHeader[]; + recentBlocks: Block[]; + maxBlocks?: number; + minTimestamp?: number; +} + +export type LightClientOutgoingMessage = + | { type: 'HELLO'; address: string; height: number; listen: string } + | { type: 'QUERY_HEADERS'; from?: number; to?: number } + | { type: 'QUERY_RECENT'; maxBlocks?: number; minTimestamp?: number } + | { type: 'QUERY_BLOCK_RANGE'; from: number; to: number } + | { type: 'QUERY_TX_PROOF'; txid: string } + | { type: 'QUERY_ADDRESS_PROOFS'; address: string; from?: number; to?: number } + | { type: 'TX'; tx: Transaction } + | { type: 'QUERY_PEERS' }; + +export function createClientReplayState(): ClientReplayState { + return { balances: new Map(), nonces: new Map() }; +} + +function credit(st: ClientReplayState, address: string, amount: number): void { + st.balances.set(address, (st.balances.get(address) ?? 0) + amount); +} + +function debit(st: ClientReplayState, address: string, amount: number): void { + st.balances.set(address, (st.balances.get(address) ?? 0) - amount); +} + +export function applyClientTransaction(st: ClientReplayState, tx: Transaction): void { + if (isCoinbase(tx)) { + credit(st, tx.to, tx.amount); + return; + } + + const burn = tx.burn ?? 0; + debit(st, tx.from, tx.amount + tx.fee + burn); + if (burn > 0) credit(st, NULL_ADDRESS, burn); + st.nonces.set(tx.from, (st.nonces.get(tx.from) ?? 0) + 1); + credit(st, tx.to, tx.amount); +} + +export function replayClientState(blocks: Block[]): ClientReplayState { + const st = createClientReplayState(); + for (const block of blocks) { + for (const tx of block.transactions) applyClientTransaction(st, tx); + } + return st; +} + +export function clientBalanceOf(st: ClientReplayState, address: string): number { + return st.balances.get(address) ?? 0; +} + +export function clientNonceOf(st: ClientReplayState, address: string): number { + return st.nonces.get(address) ?? 0; +} + +export function nextClientNonce(st: ClientReplayState, address: string, pendingCount = 0): number { + return clientNonceOf(st, address) + pendingCount; +} + +export function recentSyncWindow(now = Date.now(), maxBlocks = CLIENT_RECENT_BLOCKS, lookbackMs = CLIENT_RECENT_MS): RecentWindow { + return { maxBlocks, minTimestamp: now - lookbackMs }; +} + +export function lightClientHello(address: string, height = 0, listen = `light://${address}/${Date.now().toString(36)}`): LightClientOutgoingMessage { + return { type: 'HELLO', address, height, listen }; +} + +export function queryHeaders(from = 0, to?: number): LightClientOutgoingMessage { + return to === undefined ? { type: 'QUERY_HEADERS', from } : { type: 'QUERY_HEADERS', from, to }; +} + +export function queryRecent(window: RecentWindow = recentSyncWindow()): LightClientOutgoingMessage { + return { type: 'QUERY_RECENT', maxBlocks: window.maxBlocks, minTimestamp: window.minTimestamp }; +} + +export function queryBlockRange(from: number, to: number): LightClientOutgoingMessage { + return { type: 'QUERY_BLOCK_RANGE', from, to }; +} + +export function queryTxProof(txid: string): LightClientOutgoingMessage { + return { type: 'QUERY_TX_PROOF', txid }; +} + +export function queryAddressProofs(address: string, from = 0, to?: number): LightClientOutgoingMessage { + return to === undefined ? { type: 'QUERY_ADDRESS_PROOFS', address, from } : { type: 'QUERY_ADDRESS_PROOFS', address, from, to }; +} + +export function submitClientTx(tx: Transaction): LightClientOutgoingMessage { + return { type: 'TX', tx }; +} + +export function verifyClientHeaders( + headers: BlockHeader[], + opts: HeaderVerifyOptions = {}, +): ClientProtocolResult<{ work: bigint; tip: BlockHeader }> { + const requireGenesis = opts.requireGenesis ?? true; + if (headers.length === 0) return { ok: false, error: '空 header 链' }; + if (requireGenesis && headers[0].index !== 0) return { ok: false, error: 'header 链必须从创世高度开始' }; + if (requireGenesis && headers[0].hash !== genesisBlock().hash) return { ok: false, error: '创世 header 不一致' }; + + const checked = verifyHeaderChain(headers); + if (!checked.ok || checked.work === undefined) return { ok: false, error: checked.error ?? 'header 链校验失败' }; + + const byHeight = new Map(headers.map((h) => [h.index, h])); + for (const cp of opts.checkpoints ?? CHECKPOINTS) { + const h = byHeight.get(cp.index); + if (h && h.hash !== cp.hash) return { ok: false, error: `#${cp.index} 与 checkpoint 不一致` }; + if (requireGenesis && headers[headers.length - 1].index >= cp.index && !h) { + return { ok: false, error: `缺少 checkpoint #${cp.index} 的 header` }; + } + } + + return { ok: true, value: { work: checked.work, tip: headers[headers.length - 1] } }; +} + +export function verifyBlockAgainstHeaders(block: Block, headers: BlockHeader[]): ClientProtocolResult { + const header = headers.find((h) => h.index === block.index); + if (!header) return { ok: false, error: `缺少 #${block.index} header` }; + if (header.hash !== block.hash) return { ok: false, error: `#${block.index} hash 不在 header 链中` }; + if (blockHeader(block).merkleRoot !== header.merkleRoot) return { ok: false, error: `#${block.index} merkleRoot 与 header 不一致` }; + if (!blockMerkleRootMatches(block)) return { ok: false, error: `#${block.index} merkleRoot 不匹配` }; + for (const tx of block.transactions) { + if (!verifyTransaction(tx)) return { ok: false, error: `#${block.index} 交易 ${tx.txid.slice(0, 12)} 自洽性失败` }; + } + return { ok: true, value: header }; +} + +export function verifyRecentBlocks( + headers: BlockHeader[], + blocks: Block[], + window: Partial = {}, +): ClientProtocolResult { + if (window.maxBlocks !== undefined && blocks.length > window.maxBlocks) { + return { ok: false, error: `recent blocks 超过窗口上限 ${window.maxBlocks}` }; + } + for (const block of blocks) { + if (window.minTimestamp !== undefined && block.timestamp < window.minTimestamp) { + return { ok: false, error: `#${block.index} 早于 recent minTimestamp` }; + } + const checked = verifyBlockAgainstHeaders(block, headers); + if (!checked.ok) return { ok: false, error: checked.error }; + } + return { ok: true, value: blocks }; +} + +export function verifyLightSyncSnapshot(snapshot: LightSyncSnapshot): ClientProtocolResult<{ work: bigint; tip: BlockHeader }> { + const headers = verifyClientHeaders(snapshot.headers); + if (!headers.ok) return headers; + const recent = verifyRecentBlocks(snapshot.headers, snapshot.recentBlocks, { + maxBlocks: snapshot.maxBlocks ?? CLIENT_RECENT_BLOCKS, + minTimestamp: snapshot.minTimestamp, + }); + if (!recent.ok) return { ok: false, error: recent.error }; + return headers; +} + +export function verifyProofAgainstHeaders(proof: TxInclusionProof, headers: BlockHeader[]): ClientProtocolResult { + const header = headers.find((h) => h.index === proof.block.index); + if (!header) return { ok: false, error: `缺少 #${proof.block.index} header` }; + if (header.hash !== proof.block.hash) return { ok: false, error: `#${proof.block.index} proof 不在 header 链中` }; + if (!verifyTxInclusionProof(proof)) return { ok: false, error: `交易 ${proof.tx.txid.slice(0, 12)} Merkle proof 无效` }; + if (!verifyTransaction(proof.tx)) return { ok: false, error: `交易 ${proof.tx.txid.slice(0, 12)} 自洽性失败` }; + return { ok: true, value: proof }; +} + +export function replayAddressProofs( + address: string, + proofs: TxInclusionProof[], + headers?: BlockHeader[], +): ClientProtocolResult { + if (!isValidAddress(address)) return { ok: false, error: 'address 必须是合法地址' }; + + let balance = 0; + let nonce = 0; + const seen = new Set(); + const ordered = [...proofs].sort((a, b) => a.block.index - b.block.index || a.txIndex - b.txIndex || a.tx.txid.localeCompare(b.tx.txid)); + const accepted: TxInclusionProof[] = []; + + for (const proof of ordered) { + if (headers) { + const checked = verifyProofAgainstHeaders(proof, headers); + if (!checked.ok) return { ok: false, error: checked.error }; + } else if (!verifyTxInclusionProof(proof)) { + return { ok: false, error: `交易 ${proof.tx.txid.slice(0, 12)} Merkle proof 无效` }; + } else if (!verifyTransaction(proof.tx)) { + return { ok: false, error: `交易 ${proof.tx.txid.slice(0, 12)} 自洽性失败` }; + } + + const tx = proof.tx; + if (tx.from !== address && tx.to !== address) return { ok: false, error: `交易 ${tx.txid.slice(0, 12)} 与地址无关` }; + if (seen.has(tx.txid)) continue; + seen.add(tx.txid); + accepted.push(proof); + + if (tx.to === address) balance += tx.amount; + if (tx.from === address) { + balance -= tx.amount + tx.fee + (tx.burn ?? 0); + nonce += 1; + } + } + + return { ok: true, value: { address, balance, nonce, proofs: accepted } }; +} diff --git a/packages/core/src/crypto.ts b/packages/core/src/crypto.ts index f02512f..1613909 100644 --- a/packages/core/src/crypto.ts +++ b/packages/core/src/crypto.ts @@ -97,22 +97,31 @@ export function isEncryptedMemo(memo: string): boolean { } /** 共享密钥:我的 ed25519 私钥(种子) × 对方地址(ed25519 公钥) → 32 字节对称密钥 */ -function sharedKey(myPrivateKey: Uint8Array, otherAddress: string): Uint8Array { +export function memoSharedKey(myPrivateKey: Uint8Array, otherAddress: string): Uint8Array { const otherPub = hexToBytes(addressToPublicKeyHex(otherAddress)); const secret = x25519.getSharedSecret(edwardsToMontgomeryPriv(myPrivateKey), edwardsToMontgomeryPub(otherPub)); return secret.subarray(0, 32); } -/** 加密一段明文给收件人(发送方用自己的私钥)。返回 `ENC|` 串,直接当 memo 上链。 */ -export function encryptMemo(plaintext: string, recipientAddress: string, senderPrivateKey: Uint8Array): string { - const nonce = randomBytes(24); - const ct = xchacha20poly1305(sharedKey(senderPrivateKey, recipientAddress), nonce).encrypt(utf8ToBytes(plaintext)); +export function encryptMemoWithNonce( + plaintext: string, + recipientAddress: string, + senderPrivateKey: Uint8Array, + nonce: Uint8Array, +): string { + if (nonce.length !== 24) throw new Error('XChaCha20-Poly1305 nonce must be 24 bytes'); + const ct = xchacha20poly1305(memoSharedKey(senderPrivateKey, recipientAddress), nonce).encrypt(utf8ToBytes(plaintext)); const blob = new Uint8Array(nonce.length + ct.length); blob.set(nonce); blob.set(ct, nonce.length); return ENC_PREFIX + bytesToHex(blob); } +/** 加密一段明文给收件人(发送方用自己的私钥)。返回 `ENC|` 串,直接当 memo 上链。 */ +export function encryptMemo(plaintext: string, recipientAddress: string, senderPrivateKey: Uint8Array): string { + return encryptMemoWithNonce(plaintext, recipientAddress, senderPrivateKey, randomBytes(24)); +} + /** * 解密一条 `ENC|` 私信。otherPartyAddress = 对方地址(我是收件人→填发件人;我是发件人→填收件人)。 * 用我的私钥还原同一共享密钥。失败(非本人/被篡改/格式坏)返回 null。 @@ -124,7 +133,7 @@ export function decryptMemo(memo: string, otherPartyAddress: string, myPrivateKe if (blob.length < 24 + 16) return null; // nonce(24) + 至少一个 poly1305 tag(16) const nonce = blob.subarray(0, 24); const ct = blob.subarray(24); - const pt = xchacha20poly1305(sharedKey(myPrivateKey, otherPartyAddress), nonce).decrypt(ct); + const pt = xchacha20poly1305(memoSharedKey(myPrivateKey, otherPartyAddress), nonce).decrypt(ct); return new TextDecoder().decode(pt); } catch { return null; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0c4bc8..0454263 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export * from './wallet.js'; export * from './transaction.js'; export * from './block.js'; export * from './light.js'; +export * from './client-protocol.js'; export * from './blockchain.js'; export * from './storage.js'; export * from './market.js'; diff --git a/packages/core/src/transaction.ts b/packages/core/src/transaction.ts index 293066a..6be791d 100644 --- a/packages/core/src/transaction.ts +++ b/packages/core/src/transaction.ts @@ -29,17 +29,21 @@ export interface Transaction { txid: string; // = sha256(规范化 payload) } -type TxPayload = Pick; +export type TransactionPayload = Pick; /** * 参与签名 / txid 计算的规范化字段(顺序固定,保证各节点算出一致的 hash)。fee 一并计入 → 篡改手续费即破坏 txid。 * burn **仅在 >0 时追加**到末尾:这样所有历史交易(无 burn 字段的转账/coinbase/创世)算出的哈希与升级前**逐字节一致**, * 创世 hash 与既有 checkpoint 全部不变 —— 既不重置链,又把销毁额牢牢绑进新消息交易的 txid(篡改销毁额即破 txid)。 */ -function payloadHash(t: TxPayload): string { +export function transactionPreimage(t: TransactionPayload): string { const fields: unknown[] = [t.from, t.to, t.amount, t.fee, t.nonce, t.timestamp, t.memo]; if ((t.burn ?? 0) > 0) fields.push(t.burn); - return sha256Hex(JSON.stringify(fields)); + return JSON.stringify(fields); +} + +export function transactionPayloadHash(t: TransactionPayload): string { + return sha256Hex(transactionPreimage(t)); } /** 普通转账:由钱包签名。fee 省略时自动按比例计算(minFeeFor(amount)),给多了打包更优先。 */ @@ -52,8 +56,8 @@ export function createTransaction( fee?: number, ): Transaction { const actualFee = fee ?? minFeeFor(amount); - const base: TxPayload = { from: wallet.address, to, amount, fee: actualFee, nonce, timestamp: Date.now(), memo }; - const txid = payloadHash(base); + const base: TransactionPayload = { from: wallet.address, to, amount, fee: actualFee, nonce, timestamp: Date.now(), memo }; + const txid = transactionPayloadHash(base); return { ...base, signature: sign(txid, wallet.privateKey), txid }; } @@ -69,15 +73,15 @@ export function createMessage( burn = MESSAGE_BURN, fee = MIN_FEE, ): Transaction { - const base: TxPayload = { from: wallet.address, to, amount: 0, fee, nonce, timestamp: Date.now(), memo: text, burn }; - const txid = payloadHash(base); + const base: TransactionPayload = { from: wallet.address, to, amount: 0, fee, nonce, timestamp: Date.now(), memo: text, burn }; + const txid = transactionPayloadHash(base); return { ...base, signature: sign(txid, wallet.privateKey), txid }; } /** coinbase:每个区块第一笔,矿工收入 = 出块奖励 + 本块手续费总额(fees),无签名、自身不付费 */ export function createCoinbase(minerAddress: string, blockIndex: number, fees = 0): Transaction { // nonce 用 blockIndex,保证不同高度的 coinbase txid 不同 - const base: TxPayload = { + const base: TransactionPayload = { from: NULL_ADDRESS, to: minerAddress, amount: BLOCK_REWARD + fees, @@ -86,12 +90,12 @@ export function createCoinbase(minerAddress: string, blockIndex: number, fees = timestamp: Date.now(), memo: '', }; - return { ...base, signature: '', txid: payloadHash(base) }; + return { ...base, signature: '', txid: transactionPayloadHash(base) }; } /** 创世预挖交易:固定参数 → 所有节点算出完全相同的 txid 与创世 hash */ export function createGenesisTx(premineAddress: string): Transaction { - const base: TxPayload = { + const base: TransactionPayload = { from: NULL_ADDRESS, to: premineAddress, amount: GENESIS_PREMINE, @@ -100,7 +104,7 @@ export function createGenesisTx(premineAddress: string): Transaction { timestamp: GENESIS_TIMESTAMP, memo: 'v0idChain genesis', }; - return { ...base, signature: '', txid: payloadHash(base) }; + return { ...base, signature: '', txid: transactionPayloadHash(base) }; } export function isCoinbase(t: Transaction): boolean { @@ -133,7 +137,7 @@ export function verifyTransaction(t: Transaction): boolean { if (typeof t.memo !== 'string' || t.memo.length > MAX_MEMO * 2 || [...t.memo].length > MAX_MEMO) { return false; } - if (payloadHash(t) !== t.txid) return false; // txid 必须等于内容哈希(含 fee/burn),篡改金额/手续费/销毁额即被识破 + if (transactionPayloadHash(t) !== t.txid) return false; // txid 必须等于内容哈希(含 fee/burn),篡改金额/手续费/销毁额即被识破 // coinbase / 创世:无签名,金额>0,且自身既不付手续费也不销毁(fee 与 burn 必须为 0) if (isCoinbase(t)) return t.fee === 0 && burn === 0 && t.amount > 0; if (t.fee < minFeeFor(t.amount)) return false; // 普通交易:强制比例+保底手续费 diff --git a/scripts/client-protocol-test.ts b/scripts/client-protocol-test.ts new file mode 100644 index 0000000..85470d5 --- /dev/null +++ b/scripts/client-protocol-test.ts @@ -0,0 +1,86 @@ +import { + Wallet, + bytesToHex, + decryptMemo, + encryptMemoWithNonce, + hexToBytes, + memoSharedKey, + sign, + transactionPayloadHash, + transactionPreimage, + verify, +} from '../packages/core/src/index.js'; + +let failed = 0; +const check = (label: string, cond: boolean) => { + console.log(`${cond ? '✅' : '❌'} ${label}`); + if (!cond) failed++; +}; + +const PRIV_HEX = '0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20'; +const PUB_HEX = '79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664'; +const ADDRESS = `0x${PUB_HEX}`; +const TO = `0x${'ab'.repeat(32)}`; +const TIMESTAMP = 1_700_000_000_000; + +const wallet = Wallet.fromPrivateKeyHex(PRIV_HEX); +check('§9 PRIV_HEX derives PUB_HEX', bytesToHex(wallet.publicKey) === PUB_HEX); +check('§9 PUB_HEX derives ADDRESS', wallet.address === ADDRESS); + +const transferPayload = { + from: ADDRESS, + to: TO, + amount: 100, + fee: 1, + nonce: 0, + timestamp: TIMESTAMP, + memo: 'hi 🍜', +}; +const transferPreimage = + '["0x79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664","0xabababababababababababababababababababababababababababababababab",100,1,0,1700000000000,"hi 🍜"]'; +const transferTxid = 'da4527a9715dd7e9d098f6615b588ceb051401d9e6846125feb7bdd1c4362932'; +const transferSig = + 'dab11981063113c8b5fff5f8fcaad3d9c0a49879f7cca8a9dcee16be1171b17ea8919217ab87c077f320e3ea0eaca8a31c49467dc5df6c3e28b9ba689fc07108'; +check('§3.2 transfer preimage matches JSON.stringify bytes', transactionPreimage(transferPayload) === transferPreimage); +check('§3.2 transfer txid matches vector', transactionPayloadHash(transferPayload) === transferTxid); +check('§3.3 transfer signature matches vector', sign(transferTxid, wallet.privateKey) === transferSig); +check('§3.3 transfer signature verifies', verify(transferSig, transferTxid, PUB_HEX)); + +const messagePayload = { + from: ADDRESS, + to: TO, + amount: 0, + fee: 1, + nonce: 1, + timestamp: TIMESTAMP, + memo: 'gm', + burn: 5, +}; +const messagePreimage = + '["0x79b5562e8fe654f94078b112e8a98ba7901f853ae695bed7e0e3910bad049664","0xabababababababababababababababababababababababababababababababab",0,1,1,1700000000000,"gm",5]'; +const messageTxid = 'bb451006fa4c197f7944346de8f24e9712c8d1fd90e47abf52f8b9fa2b52cd06'; +const messageSig = + '817ccc45061524d52b8f1fc41f0b3542498993679c5e73a9497f60421dd0f7c19ea1837de981dedcd20301e2ea0d2076c029a6249c0e9b832f24962ae7972104'; +check('§3.2 message preimage includes burn only when >0', transactionPreimage(messagePayload) === messagePreimage); +check('§3.2 message txid matches vector', transactionPayloadHash(messagePayload) === messageTxid); +check('§3.3 message signature matches vector', sign(messageTxid, wallet.privateKey) === messageSig); +check('§3.3 message signature verifies', verify(messageSig, messageTxid, PUB_HEX)); + +check('§3.2 JSON escaping matches ECMA JSON.stringify', JSON.stringify(['x"y\nz\t🎲']) === '["x\\"y\\nz\\t🎲"]'); + +const B_PRIV_HEX = '2122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f40'; +const B_ADDRESS = '0xe7f162a10bec559afea195e4dce84b69568d5d2cb0963eb446c0685e2b17f2f0'; +const shared = '22dd9afeb5878d76b7b7eba66e349a1a00858963745f1b92b78a1741e9ccf249'; +const bWallet = Wallet.fromPrivateKeyHex(B_PRIV_HEX); +check('§8.6 B seed derives B address', bWallet.address === B_ADDRESS); +check('§8.6 A->B shared key matches vector', bytesToHex(memoSharedKey(wallet.privateKey, B_ADDRESS)) === shared); +check('§8.6 B->A shared key matches vector', bytesToHex(memoSharedKey(bWallet.privateKey, ADDRESS)) === shared); + +const fixedMemo = 'ENC|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa6359b5d168414e050a885e42c9dc6eabf98ecbaea44fa9'; +const encrypted = encryptMemoWithNonce('hi 🔐', B_ADDRESS, wallet.privateKey, hexToBytes('aa'.repeat(24))); +check('§8.6 fixed nonce encrypted memo matches vector', encrypted === fixedMemo); +check('§8.6 recipient decrypts fixed memo', decryptMemo(encrypted, ADDRESS, bWallet.privateKey) === 'hi 🔐'); +check('§8.6 sender decrypts own fixed memo', decryptMemo(encrypted, B_ADDRESS, wallet.privateKey) === 'hi 🔐'); + +console.log(failed === 0 ? '\n🎉 CLIENT-PROTOCOL vectors all passed\n' : `\n💥 ${failed} vector checks failed\n`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/light-sync-test.ts b/scripts/light-sync-test.ts index e28e8ea..75262dd 100644 --- a/scripts/light-sync-test.ts +++ b/scripts/light-sync-test.ts @@ -2,9 +2,16 @@ import { rmSync } from 'node:fs'; import { Wallet, + clientBalanceOf, + clientNonceOf, verifyHeaderChain, verifyTxInclusionProof, findTxInclusionProof, + queryRecent, + recentSyncWindow, + replayAddressProofs, + replayClientState, + verifyLightSyncSnapshot, type TxInclusionProof, } from '../packages/core/src/index.js'; import { V0idNode, startHttpApi } from '../packages/node/src/index.js'; @@ -73,6 +80,19 @@ const recentRes = await fetchJSON<{ blocks: any[] }>( ); check('GET /recent 同时满足最近块数与时间窗口', recentRes.status === 200 && recentRes.body.blocks.length <= 3); check('GET /recent 不返回早于 minTimestamp 的块', recentRes.body.blocks.every((b) => b.timestamp >= minTimestamp)); +check( + 'client-protocol 可验证 headers + recent snapshot', + verifyLightSyncSnapshot({ + headers: headersRes.body.headers, + recentBlocks: recentRes.body.blocks, + maxBlocks: 3, + minTimestamp, + }).ok, +); + +const defaultWindow = recentSyncWindow(1_700_000_000_000); +check('client-protocol 默认 recent 窗口 = 10000 blocks + 3 days', defaultWindow.maxBlocks === 10_000 && defaultWindow.minTimestamp === 1_699_740_800_000); +check('client-protocol QUERY_RECENT builder 使用窗口参数', JSON.stringify(queryRecent({ maxBlocks: 3, minTimestamp })) === JSON.stringify({ type: 'QUERY_RECENT', maxBlocks: 3, minTimestamp })); const proofRes = await fetchJSON(`http://127.0.0.1:7811/tx-proof?txid=${txid}`); check('GET /tx-proof 返回证明', proofRes.status === 200); @@ -83,6 +103,13 @@ const addrRes = await fetchJSON<{ proofs: TxInclusionProof[] }>( ); check('GET /address-proofs 返回地址相关交易证明', addrRes.status === 200 && addrRes.body.proofs.some((p) => p.tx.txid === txid)); check('address proofs 每条都可验证', addrRes.body.proofs.every(verifyTxInclusionProof)); +const replayedProofs = replayAddressProofs(recipient.address, addrRes.body.proofs, headersRes.body.headers); +check('client-protocol 可用地址 proofs 回放余额', replayedProofs.ok && replayedProofs.value.balance === node.bc.balanceOf(recipient.address)); +check('client-protocol 地址 proofs 回放 nonce', replayedProofs.ok && replayedProofs.value.nonce === node.bc.nonceOf(recipient.address)); + +const replayedFull = replayClientState(node.bc.chain); +check('client-protocol 全块回放余额与节点一致', clientBalanceOf(replayedFull, recipient.address) === node.bc.balanceOf(recipient.address)); +check('client-protocol 全块回放 nonce 与节点一致', clientNonceOf(replayedFull, recipient.address) === node.bc.nonceOf(recipient.address)); const ws = new WebSocket('ws://127.0.0.1:6811'); await new Promise((resolve) => { From 4ad82435990bd81ec53d73ab927ce8d8f1db2744 Mon Sep 17 00:00:00 2001 From: Soren Qin Date: Thu, 2 Jul 2026 08:55:32 -0700 Subject: [PATCH 2/2] fix client header difficulty validation --- packages/core/src/client-protocol.ts | 11 ++++++++++- scripts/light-sync-test.ts | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/src/client-protocol.ts b/packages/core/src/client-protocol.ts index 6009388..bc01c7c 100644 --- a/packages/core/src/client-protocol.ts +++ b/packages/core/src/client-protocol.ts @@ -1,5 +1,5 @@ import type { Block } from './block.js'; -import { genesisBlock } from './blockchain.js'; +import { expectedDifficulty, genesisBlock } from './blockchain.js'; import { CHECKPOINTS, NULL_ADDRESS } from './config.js'; import { isValidAddress } from './crypto.js'; import { blockHeader, blockMerkleRootMatches, type BlockHeader, type TxInclusionProof, verifyHeaderChain, verifyTxInclusionProof } from './light.js'; @@ -138,6 +138,15 @@ export function verifyClientHeaders( const checked = verifyHeaderChain(headers); if (!checked.ok || checked.work === undefined) return { ok: false, error: checked.error ?? 'header 链校验失败' }; + if (headers[0].index === 0) { + for (let i = 0; i < headers.length; i++) { + const expected = expectedDifficulty(headers as unknown as Block[], i); + if (headers[i].difficulty !== expected) { + return { ok: false, error: `#${headers[i].index} 难度不符(期望 ${expected})` }; + } + } + } + const byHeight = new Map(headers.map((h) => [h.index, h])); for (const cp of opts.checkpoints ?? CHECKPOINTS) { const h = byHeight.get(cp.index); diff --git a/scripts/light-sync-test.ts b/scripts/light-sync-test.ts index 75262dd..ed453aa 100644 --- a/scripts/light-sync-test.ts +++ b/scripts/light-sync-test.ts @@ -12,6 +12,8 @@ import { replayAddressProofs, replayClientState, verifyLightSyncSnapshot, + verifyClientHeaders, + calcHeaderHash, type TxInclusionProof, } from '../packages/core/src/index.js'; import { V0idNode, startHttpApi } from '../packages/node/src/index.js'; @@ -90,6 +92,15 @@ check( }).ok, ); +const loweredDifficultyHeaders = headersRes.body.headers.map((h) => ({ ...h })); +if (loweredDifficultyHeaders.length > 1) { + const last = loweredDifficultyHeaders[loweredDifficultyHeaders.length - 1]; + last.difficulty = 0; + last.hash = calcHeaderHash(last); +} +const loweredDifficultyCheck = verifyClientHeaders(loweredDifficultyHeaders); +check('client-protocol 拒绝私自降低难度的 header 链', !loweredDifficultyCheck.ok && loweredDifficultyCheck.error.includes('难度不符')); + const defaultWindow = recentSyncWindow(1_700_000_000_000); check('client-protocol 默认 recent 窗口 = 10000 blocks + 3 days', defaultWindow.maxBlocks === 10_000 && defaultWindow.minTimestamp === 1_699_740_800_000); check('client-protocol QUERY_RECENT builder 使用窗口参数', JSON.stringify(queryRecent({ maxBlocks: 3, minTimestamp })) === JSON.stringify({ type: 'QUERY_RECENT', maxBlocks: 3, minTimestamp }));