记录
Label 309 的线上格式——记录在元数据标签 309 下的存放位置、它的 map 结构、规范 CBOR 规则、传输分块,以及 CDDL 模式。
一条 Label 309 记录就是一个承载在 label 309 下的 Cardano 交易元数据中的 CBOR map。这个 map 把一个或多个内容哈希提交到链上;交易所在区块的区块时间即是见证,证明那些字节存在的时间不晚于那一刻。记录还能携带的其余一切——存储 URI、加密信封、作者身份签名、取代指针——都只是围绕这一核心主张的可选元数据。
本页定义记录在线上的形态:它存放在何处、如何编码、超长值如何传输,以及结构校验器据以检查的那套封闭模式。这里引用的密码学构造(哈希算法、密封信封、签名)各有专页介绍;本页讲的是线上格式。
记录存放在何处
一条 PoE 记录 MUST 放在交易元数据标签 309 之下,该标签在
CIP-10 元数据标签注册表
中被保留为「Proof of Existence record」。交易元数据是一个从整数标签到值的 map,因此一笔交易 MUST NOT 携带多于一条 PoE 记录——每笔交易恰好一条记录。
一笔交易 MAY 在其他标签下携带额外的元数据(例如一条
CIP-20
的 674 消息)。处理 PoE 的验证器 MUST 忽略除 309 以外的每一个标签。
在 Conway 时代的账本上,交易元数据是交易的 auxiliary_data 里的 metadata 字段。任何标签下允许出现的值都被约束为账本的递归 metadatum 类型——整数、字节串、文本串、数组和 map,其中字节串与文本串各自上限均为 64 字节:
metadatum =
{ * metadatum => metadatum }
/ [ * metadatum ]
/ int
/ bstr .size (0..64)
/ tstr .size (0..64)任何携带单个超过 64 字节的 bstr 或 tstr 的交易,会在提交时被 Cardano 节点拒绝,验证器根本看不到它。正是这条上限,促使 Label 309 定义了一套传输分块规范(见下文);记录携带的每个字段——无论是基础字段还是扩展字段——都必须能归约为一个 metadatum。
传输:整体分块数组
一条序列化后的记录体动辄超过 64 字节,因此无法作为一个裸值存放在 label 309 之下。于是记录体被承载为一个不透明的整体分块数组:一个由 ≤ 64 字节字节串(bstr .size (1..64))组成的单一 CBOR 数组,其元素按顺序拼接就是记录体。这次传输切分是 Label 309 唯一执行的分块——也是这套格式里唯一真正由账本所强制的步骤。
由于账本只看到这个传输数组,从不看到重组后记录体内部的字段,那些字段就是普通的 CBOR 值,既没有逐字段的分块包装,也没有字段级的 64 字节上限:一个存储 URI 是单个文本串,一个 COSE_Sign1 是单个字节串,一个 X-Wing 的 kem_ct 是单个 1120 字节的字节串。一个超过 64 字节的字段,只会像记录体里其余任何一段那样,跨越整体数组的若干分块边界。
生产方 MUST 把记录体一次性序列化为规范 CBOR,把这个字节串切分成 1 到 64 字节的分块,再把得到的定长数组(其元素为定长字节串)作为 label-309 的值存放。数组这层包装始终是必需的,即便记录体只有 64 字节或更短也不例外:这样的记录体会是一个长度为 1 的数组,绝不会是一个裸 map 或裸字节串。生产方 SHOULD 采用最小切分(除最后一块外,每块都恰好 64 字节),并 SHOULD NOT 发出零长度分块;过度分块只会白白浪费交易字节、毫无好处。
验证器在做结构校验之前 MUST 按顺序把数组元素逐字节拼接以重组记录体,并 MUST 拒绝任何不是这种数组的 label-309 值。分块边界不带任何语义:两个拼接结果逐字节一致的传输数组,表示的是同一条记录。承载错误目录钉死了拒绝码——一个超过 64 字节的分块是 CHUNK_TOO_LARGE;一个非字节串的数组元素、一个不定长数组或元素,或一个非数组的 label-309 值(裸 map、裸字节串、整数)则是 MALFORMED_CBOR。一个零长度分块不贡献任何字节,会被容忍,绝不会仅因其本身而被拒绝。
模式描述的是重组后的记录体
下文的一切——记录 map、CDDL、字段规则——描述的都是分块重组之后的记录体。整体分块数组不属于模式;它会先被还原,之后才校验记录体。
记录 map
重组后的记录体是一个 CBOR map。整数值字段是 CBOR 主类型 0/1;文本字段是主类型 3,且 MUST 为合法的 UTF-8;字节字段是主类型 2;数组是主类型 4;嵌套 map 是主类型 5。一个存在的可选字段 MUST NOT 携带空值。
顶层结构如下:
| 键 | 类型 | 状态 | 含义 |
|---|---|---|---|
v | uint | REQUIRED | 模式版本;本文档定义 v = 1。 |
items | item map 数组 | OPTIONAL | 每项内容的承诺——见内容与哈希。 |
merkle | 承诺数组 | OPTIONAL | 把链下叶子列表绑定到单个根的列表承诺。 |
supersedes | bytes (32) | OPTIONAL | 本记录所取代的某条先前记录的交易哈希。 |
sigs | 签名 map 数组 | OPTIONAL | 记录级作者身份签名——见签名。 |
crit | 文本串数组 | OPTIONAL | 必须被理解的扩展键。 |
一条合规记录 MUST 至少承诺 items(含 ≥ 1 项)或 merkle(含 ≥ 1 项)二者之一。两者都不携带——或把其中之一以空数组形式携带——的记录会被作为空记录拒绝。除这条规则外,items 与 merkle 互不相干:一条记录可以只带其中之一,也可以两者都带。
Label 309 不对条目数量设任何数值上限。唯一的天花板是 Cardano 当下的最大交易大小,而生产方按字节付费的机制本身就自然地约束了记录大小。校验器 MUST NOT 仅因一条记录携带许多条目就拒绝它,只要它能放进账本的大小限制之内即可。
版本字段
v 是一个 CBOR 无符号整数,而非语义版本字符串。本文档恰好定义了 v = 1。校验器 MUST 以一个带类型的错误拒绝 v 超出其支持集合的记录;它 MUST NOT panic、中止,或悄悄把该记录当成另一种元数据模式来处理。v 整数只在某项变更会导致 v1 解析器误解记录时才递增——可加性的、带命名空间的扩展不会让它递增。
Items
items 中的每一项都是一个 CBOR map,含一个必需字段和两个可选字段:
hashes——REQUIRED,一个从哈希算法标识符到原始 32 字节摘要的非空 map。至少一项;由于 CBOR map 键唯一,重复算法在结构上不可能出现。见内容与哈希。uris——OPTIONAL,一个发现用 URI 的复数列表(规则见下文)。enc——OPTIONAL,密封项的加密信封。见密封 PoE。
不存在逐项的签名槽。作者身份只在记录级表达,由一条一致覆盖每一项的 sigs[] 项来体现。
Merkle 承诺
merkle 中的每一项通过一种规范的哈希树构造,把记录绑定到一个有序的 32 字节叶子列表,使链上的单个 32 字节根能够代表任意大的链下叶子列表。一条承诺是一个封闭的 map:
| 字段 | 类型 | 状态 | 含义 |
|---|---|---|---|
alg | tstr | REQUIRED | 已注册的列表承诺算法标识符。 |
root | bytes (32) | REQUIRED | 在生产方有序叶子列表之上计算出的规范根。 |
leaf_count | uint | REQUIRED | 已承诺的叶子数量;把根绑定到列表规模。 |
uris | URI 列表 | OPTIONAL | 链下叶子列表文件的内容寻址 URI。 |
Merkle 根承诺的是一个叶子列表结构,而 hashes 项承诺的是明文字节;两者的验证方式不同(包含性证明 vs 明文重算),这正是列表承诺位于顶层、而不在某个条目内部的原因。列表承诺注册表与内容哈希注册表互不相交——见算法注册表。
Supersedes
supersedes 是一个可选的 32 字节 Cardano 交易哈希,指向一条更早的 Label 309 记录。它是一种不依赖任何服务、只增不改的链接:后一条记录可以指向先前的记录,而无需任何链下数据库或厂商记录 ID。
取代并不移除、撤销或使先前记录失效——链是只增不改的,验证器 MUST 继续把更早的记录视为存在且可独立验证。该指针不带任何理由或自由文本字段;任何人类含义(更正、替换、撤回)都应落在新的内容里,而非 label 309 中。解析该指针的验证器 MUST 在与所在交易相同的 Cardano 网络上查找它;该字段不带网络判别符,因为一个交易哈希只在它自己的网络内唯一。
签名
sigs 是一个可选的记录级签名项数组。每一项携带一个覆盖记录体的分离式 COSE_Sign1 结构——也就是去掉 sigs 之后的完整记录 map——并在钱包签名路径下可选地携带签名者的公钥。单个签名见证整个记录体:每一项、每个 URI、每个信封、取代指针(若存在),以及任何扩展键。签名始终是可选的,且无法识别的签名算法绝不会使内容主张失效。被签名的载荷、域分隔前缀、签名者密钥的解析,以及严格的验证规则,都在签名中规定。
URI 规则
uris 出现时是一个非空列表;每一项是单个携带恰好一个 URI 的 CBOR 文本串。这里没有逐 URI 的长度上限,也没有任何包装形态:整体传输已经满足了账本的 64 字节字符串上限,因此一个很长的 ipfs://<CIDv1>/<path> URI 就和其他任何文本串一样,是单个文本串。重建出的 URI MUST 是绝对的、MUST 包含 scheme 与层级部分,且 MUST NOT 含有片段标识符——一条 PoE 是关于内容字节的主张,而非关于文档某个子组件的主张。
v1 的 scheme 集合是封闭且内容寻址的:
| Scheme | 说明 |
|---|---|
ar:// | Arweave 交易 ID(43 字符 base64url)。形式为 ar://<txid>。 |
ipfs:// | IPFS CID,推荐 CIDv1。形式为 ipfs://<cid> 或 ipfs://<cid>/<path>。 |
生产方 MUST NOT 发出任何其他 scheme——https://、http://、file://、data: 等等一律被拒。这一限制是刻意为之、而非临时之举:内容寻址 URI 通过存储层的完整性模型把取回的字节与 URI 本身绑定起来(一个 IPFS CID 是内容的 multihash;一个 Arweave 交易 ID 在 Arweave 共识下对数据作出承诺),因此验证器无需信任 DNS、TLS、网关或证书颁发机构,就能确认「我取回的字节正是生产方所承诺的字节」。集合之外的 scheme 会使记录在结构上无效;它绝不会被判为 valid。
uris 自始至终都是可选的。一条省略了 uris 的纯哈希记录是一个完整的主张——它在不承诺任何取回渠道的情况下断言内容的存在。确切的 CID 规格(接受的 multibase 前缀、编解码器和 multihash)属于验证规则的一部分;见验证。
规范 CBOR
每一条 Label 309 记录 MUST 按 RFC 8949 §4.2.1(核心确定性编码)编码为规范 CBOR。具体而言:
- 每个整数都采用首选(最短形式)序列化。
- 所有字节串、文本串、数组和 map 均采用定长编码。
- 不使用语义标签(本文档不需要任何标签——bignum 标签 2/3 MUST NOT 出现)。
- map 键按其 CBOR 编码的逐字节字典序排序。
- UTF-8 文本串,不带字节序标记。
- 任何 map 中均不得有重复键。
- 不出现浮点数或非平凡的简单值——一条记录只携带整数、字节串、文本串、数组、map,以及(在某个模式允许它的地方)
true/false/null。主类型 7 的浮点数(包括整数形式的1.0)、负零和undefinedMUST 被拒绝,而非被强制转换。
正是确定性让这一格式可互操作:两个生产方表达同一条逻辑记录时会发出逐字节相同的字节,因此由一个实现在记录体之上算出的签名能在另一个实现下通过验证。校验器 MUST 拒绝非规范的编码。区块浏览器和钱包可以通过一种 JSON 投影来呈现元数据,但合规的验证器 MUST 校验原始的交易 CBOR,绝不能校验它的某种有损 JSON 重编码。
向前兼容
Label 309 v1 保留了一组封闭的基础键:v、items、merkle、supersedes、sigs、crit。一条记录 MAY 额外携带名称匹配以下两个保留命名空间之一的扩展键:
^x-.+——厂商 / 实验命名空间。^[a-z]+-.+——配套规范命名空间,其前缀标明进行注册的规范。
校验器 MUST 解码并保留扩展键,MUST NOT 仅因为它们存在就拒绝一条记录,并 MUST 以信息性方式呈现它们,同时不声称已验证其内容。扩展键属于被签名的记录体,因此一个记录级签名会覆盖它们——中继方无法在签名生成之后再注入一个扩展键。任何两个模式都不匹配的未知顶层键(如拼写错误的 supersedess,或大小写变体 Sigs)会被作为未知字段拒绝。这种基于模式的宽容,在保留对基础集合的拼写错误检测的同时,为未来的新增项保持一个稳定的开放池。
如果生产方要求验证器理解某个非基础字段,它 MUST 在顶层 crit 数组中列出该字段的名称。v1 验证器遇到一个它未实现的 crit 项时 MUST NOT 把该记录报告为有效。每个 crit 项 MUST 匹配扩展键模式(crit 中禁止出现基础键)、MUST 命名一个确实存在于记录中的字段,且 MUST 唯一——这样一个关键性标记总能追溯到一个具体字段,验证器有义务理解该字段的语义。这些规则沿用了 RFC 9052 §3.1(COSE crit)和 RFC 7515 §4.1.11(JWS crit)中关于「必须理解 / 必须忽略」的先例。
字节预算
对记录大小唯一的硬性天花板是 Cardano 当下的 maxTxSize 协议参数——在主网协议主版本 10 下为 16 384 字节,并受账本参数更新的约束。Label 309 不在其之下设任何模式级的上限。超过该限制的记录会在提交时被 Cardano 节点拒绝,因此没有验证器会看到这样的记录;校验器 MUST NOT 凭空发明一个低于 maxTxSize 的、Label 309 专有的天花板。
实践中,一笔交易的非元数据结构(输入、输出、见证、手续费与有效性字段)大约占用 245 字节,给 label-309 记录留下约 16 KB 的空间。生产方 SHOULD 把目标定在限制之下数百字节处以吸收手续费波动,并 SHOULD 在提交前计算候选记录的大小,若放不下就尽早失败。能够放下的现实形态相当宽裕:一百多个单哈希条目、数十个记录级签名,或许多经典接收方槽,都能舒舒服服地装进一笔交易——而单个 Merkle 根以固定的 32 字节链上成本,就能承诺一个无界的链下叶子列表。
CDDL 模式
下面的 CDDL 是重组后记录体的结构模式——即把存放在 label 309 下的 ≤ 64 字节分块数组拼接之后得到的规范 CBOR 字节。重组后的记录体就是普通的确定性 CBOR:它本身并不是一个账本 metadatum,它的字段也不受那条 64 字节字符串上限的约束——那条上限仅由整体传输包装来满足。该包装不在此建模。
该块描述的是良构形态的宽松超集;跨字段不变式(items-或-merkle 规则、加密信封的 slots ⊕ passphrase 互斥性、算法标识符的注册表归属、逐 KEM 的槽形规则)由对解码后结构的带类型校验过程来强制执行,而非由 CDDL 本身。
; An extension value is any CBOR value the canonical (deterministic) encoding
; profile admits. Floats and semantic tags are excluded by that profile (they
; are rejected as MALFORMED_CBOR on decode), so the exclusion is not repeated
; here; the reassembled body carries no field-level 64-byte cap.
extension-value =
{ * extension-value => extension-value }
/ [ * extension-value ]
/ int
/ bstr
/ tstr
/ bool
/ null
; A conformant record MUST carry at least one of `items` (>= 1 entry) or
; `merkle` (>= 1 entry); a record with both absent (or both empty) is rejected
; as SCHEMA_EMPTY_RECORD by the typed pass, not at the CDDL layer.
poe-record = {
poe-common,
? "items": [ 1* item-entry ],
? "crit": [ 1* tstr ],
* extension-key => extension-value
}
poe-common = (
"v": 1,
? "merkle": [ 1* merkle-commit ],
? "supersedes": bytes32,
? "sigs": [ 1* sig-entry ],
)
extension-key = tstr .regexp "^x-.+"
/ tstr .regexp "^[a-z]+-.+"
item-entry = {
"hashes": hash-map,
? "uris": [ 1* uri ],
? "enc": enc,
}
; A non-empty CBOR map keyed by a content-hash algorithm identifier with the
; 32-byte digest as value. Map-key uniqueness makes duplicate algorithms
; structurally impossible.
hash-map = { + content-hash-alg => bytes32 }
; A list commitment binds the record to an ordered leaf list. `leaf_count`
; binds the on-chain commitment to the off-chain list size.
merkle-commit = {
"alg": merkle-commit-alg,
"root": bytes32,
"leaf_count": uint32,
? "uris": [ 1* uri ],
}
; `enc` is a choice between the scheme-1 envelope shape and a bounded opaque
; envelope (the degrade-to-opaque rule for an unsupported scheme/kem/aead). The
; typed pass enforces the slots/passphrase exclusivity and the per-KEM
; slot-shape rules over a supported envelope.
enc = enc-scheme-1 / enc-opaque
; `scheme: 1` is not a version counter for the `enc` map alone: it names the
; ENTIRE sealed cryptographic suite — the canonicalEncode rules, the slot
; schema, the HKDF and HMAC hashes, the wrap AEAD, the segmented-STREAM content
; format, the transcript schemas, the in-ciphertext passphrase commitment, the
; pinned X-Wing revision, every domain-separation label, and the Argon2id and
; passphrase-normalization profiles. Changing any one of them requires a new
; `scheme` value; see Sealed PoE for the construction it pins.
enc-scheme-1 = {
"scheme": 1,
"aead": aead-alg,
"nonce": bstr,
? "kem": kem-alg,
? "slots": [ 1* slot ],
? "slots_mac": bytes32,
? "passphrase": passphrase-block,
}
; The opaque reading of an envelope under an unsupported identifier: `scheme`
; is the only structurally required key, and every other entry is any key/value
; pair the canonical profile admits, subject to the generic decode bounds.
enc-opaque = {
"scheme": uint,
* tstr => extension-value
}
slot = classical-slot / hybrid-slot
; enc.kem = "x25519": the per-slot X25519 ephemeral public key + wrapped CEK.
classical-slot = {
"epk": bytes32,
"wrap": bytes48,
}
; enc.kem = "mlkem768x25519": the 1120-byte X-Wing ciphertext plus the wrapped
; CEK. There is NO `epk` — the X25519 ephemeral is the trailing 32 bytes of the
; X-Wing ciphertext inside `kem_ct`.
hybrid-slot = {
"kem_ct": bstr .size 1120,
"wrap": bytes48,
}
passphrase-block = {
"alg": kdf-alg,
"salt": bstr .size (16..64),
"params": { "m": uint32, "t": uint32, "p": uint32 },
}
; A signature entry is a closed map. `cose_sign1` is REQUIRED and carries the
; CBOR-encoded COSE_Sign1 as a single byte string; `cose_key` is OPTIONAL and
; carries the CBOR-encoded COSE_Key for the wallet-signing path as a single
; byte string.
sig-entry = {
"cose_sign1": bstr,
? "cose_key": bstr,
}
; A uri is one absolute URI in a single text string. The URI shape rules
; (absolute, no fragment, closed scheme set {ar://, ipfs://}) are enforced in
; the typed pass; the rule carries no length cap.
uri = tstr
bytes32 = bstr .size 32
bytes48 = bstr .size 48
; uint32 is the pinned range of every numeric field: an unsigned integer
; representable in 4 bytes (0 .. 2^32-1), handled as an exact integer.
uint32 = uint .size 4
; Algorithm-identifier strings are open `tstr`: the registries are
; authoritative for accepted values, and the typed pass emits the precise
; unsupported-algorithm code for any unrecognised identifier.
content-hash-alg = tstr ; e.g. "sha2-256", "blake2b-256"
merkle-commit-alg = tstr ; e.g. "rfc9162-sha256"
aead-alg = tstr
kem-alg = tstr
kdf-alg = tstr