# GENART-L0 · 作品包格式

**状态**：草案 v0.1 · 2026-10-19
**范围**：只定义「一件作品在链上占哪些字节」。不定义市场、不定义版税、不定义索引器实现。

---

## 0. 为什么先定这一层

上层（协议规则、索引器、查看器、市场）都可以改版本、可以替换、可以多实现。

**这一层不能。** 铭刻上链的字节永远在那里。用一套糟糕的布局发了一百件作品，它们就永远长那样，而且会成为后来者的先例。

所以这一层必须先收敛，而且必须用真作品验证过再冻结。

---

## 1. 术语

| 词 | 含义 |
|---|---|
| **generator** | 一段自包含的代码，给定 seed 必然产出同一个结果 |
| **seed** | 绑定到单次铸造的字节串，任何人都能从链上重算 |
| **piece** | 一个指向 generator 的引用 + 一个 seed。**它不带图** |
| **manifest** | 一个系列的声明，铭刻一次，之后不可变 |
| **content source** | 提供铭文原始字节的服务（见 §8） |

一件作品 = `generator + seed + params`。图像是这个三元组的**后果**，不是它的内容。

---

## 2. 三个铭文

```
① collection manifest   application/json, ≤ 512 B
② generator code        text/html, 自包含, ≤ 512 KB
③ mint                  application/json, ≤ 512 B
```

①② 由艺术家铭刻一次，③ 每件作品一条。

**为什么 manifest 和代码分开？**
manifest 需要被人和索引器频繁读取（供应量、价格、艺术家地址），代码则可能很大。分开之后，索引器只需要读 512 字节就能判断一个系列的全部规则，不必解析几十 KB 的 HTML。

---

## 3. 运行时契约（最重要的一节）

generator 是一个 HTML 文档，运行在**沙箱 iframe** 里，`sandbox="allow-scripts"`，无同源权限。

宿主与作品之间只有三条消息：

```
宿主 → 作品   { type: "genart:render", seed: string, params: object }
作品 → 宿主   { type: "genart:rendered", seed, features: object, bytes: number }
作品 → 宿主   { type: "genart:error", message: string }
```

作品在 `load` 之后向 `parent` 发 `{ type: "genart:ready" }`，宿主收到后再发 render。

### 硬性要求

1. **禁止不确定性来源**：不得使用 `Math.random`、`Date`、`crypto.getRandomValues`、`performance.now` 参与构图。
2. **禁止网络**：不得 `fetch`、`XMLHttpRequest`、加载外部 `<script src>`。查看器会注入 CSP 强制这一点。
3. **禁止持久化**：不得使用 `localStorage` / `indexedDB` / cookie。
4. **序列化必须稳定**：所有进入输出的数字必须先过 `r2(n) = round_half_up(n × 100) / 100`，再转成十进制字符串，且整数值不带小数点（`1000`，不是 `1000.0`）。浮点尾数差异会直接破坏哈希一致性。
5. **不得依赖宿主环境**：字体、设备像素比、窗口尺寸都不能影响输出。画布用固定 `viewBox`。

第 4 条是最容易踩的坑，而且有两个独立的坑。

**坑一：`toFixed` 不是这个语义。** `(2.675).toFixed(2)` 得到 `"2.67"`，而 `r2(2.675)` 得到 `2.68` —— 因为 2.675 的二进制表示略小于 2.675，乘 100 后恰好是 267.5，half-up 进位。**规范用的是 half-up，不是 toFixed，也不是 Python 的 round（half-to-even）。** `r2(0.025) = 0.03`，banker's rounding 会给 0.02。

**坑二：数字转字符串不是语言默认行为。** `Math.round(n*100)/100` 之后仍可能出现 `0.30000000000000004`，所以拼进字符串前必须经过统一的格式化函数；同时 JS 的 `String(1000)` 是 `"1000"`，Python 的 `repr(1000.0)` 是 `"1000.0"`。这两件事都会让字节对不上，而画面看起来完全正常。

这两条不靠读代码能发现，只能靠向量。见 §10。

### 验证方式

`node scripts/verify.mjs` 会做四组检查：同一 seed 在**全新 vm 上下文**里渲染两次是否字节一致、64 个 seed 是否产出 64 个不同结果、traits 是否与实际像素一致、产物里是否出现了禁用 API。

