Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/blockchain/CLIENT-PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 金标准向量)。

---

Expand Down Expand Up @@ -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 是**存在证明**,不是“无遗漏证明”。如果钱包不拉全链,最好向多个节点交叉请求同一地址历史;真正的单节点余额证明需要未来共识层加入状态承诺。

---
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
249 changes: 249 additions & 0 deletions packages/core/src/client-protocol.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import type { Block } from './block.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';
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<string, number>;
nonces: Map<string, number>;
}

export interface ClientAddressReplay {
address: string;
balance: number;
nonce: number;
proofs: TxInclusionProof[];
}

export type ClientProtocolResult<T> = { 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);
Comment on lines +70 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Match client replay to consensus escrow payouts

For chains that include red-packet or staking transactions, this transfer-only accounting diverges from the consensus state machine in Blockchain.applyTx: CLAIM|, REFUND|, and UNSTAKE| have amount === 0 but credit payouts from escrow, and SLASH| moves stake to the treasury, while this helper only deducts the sender fee and credits tx.to by zero. Any wallet/indexer using the new replayClientState()/clientBalanceOf() helpers on full blocks will show incorrect balances after those already-supported transaction types appear.

Useful? React with 👍 / 👎.

}

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 链校验失败' };
Comment on lines +138 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce consensus difficulty for headers

For light clients this delegates to verifyHeaderChain(), which only checks that each hash satisfies the difficulty value embedded in that header; it never recomputes the consensus expectedDifficulty from the prior headers. After the last checkpoint, a peer can mine a fork with artificially lowered difficulty (even 0), pass verifyClientHeaders(), and then serve recent/proof data from a chain full nodes would reject. The client protocol verifier needs to reject headers whose difficulty is not the consensus value before trusting the tip/work.

Useful? React with 👍 / 👎.

Comment on lines +138 to +139

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject headers beyond the local future-drift limit

This header verifier omits the consensus timestamp guard from Blockchain.validateChain() that rejects blocks with timestamp > Date.now() + MAX_FUTURE_DRIFT_MS. A peer can therefore serve a header chain containing far-future blocks that full nodes would reject, and the light client will still trust that tip and any recent blocks/proofs anchored to it. Apply the same future-drift check while walking headers before returning ok.

Useful? React with 👍 / 👎.


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);
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<BlockHeader> {
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 不一致` };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare the full block header

This only compares the returned block's Merkle root to the trusted header, leaving the other header fields in the full block object unchecked. Because verifyRecentBlocks() tests block.timestamp before calling this, a peer can take an older block, change its timestamp into the requested window while keeping hash and merkleRoot copied from the real header, and still pass this verifier. Compare the entire blockHeader(block) to the matched header, or recompute the hash from the full block header, before accepting it.

Useful? React with 👍 / 👎.

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)} 自洽性失败` };
Comment on lines +168 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-consensus coinbase layouts

Recent blocks are accepted after only per-transaction self-checks, but verifyTransaction() allows any coinbase with a positive amount and does not enforce that there is exactly one coinbase in position 0 or that its amount is BLOCK_REWARD + fees. A peer that can mine an otherwise valid-looking header can include extra or overpaying coinbase transactions, this helper will accept the block, and replayClientState() will credit fabricated rewards that full nodes reject in Blockchain.validateChain().

Useful? React with 👍 / 👎.

Comment on lines +168 to +169

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject non-coinbase NULL_ADDRESS outputs

verifyTransaction() intentionally only checks transaction self-consistency, so a signed non-coinbase transfer with to === NULL_ADDRESS passes here even though full nodes reject it in Blockchain.validateChain(). If such a transaction appears in a served recent block, this verifier accepts the block and replayClientState() treats the amount as credited to the null address outside the burn path, producing state from a chain the network will not accept.

Useful? React with 👍 / 👎.

}
return { ok: true, value: header };
}

export function verifyRecentBlocks(
headers: BlockHeader[],
blocks: Block[],
window: Partial<RecentWindow> = {},
): ClientProtocolResult<Block[]> {
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 };
Comment on lines +179 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require the complete recent-block suffix

When verifying a QUERY_RECENT snapshot, this accepts any subset of eligible blocks: an empty recentBlocks array, or one with a recent block omitted, passes as long as the returned blocks are not too many and not too old. Because the headers already reveal the tip height and timestamps, this should require the exact suffix headers.slice(-maxBlocks).filter(h => h.timestamp >= minTimestamp) to be present; otherwise a node can omit a recent block containing the wallet's transaction and the client will treat the stale snapshot as verified.

Useful? React with 👍 / 👎.

}

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<TxInclusionProof> {
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<ClientAddressReplay> {
if (!isValidAddress(address)) return { ok: false, error: 'address 必须是合法地址' };

let balance = 0;
let nonce = 0;
const seen = new Set<string>();
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)} 自洽性失败` };
Comment on lines +229 to +232

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Require headers before trusting address proofs

When callers omit headers, this path accepts a Merkle proof against the proof's self-supplied block header; verifyTxInclusionProof() does not check PoW, checkpoints, or whether that header is on the verified chain. A wallet that replays ADDRESS_PROOFS without separately passing the header chain can be given fabricated proofs and an arbitrary balance. Require verified headers here, or return an explicitly untrusted result when they are absent.

Useful? React with 👍 / 👎.

}

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 } };
}
21 changes: 15 additions & 6 deletions packages/core/src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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|<hex>` 串,直接当 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|<hex>` 串,直接当 memo 上链。 */
export function encryptMemo(plaintext: string, recipientAddress: string, senderPrivateKey: Uint8Array): string {
return encryptMemoWithNonce(plaintext, recipientAddress, senderPrivateKey, randomBytes(24));
}

/**
* 解密一条 `ENC|` 私信。otherPartyAddress = 对方地址(我是收件人→填发件人;我是发件人→填收件人)。
* 用我的私钥还原同一共享密钥。失败(非本人/被篡改/格式坏)返回 null。
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading