@trustbaseai/protocol
v0.1.0
Published
TrustBase 跨侧唯一事实来源:链消息 type URL 注册表、bundle_hash 规范化、WS 查询协议、端点广播/探测协议、跨侧常量
Maintainers
Readme
@trustbaseai/protocol
TrustBase 的跨侧唯一事实来源:链上 / 索引器 / PWA 三方共用的类型、常量与纯函数。
- 零副作用、零 Node 内建依赖:Node 与浏览器同一份代码、同一份结果
- 只有类型 + 纯函数 + 常量;不含私钥、不签名、不联网、不落盘
- 运行时依赖只有
@noble/hashes(sha256 与 hex/utf8 工具)
npm install @trustbaseai/protocolimport {
canonicalize,
endpointBundleHashHex,
getMsgType,
validateQueryRequest,
} from '@trustbaseai/protocol';1. 为什么先做这个包
SDK 里最能"静默失败"的东西都在这儿:
| 症状 | 根因 | 本包的防线 |
|---|---|---|
| unable to resolve type URL /trustchain.order.v1.MsgXxx | type URL 写错/模块未上线 | registry 逐条核对 proto,带 文件:行号 出处 |
| 交易被拒但报错像"权限不足" | signer 注释与字段填错位 | 每个 message 标注 cosmos.msg.v1.signer 指名字段 |
| 所有见证票被拒、链上永远没票 | 三方对同一个 bundle 算出不同 hash | canonicalize + 金标准向量(换算法 = 破坏兼容) |
| 浏览器里 ws 查询参数被拒 | 客户端与索引器上限不一致 | WS_QUERY_LIMITS 与索引器实现同值,测试锁死 |
| 页面在浏览器里崩(Node API) | 误引 Node 内建模块 | test/isomorphic.test.ts 静态扫描守门 |
2. bundle_hash 规范化(红线,冻结成一份)
bundle_hash = sha256( utf8( canonicalize(bundle) ) ) // 32 字节链上 x/endpoint 只校验两件事:长度是 32 字节、且等于当前指针里的哈希
(x/endpoint/keeper/msg_server.go:44(轮换) / :142(见证) 长度校验,:161 哈希相等)。
链码不强制算法本身 —— 所以一旦三方算不一致,症状是
「所有见证票被拒、链上永远没票」,而链上只会回一句看起来像"对方写错"的错误。
规范化规则 trustbase-canonical-json-v1
| # | 规则 |
|---|---|
| 1 | 只接受 JSON 数据模型:null / boolean / 整数 / 字符串 / 数组 / 纯对象 |
| 2 | 对象键按 Unicode 码点升序 |
| 3 | 数字必须是整数且 |n| <= 2^53-1;NaN / ±Infinity / 浮点 / -0 / 超大整数一律抛错 |
| 4 | 字符串按 UTF-8 编码;非 ASCII 不转义;只转义 " \ 与 C0 控制符;拒绝落单代理项 |
| 5 | 数组保序;拒绝稀疏数组与数组上的非下标属性 |
| 6 | 对象只读 enumerable own 属性;拒绝 accessor (getter)、symbol 键 |
| 7 | 输出无任何空白 |
| 8 | 拒绝 undefined / bigint / symbol / function / 循环引用 / 深度 > 64 |
与 JSON.stringify 的三个差异(都是刻意的,都是"静默不一致"的入口):
- 不调用
toJSON()—— 遇到就报错,而不是把对象悄悄换成别的东西 - 不做
-0 → 0的静默归一 —— 直接拒(否则"谁先归一"决定哈希) - 不丢
undefined键 —— 直接拒(丢键会让"少写一个字段"看起来像正常数据)
跨语言实现要点:Go 的 sort.Strings 与 Python 的 sorted() 天然是码点序;
JS 默认 sort() 是 UTF-16 码元序,对 emoji 这类增补平面字符会排错
(本包提供 compareByCodePoint,并用金标准向量 unicode-key-order 把这个坑锁死)。
函数签名
function canonicalize(value: unknown): string; // 不满足规则 → 抛 CanonicalizationError
function endpointBundleHash(bundle: unknown): Uint8Array; // 32 字节,喂给链上 bytes 字段
function endpointBundleHashHex(bundle: unknown): string; // 64 位小写 hex,线协议用
function announcementSigningPayload<T extends { sig?: unknown }>(announce: T): Omit<T, 'sig'>;
function announcementHashHex(announce: unknown): string; // = 去掉 sig 后取 hash
function isBundleHashHex(value: unknown): value is string; // 严格:必须全小写
function normalizeBundleHashHex(value: string): string; // 宽松:大小写都收 → 归一为小写
function bundleHashFromHex(value: string): Uint8Array; // hex → 32 字节
function bundleHashToHex(bytes: Uint8Array): string; // 32 字节 → hex线协议里 hash 用 64 位小写 hex(与链侧 credential_hash / evidence_hash 同格式);
链上 bytes 字段的 wire JSON 是 base64 —— 发链前记得转,这是本仓库真机踩过的坑
(routes/chain-writes-products.js:205)。
⚠️ 换算法 = 破坏兼容
链上只比对字节,不做算法协商。任何一方换算法(换规范化规则 / 换哈希 / 换编码)
都必须链上 + 索引器 + PWA 三方同时升级,并 bump CANONICALIZATION_VERSION
(该值应进协议握手与广播包版本;旧包直接拒收)。
3. 金标准向量
test-vectors/bundle-hash.golden.json —— 冻结的常量,不是运行时算出来的对照。
测试对它做三层校验:
- 逐条比
canonical与sha256_hex - 独立复算:用
node:crypto对同一份规范化字符串再算一次 sha256(绕开@noble/hashes) - 关系断言:键序不同 → 必须同 hash;数组反序 → 必须不同 hash
| id | 分组 | 说明 |
|---|---|---|
| key-order-a / key-order-b | key-order-equivalence | 内容相同、键序不同 → 同 hash |
| nested | nested-objects | 每层都排序,数字/布尔/null 正确序列化 |
| array-order-asc / array-order-desc | array-order-preserved | 数组保序 → 反序必须不同 hash |
| non-ascii | non-ascii | 中文字段不转义 |
| unicode-key-order | unicode-key-order | 码点序而非 UTF-16 码元序(锁住 emoji 排序坑) |
| empty-collections | empty-collections | 空数组 / 空对象 / 空字符串互不混淆 |
| boundary-length | boundary-length | 4096 字符字符串 + 单字符键 |
| realistic-announce / -reordered | realistic-announce + 键序等价 | 真实广播包形态 + 整包键序打乱仍同 hash |
重新生成向量的脚本是 tools/gen-bundle-hash-vectors.cjs,故意不挂进 npm scripts:
必须显式敲路径才跑得到,且会打印醒目警告。因为测试红了才去刷向量 = 让三方静默分裂。
4. 链消息注册表
MSG_TYPES 收录商家/买家/服务商实际要发的消息(gov-only 的 MsgUpdateParams 不收)。
每条给:typeUrl、protoMessage、signer、fields[](名/类型/是否必填)、
source(proto 文件:行号)、provenBy(真机跑通的参考实现,可选)。
字段 kind 与 wire 形态:
uint32/uint64/int64→ JSON 里是字符串bytes→ JSON 里是 base64(不是 hex)enum→ JSON 里是枚举名(如PRODUCT_TYPE_PHYSICAL)
出处可机器校验(不是手抄):
npm run build
node tools/check-proto-citations.cjs /path/to/trustchain-relmerge # 校验
node tools/check-proto-citations.cjs /path/to/trustchain-relmerge --fix # 收紧行号它逐条核对 message 名、字段名、字段类型、cosmos.msg.v1.signer 注解,并做反向完整性检查
(proto 里有、注册表漏登记 = 失败)。该工具不在 npm test 里:proto 不在本仓库
(SDK 开源、链码单仓),CI 跑不了不是 bug —— 有链码 checkout 的维护者手动跑。
已收录(33 条,按 proto 核对,快照 trustchain-relmerge@40d1654 + 2026-09-23 工作区改动):
/trustchain.order.v1.MsgCreateOrder
/trustchain.order.v1.MsgConfirmPayment
/trustchain.order.v1.MsgSettleOrder
/trustchain.order.v1.MsgTransferCredits
/trustchain.order.v1.MsgShipOrder
/trustchain.order.v1.MsgConfirmOrder
/trustchain.order.v1.MsgCancelOrder
/trustchain.order.v1.MsgDisputeOrder
/trustchain.order.v1.MsgResolveDispute
/trustchain.marketplace.v1.MsgListSKU
/trustchain.marketplace.v1.MsgAddStock
/trustchain.marketplace.v1.MsgReduceStock
/trustchain.marketplace.v1.MsgUpdateSKU
/trustchain.marketplace.v1.MsgPauseSKU
/trustchain.marketplace.v1.MsgResumeSKU
/trustchain.marketplace.v1.MsgDelistSKU
/trustchain.marketplace.v1.MsgConfiscateStake
/trustchain.verification.v1.MsgSubmitVerification
/trustchain.verification.v1.MsgApproveVerification
/trustchain.verification.v1.MsgRejectVerification
/trustchain.verification.v1.MsgRevokeVerification
/trustchain.verification.v1.MsgVerifyByGovernance
/trustchain.serviceprovider.v1.MsgRegisterServiceProvider
/trustchain.serviceprovider.v1.MsgSubmitPaymentFact
/trustchain.serviceprovider.v1.MsgConfirmPaymentFact
/trustchain.serviceprovider.v1.MsgClaimProviderReward
/trustchain.insurance.v1.MsgSubmitClaim
/trustchain.reward.v1.MsgRewardPoolTransfer
/trustchain.endpoint.v1.MsgUpdateEndpointPointer
/trustchain.endpoint.v1.MsgAttestEndpoint
/trustchain.subchain.v1.MsgFundBootstrap
/trustchain.subchain.v1.MsgDisburseBootstrap
/trustchain.economy.v1.MsgGrantCreditssigner 别读成"签名者身份"。signer 只表示"签名写在这条消息的哪个字段里";
谁被允许签由 keeper 判定。典型例子:MsgConfirmOrder / MsgDisputeOrder 的 signer
是 submitter,但 submitter 可以是订单卖家、params.dispute_authority(平台服务商),
或超时兜底后的任意账户 —— 三条路径见 registry.ts 里这两条的注释与
x/order/keeper/order_submit_auth.go。
function getMsgType(typeUrl: string): MsgTypeSpec | undefined;
function getMsgTypeByProtoMessage(protoMessage: string): MsgTypeSpec | undefined;
function isKnownMsgType(typeUrl: unknown): typeUrl is string;建议用法:每次构造交易前过一遍 getMsgType(typeUrl)。返回 undefined 就先当作
「type URL 拼错 or 链上模块未上线」处理,别把二进制丢进广播 ——
真机上这两种情况的报错信息长得一模一样。
5. 协议类型(WS 查询 / 端点广播 / 探测背书)
5.1 索引器 WS 查询协议
已有实现的镜像(trustchain-relmerge/indexer/src/core/websocket.ts 与
ws-query-provider.ts),不是新设计。三条容易踩的语义:
events优先于topics(老字段兼容策略;两个都给时按events算)- 非法 topic 是"过滤"不是"报错" —— 服务端照样回
subscribed, 以响应里的events(当前全部订阅集合)为准 - 连接建立即默认全订
block/transaction/product/order;subscribe是增量语义
validateClientMessage(input): Validation<WsClientMessage>;
validateSubscribeRequest(input): Validation<ValidatedSubscriptionRequest>;
validateUnsubscribeRequest(input): Validation<ValidatedSubscriptionRequest>;
validateQueryRequest(input): Validation<ValidatedQueryRequest>;
validateQueryArgs(name, args): Validation<WsQueryArgsByName[name]>;
validateServerMessage(input): Validation<WsServerMessage>;
extractTopics(request): string[];校验失败不抛异常,返回 { ok: false, error, message },其中 error 与索引器实际回的错误码同值
(invalid_id / unknown_query / invalid_params / …),客户端一套分支就能同时处理本地预检与服务端回包。
上限常量与索引器同值(改一个就得改另一边):
| 常量 | 值 | 出处 |
|---|---|---|
| maxQueryIdLength | 128 | ws-query-provider.ts:23 |
| maxLimit / defaultLimit | 100 / 20 | :18-19 |
| maxOffset | 1 000 000 | :20 |
| maxKeywordLength / maxIdLength | 200 | :21-22 |
| maxMessageBytes | 64 KiB | :24 |
| maxPendingQueries / queryTimeoutMs | 8 / 5000 | websocket.ts:70-71 |
5.2 端点广播包 / 探测背书包
validateEndpointAnnouncement(input): Validation<EndpointAnnouncement>;
validateProbeReport(input): Validation<ProbeReport>;
shouldAcceptAnnouncement(local, incoming, now?): AnnouncementAcceptance;
isEndpointDescriptor(input): input is EndpointDescriptor;- 两个校验函数通过时返回入参本身(同一引用),绝不重建对象 —— hash 是对原文算的,"顺手补个默认字段"就会让哈希对不上
- 不做密码学:
sig/pubkey只做结构校验;验签与"pubkey 是否等于链上登记身份" 属于@trustbase/identity/@trustbase/p2p(planned) announcementHashHex()定义的哈希域 = 签名域 =announce 去掉 sig, 探测背书包的endpoint_hash必须等于它(§6.4 第 2 层:防"签名留白、内容调包")- 接收判定:
issued_at + ttl_ms <= now→ 拒;本地无记录 → 收;seq > 本地 seq→ 收;否则拒(防重放 / 防回滚到旧端点)
诚实标注:
endpoint_announce/probe_report/ gossip topictrustbase/endpoints/v1在链侧仓库里尚无实现(grep 为空),属 P2 阶段落地。 本包把它们先冻结成契约,让索引器与 PWA 从一开始照同一份写。 其中"签名域"是新定义,不是已存在的事实。 相比之下x/endpoint的指针与见证是链上已实现的,bundle_hash的 长度与相等校验都是硬约束。
6. 常量
DEFAULT_CHAIN_ID // 'trustchain-1'
BECH32_PREFIX // 'tct'
DENOM_UTCT // 'utct'(基本 denom)
TCT_DECIMALS // 6
UTCT_PER_TCT // 1_000_000(1 TCT = 10^6 utct)
WS_QUERY_LIMITS // 见上表
WS_QUERY_NAMES // ['health','catalog','product','endpoints']
WS_TOPICS // ['block','transaction','product','order']
PointerState // ACTIVE / UNUSABLE / EXPIRED(=算出) / NONE(=无指针)
MAX_LATENCY_MS // 3_600_000(= x/endpoint/types/keys.go:44)
EndpointEventType // endpoint_pointer_updated 等 6 个链上事件名
GOSSIP_TOPIC_ENDPOINTS // 'trustbase/endpoints/v1'(待实现)EXPIRED 不落链,是按 rotated_at_height + ttl_blocks 实时算的;
UNUSABLE 保留不删除(可审计、可恢复)。出处:x/endpoint/keeper/keeper.go:168-174、
proto/trustchain/endpoint/v1/endpoint.proto:36-38。
7. 构建与测试
npm run build # tsc → dist/(CommonJS + .d.ts)
npm test # jest
npm run typecheck # tsc --noEmit当前产物是 CommonJS:Node 直接可用,浏览器经任一打包器可用。
源码本身零 Node 依赖,ESM 双产物排在 S1(见下)。
test/isomorphic.test.ts 会静态扫描 src/,出现 node: 导入 / process. / Buffer
即测试失败 —— 这条纪律靠测试守,不靠自觉。
维护者工具(不在 npm test 里)
| 工具 | 作用 | 何时用 |
|---|---|---|
| tools/check-proto-citations.cjs <repo> [--fix] | 拿真实 proto 逐条核对注册表:message 名 / 字段名 / 字段类型 / signer 注解 + 反向完整性(proto 有而注册表漏 = 失败)。行号漂移会判 FAIL,--fix 按 message 名在整份 proto 里重定位后重排(不会指错,因为名字是权威) | 改过 proto、或改过 registry.ts |
| tools/gen-bundle-hash-vectors.cjs | ⛔ 重新生成金标准向量 | 只有协议大版本升级时(测试红了别跑它) |
校验工具对空结果输出 all citations verified,失败逐条列 ✗。
它读的是 dist/ —— 改完源码必须先 npm run build 再跑,否则校验的是旧产物(这个坑踩过两次)。
本轮校对结果:33 条 type URL 全部通过(2026-09-23,含 D2/D3 两批改动)。
8. 尚未做(明写,别当已实现)
- ESM 产物:当前只有 CJS。浏览器原生
<script type="module">直接 import 尚不可用 (打包器可以)。 sig的密码学验证:只做结构校验。since_height语义:字段已定义,但索引器当前忽略它(真机未见实现)。- WS 清单文件 / 端口发现的客户端:常量已冻结(
ws-port.json、INDEXER_WS_PORT), 读取与发现逻辑属于@trustbase/index-client(planned)。 endpoint订阅 topic:设计文档示例里有,索引器尚未实现(PLANNED_WS_TOPICS)。MsgConfiscateStake无法作为交易提交:proto 里有 message,但service Msg没有对应 rpc (生成的MsgServer接口也没有该方法),keeper 里却已实现 handler —— 实际只能模块内部调用。 本包照实登记,不假装它能发。
9. 事实核对发现(不一致清单)
写这一版时逐文件核对 proto / 索引器 / 卖家后端,发现下面这些不一致。 它们不影响本包的正确性(本包按"链上真实行为"登记),但会坑到调用方,所以记在这里:
| # | 发现 | 证据 | 影响 / 应对 |
|---|---|---|---|
| 1 | chain-id 默认值有两个 | routes/chain-writes.js:58 = trustchain-1;index.js:334、run-relationship.js:22、run-unified.js:283,298、mcp/server.js:19 = trustchain-local;routes/order.js:151 的 CLI 示例又写 trustchain | 签名 chain-id 不一致 = 交易被拒。索引器 config/manager.ts:179 认定正确值是 trustchain-1,并在 :225-227 显式纠正历史误配。本包取 trustchain-1 |
| 2 | ~~MsgCreateOrder 的 signer 语义被注释写反~~ 已解决(2026-09-22 方案 A) | 链侧已把 proto 与 Go 统一到 seller(order/v1/tx.proto:46、x/order/types/signers.go:57-58),routes/chain-writes.js 的注释不再是错的 | 本包随之为 signer: 'seller'。但 D2 又改了另外三条(见 #9/#10),所以"signer 不一定是买家/卖家"这个直觉仍然要不得 |
| 3 | 服务商消息名与设计文档不一致 | 设计/口语里说 MsgSubmitFact / MsgConfirmFact;proto 实为 MsgSubmitPaymentFact / MsgConfirmPaymentFact(serviceprovider/v1/tx.proto:47,96) | 按口语写 type URL 必报 unable to resolve type URL。注册表用 proto 真名 |
| 4 | MsgConfiscateStake 有 message 无 rpc | marketplace/v1/tx.proto:119 有 message,:13-24 的 service Msg 没有 rpc;x/marketplace/types/tx.pb.go:1201-1210 的 MsgServer 接口无此方法,但 x/marketplace/keeper/confiscate.go:81 实现了 handler | 该消息发不出去(只能模块内部调 Keeper.ConfiscateStake)。谁要发它,先补 rpc |
| 5 | bytes 字段 wire 形态是 base64,仓库里存的是 hex | chain-writes-products.js:205 Buffer.from(hex,'hex').toString('base64') 后才发链 | 直接把索引器里的 hex content_hash 塞进交易 = 链上收到错误字节(不报错)。本包在字段 note 里标出来 |
| 6 | endpoint topic 只在设计文档里 | 索引器全仓 grep 无 endpoint 广播路径;默认订阅只有 block/transaction/product/order(websocket.ts handleConnection()) | 订阅 endpoint 不会报错,但永远收不到东西。见 PLANNED_WS_TOPICS |
| 7 | endpoint_announce / probe_report / gossip topic 尚无实现 | 链侧仓库 grep 无这三个名字 | 本包的这套类型是先冻结契约,不是既有事实;签名域属新定义 |
| 8 | 外部仓库行号会漂移 | 编写本包期间 indexer/src/core/websocket.ts 与 routes/chain-writes.js 都被其它人改过(行号移动了几十行);2026-09-23 又在 order/v1/tx.proto 上漂移了一轮 | 索引器/后端的引用一律以函数名/符号名为准;proto 的行号由 tools/check-proto-citations.cjs 机器校验(漂移会被判 FAIL,--fix 可按 message 名自动重排) |
| 9 | MsgConfirmOrder / MsgDisputeOrder 的 signer 字段从 seller 改名为 submitter(同字段号 5) | 链侧先加 seller = 5(2026-09-22 中间态),随后 D2 定稿为 submitter = 5(order/v1/tx.proto:90-98 / :109-117) | 对调用方是破坏性变更:字段号不变、类型不变(wire 层仍是 string),但字段名变了 —— 谁按旧名 seller 发,链上读到的 submitter 就是空值,签名者取不到地址,交易必被拒。必须与卖家后端同批升级。注册表已改为 submitter |
| 10 | signer 字段名 ≠ 签名者身份(本次最容易误读的一条) | MsgConfirmOrder / MsgDisputeOrder 的 submitter 有三条放行路径(订单卖家 / params.dispute_authority / 超时兜底后的任意账户),判定在 keeper x/order/keeper/order_submit_auth.go 的 authorizeOrderSubmit() | 任何"signer 一定是卖家"的客户端假设都会错。注册表这两条的注释里写明了三条路径;params.dispute_authority 是治理可调且空串 = 关闭的参数(order/v1/params.proto:36-39),客户端不能硬编码它 |
| 11 | signers.go 尾部注释与新代码自相矛盾 | x/order/types/signers.go:17-24 仍留着旧说明「DisputeOrder: actor is disputer」「keeper 应校验 disputer 是买家」,与同文件 :73-78 的实现(submitterAddr(m.Submitter))直接冲突 | 链侧文档债(本包不改链仓):读 signers.go 时以代码为准。已记录,建议链侧清掉旧注释 |
| 12 | MsgSubmitClaim.order_id 是"新增必填",不是兼容新增 | insurance/v1/tx.proto:77-90(D3 修复);Go x/insurance/types/tx.pb.go:494-505 同 | 老客户端不带 order_id(= 0)会被链上拒。理赔入口必须同批升级,否则用户侧表现为"提交失败"而无明显原因 |
| 13 | Claim 状态类型在 query.proto,不在 tx.proto | insurance/v1/query.proto:14 的 message Claim;D3 新增字段在同文件 :28(uint64 order_id = 12)与 :34(string beneficiary = 13);insurance/v1/insurance.proto 里只有 InsuranceApplication / InsurerStake / SKUDeclaration | 按 insurance.proto 找 Claim 是找不到的。本包注册表只收 Msg* 交易消息,故未收录 Claim(它是查询侧状态类型)。另注:beneficiary 老数据为空串时回退 claimant(升级前的历史索赔)—— 读链上数据的一方要处理这个回退,别把空串当"无赔付对象" |
| 14 | x/insurance 只收录了 1 条消息 | 注册表里 trustchain.insurance.* 仅 MsgSubmitClaim(D3 修复点);同模块另有 9 条(MsgRequestInsurance / MsgConfirmInsurance / MsgCancelInsuranceApplication / MsgResolveClaim / MsgDepositInsuranceStake / MsgWithdrawInsuranceStake / MsgDeclareSKU / MsgRevokeInsurance / MsgUpdateParams)未收录 | 本包不假装覆盖整个保险模块。要做保险买家/卖家侧 SDK 时需先补齐(有 MSG_TYPES 的反向完整性检查在,补的时候不会漏字段) |