「全新上下文」是本地能做到的最接近「另一台机器」的近似——但只是近似，它换的仍然是同一个 JS 引擎。真正的跨运行时验证靠 §10 的向量集：另有一份 Python 实现逐字节复现了全部输出。

---

## 4. manifest 字段

```json
{
  "p": "genart",
  "v": 1,
  "op": "collection",
  "name": "Ink Field",
  "entry": "abc…i0",
  "code_hash": "sha256:8072f870…2cf7",
  "code_bytes": 5642,
  "code_encoding": "identity",
  "libraries": [],
  "supply": 64,
  "price": 5000000,
  "artist": "bc1p…",
  "collab": [],
  "start_height": 0,
  "seed_mode": "future_block",
  "maturity": 6,
  "runtime": "js-deterministic"
}
```

| 字段 | 必填 | 说明 |
|---|---|---|
| `p` / `v` / `op` | 是 | 协议标识与版本 |
| `name` | 是 | 系列名，展示用 |
| `entry` | 是 | generator 代码铭文的 ID |
| `code_hash` | 是 | 见 §5 |
| `code_bytes` | 是 | 代码字节数，供索引器预检 |
| `code_encoding` | 是 | `identity` 或 `gzip`。**推荐 identity**，见 §7 |
| `libraries` | 否 | 依赖的库铭文，见 §6 |
| `supply` | 是 | 版数上限，`0` 表示不限 |
| `price` | 是 | 单价，单位 sats |
| `artist` | 是 | 收款地址 |
| `collab` | 否 | `[{addr, bps}]`，与 artist 合计不超过 10000 |
| `start_height` | 是 | 开售区块高度 |
| `seed_mode` | 是 | 目前只定义 `future_block` |
| `maturity` | 是 | 承诺区块的偏移，推荐 6 |
| `runtime` | 是 | `js-deterministic` / `wasm-pinned` / `cloud-api` |

`runtime` 不是装饰。索引器对 `cloud-api` 必须标记 `verified: false`——那类作品的输出依赖外部服务，无法独立复现。

---

## 5. 哈希定义

```
code_hash = "sha256:" + hex( SHA-256( 解码后的字节 ) )
```

- `code_encoding = identity` 时，解码后 = 原样字节
- `code_encoding = gzip` 时，先 gunzip 再算

**算解码后的内容，不算铭文信封。** 这样同一份代码无论用不用 gzip，哈希都一样——换编码不改变身份。

`entry` 指向的铭文内容必须与 `code_hash` 一致，否则索引器判定该系列无效。

---

## 6. 分片与依赖

### 分片

代码超过单笔铭文上限时按顺序切成 N 片，每片一个铭文，`entry` 指向第一片，manifest 里加：

```json
"chunks": ["abc…i0", "abc…i1", "abc…i2"],
"chunk_hash": "sha256:…"
```

`chunk_hash` 是**拼接后**的完整内容哈希。索引器必须验证：按 `chunks` 顺序拼接 → 计算哈希 → 与 `chunk_hash` 和 `code_hash` 都一致。

**建议不要分片。** 512 KB 以内单铭文可以解决绝大多数作品，分片会让铸造失败率、索引复杂度和用户等待时间同时上升。真需要大资源时，用 §6.2 的引用。

### 依赖：递归铭文当链上 CDN

```html
<script src="/content/def456…i0"></script>
```

库只需铭刻**一次**，所有作品共享。manifest 里声明并锁定哈希：

```json
"libraries": [
  { "id": "p5", "inscription": "def456…i0", "hash": "blake2b:9a1c…" }
]
```

查看器加载时校验 `hash`，不一致就拒绝渲染。这比 npm 方案更彻底——npm 会下架包，铭文不会。

**注意**：`/content/` 是 ord 的端点。§8 会说明它现在的问题。

---

## 7. 种子派生

```
seed = SHA-256( block_hash(start_height + maturity) ‖ collection_id ‖ token_index )
```

`collection_id` 用 manifest 的铭文 ID，`token_index` 是该笔有效铸造的序号（从 0 开始）。

### 为什么不用交易哈希

用 txid 做种子在比特币上是**可以被 grind 的**：txid 完全由输入输出决定，买家换个找零地址就得到全新 txid，离线批量试算成本接近零。能本地渲染的人可以在广播前试几千个变体，挑一个自己喜欢的。

承诺未来区块解决了这个问题——`block_hash(start_height + maturity)` 在铸造时还不存在，**连艺术家也无法预筛**。

代价是铸造后有约 3 分钟（6 块 × 30 秒）的「未揭示」窗口。这是设计意图，不是故障。

---

## 8. 内容分发：当前最大的风险

**2026-10 实测：Fractal 官方 ord 索引服务已退役。**

`ordinals.fractalbitcoin.io` 现在返回一份 Service Notice，原文：

> The ord index service that previously powered ordinals.fractalbitcoin.com has been retired and will no longer receive updates or maintenance.

退役原因是「数据量增长过快、难以跟上最新区块高度」。官方指向 Fractal Standard Indexing Service 与 `uniscan.cc/fractal`。

**这意味着「用公共 ord 端点渲染」这条路目前走不通。** 内容来源只有三种：

| 方案 | 可行性 | 代价 |
|---|---|---|
| 自建 `fractal-bitcoin/ord` + 全节点 | 唯一不依赖第三方的路 | 2 TB 磁盘 + 运维 |
| UniSat 内容接口 | 可用但未验证（本次网络无法连通） | 商业依赖、限流、政策可变 |
| 自建轻量内容服务（只取自己协议的铭文） | 折中 | 仍需节点读区块 |

所以 **P0 必须包含「跑通一台 ord 节点」**，这不是规模化才需要的东西，是第一步就需要的东西。

查看器因此把 content source 做成可切换的：本地文件 / 自建 ord / 第三方 API，三者同一个接口。

### 内容源必须原样返回字节

实测中踩到一条：把作品放到静态托管上之后，`work.html` 取回来的哈希对不上本地。原因是托管平台往 HTML 响应里注入了一段统计脚本（`beacon.cdn.qq.com`），字节数从 5,721 变成 6,296。

**这不是小事。** 只要内容源会改写字节，`code_hash` 校验就永远失败，整个可验证性就没了。

推论：

- **内容源的选择标准不是"能访问"，是"不改字节"。** 静态对象存储、ord 的 `/content/`（按铭刻时的 content-type 原样返回）满足；带注入的托管平台不满足。
- 在会注入 HTML 的平台上，用非 HTML 的 content-type 存放同样的字节（本仓库同时产出 `work.txt`）可以绕过，但这只是权宜——它说明你依赖的仍然是别人的转发行为。
- 自建 ord 节点在这里又赢一次：它返回的就是铭文里的字节，没有任何中间层可以改。

---

## 9. 索引器判定（L1 摘要，下一阶段）

manifest 有效 → 铸造铭文有效 → 排序 → 配额。规则细节留到 P1，这里只锁定三条不能变的：

1. **付款可验证**：同一笔交易里必须有输出付给 `artist` 且 ≥ `price`。索引器能看完整交易，这是它比脚本强的地方。
2. **排序确定**：`(block_height, tx_index, inscription_index)` 升序，前 `supply` 个有效。
3. **终局性**：6 块后确认，重组时回滚重算。

---

## 10. 一致性测试（规范性）

`conformance/vectors.json` 是这一层的规范性附件，和本文档同等效力。**复现不了它，就不算实现了这件作品。**

### 10.1 为什么需要向量

「同一份代码在任何机器上产出同一张图」是一句声明。声明不能验证，向量可以。

一件生成艺术品的渲染方不止一个：艺术家本地预览、查看器、索引器生成缩略图、第三方转售页面、几年后的归档工具。只要其中任何一个的字节不一样，`code_hash` 的信任链就断了。而这些实现不会都跑在同一个 JS 引擎上。

### 10.2 四层向量

| 层 | 数量 | 内容 | 用来定位什么 |
|---|---|---|---|
| `primitives.round2` | 17 | 数字 → 舍入结果 | 舍入语义错了 |
| `primitives.num2str` | 11 | 数字 → 字符串 | 序列化错了 |
| `primitives.state` | 23 | seed → PRNG 的四个 uint32 | 哈希/位运算错了 |
| `vectors` | 23 | seed → `sha256(SVG)` + traits + 字节数 | 整体错了 |

分层的意义是**报错的位置**。只有最后一层时，一个 7 KB 的 SVG 对不上，你只知道「错了」；有了前三层，你能直接看出是第 3 个随机数的第 2 位错了。

### 10.3 只有作品级向量是不够的（实测）

`conformance/port/mutations.py` 会故意引入典型移植错误，检查向量能不能抓到。第一次跑的结果：

```
SURVIVED  banker's rounding          —
```

**23 个作品向量全部放过了把 `Math.round` 换成 Python `round` 的实现。** 原因是没有一个种子恰好让 `n × 100` 落在 `.5` 上——这种平局在真实输入里几乎不出现，但它决定了实现的正确性。

这就是 `primitives.round2` 存在的原因，里面专门放了 `0.025`、`0.005`、`0.125` 三个精确平局。

现在的变异测试结果（5/5 全部被抓）：

| 变异 | 抓到它的层 |
|---|---|
| half-up 换成 half-to-even | `round2` 3 组 |
| 按码点而非 UTF-16 码元迭代 | `state` 3 组、`vectors` 3 组（全是 emoji 种子） |
| 去掉 cyrb128 的收尾混合 | `state` 23 组、`vectors` 23 组 |
| 数字打印成 `1000.0` | `num2str` 5 组、`vectors` 21 组 |
| `>>>` 写成 `>>` | `state` 23 组、`vectors` 23 组 |

**结论：一致性测试必须同时包含原语级和作品级向量。** 只测端到端会漏掉一整类错误，而这类错误恰好是移植时最容易犯的。

### 10.4 三个真实踩到的移植陷阱

1. **舍入**：JS `Math.round` 是 half-up，Python `round` 是 half-to-even，`toFixed` 又是第三种行为。`r2(2.675) = 2.68`，而 `(2.675).toFixed(2)` 是 `"2.67"`。
2. **字符串**：JS 按 UTF-16 码元迭代（`charCodeAt`），Python 按码点。emoji 在 JS 里是 2 个码元、在 Python 里是 1 个码点，哈希循环的迭代次数因此不同。
3. **数字转字符串**：`String(1000)` 是 `"1000"`，`repr(1000.0)` 是 `"1000.0"`。这个差异会出现在每一个 `d=` 和 `cx=` 属性里。

生产环境的 seed 恒为 64 位小写十六进制（§7），所以陷阱 2 在真实输入里不会触发。向量集里的中文和 emoji 种子是**压力测试**，专门用来暴露这类实现差异，不代表真实输入。

### 10.5 如何验证一个实现

```bash
node scripts/conformance.mjs                 # 检查 src/ink-field.js
node scripts/conformance.mjs path/to/impl.js # 检查任何暴露 render/features 的模块
python conformance/port/check.py             # 检查独立的 Python 实现
python conformance/port/mutations.py         # 检查向量集本身有没有漏洞
```

`conformance/port/ink_field.py` 是第二实现：只用 Python 标准库，与 JS 版本不共享任何代码。当前 **74/74 项逐字节一致**（17 round2 + 11 num2str + 23 state + 23 artwork）。

它证明的比它看起来少：同一个作者写的两个实现，可能只是把同一个误解写了两遍。它证明的是**运行时无关**——两套浮点格式化、两套整数语义、两套字符串模型，产出同一串字节。至于「另一个没读过这段代码的人能不能实现」，向量比人更可靠，但最终仍需要真人来试。

### 10.6 向量钉住的是作品，不是格式

这 23 个向量描述的是 **Ink Field 这一件作品**，不是 L0 格式本身。换一个 generator，向量全部作废，必须重新生成。

所以每件作品都应该带自己的向量集。当前它放在仓库里，理想状态是**和 manifest 一起铭刻**，让第三方不必信任任何网站就能验证——这条写进了 §11。

---

## 11. 未决问题

- **`params` 要不要进哈希？** 当前 `seed` 只绑定种子，`params` 由铸造者在同一笔交易里传入。如果 `params` 影响构图（本实现里不影响），它必须被纳入 piece 的身份定义，否则同一 token 可以有多个合法输出。
- **`start_height = 0` 的含义**：当前实现里 0 表示「立即开售」，但和「从创世块开始」字面冲突，需要改成显式的 `null` 或 `open`。
- **分片顺序的证明**：`chunks` 数组由 manifest 声明，但数组本身可能被伪造。是否需要一个独立的「拼接证明」铭文，待定。
- **`runtime: cloud-api` 的展示规范**：需要约定 UI 上如何标注，否则等于没标。
- **向量集要不要进 manifest？** 见 §10.6。加一个 `vectors: <inscription_id>` 字段能让验证完全脱离网站，代价是 manifest 从 512 B 涨到约 580 B，且多一笔铭刻。倾向加，等 P1 定 schema 时一起改。
