@ckb-firewall/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +11 -6
  2. package/dist/commands/anchor.d.ts +23 -0
  3. package/dist/commands/anchor.d.ts.map +1 -0
  4. package/dist/commands/anchor.js +412 -0
  5. package/dist/commands/anchor.js.map +1 -0
  6. package/dist/commands/config.d.ts +4 -0
  7. package/dist/commands/config.d.ts.map +1 -0
  8. package/dist/commands/config.js +59 -0
  9. package/dist/commands/config.js.map +1 -0
  10. package/dist/commands/execute.d.ts +9 -1
  11. package/dist/commands/execute.d.ts.map +1 -1
  12. package/dist/commands/execute.js +394 -229
  13. package/dist/commands/execute.js.map +1 -1
  14. package/dist/commands/import.d.ts.map +1 -1
  15. package/dist/commands/import.js +15 -48
  16. package/dist/commands/import.js.map +1 -1
  17. package/dist/commands/inspect.d.ts.map +1 -1
  18. package/dist/commands/inspect.js +32 -0
  19. package/dist/commands/inspect.js.map +1 -1
  20. package/dist/commands/proposals.d.ts.map +1 -1
  21. package/dist/commands/proposals.js +2 -4
  22. package/dist/commands/proposals.js.map +1 -1
  23. package/dist/commands/propose.d.ts +5 -0
  24. package/dist/commands/propose.d.ts.map +1 -1
  25. package/dist/commands/propose.js +91 -7
  26. package/dist/commands/propose.js.map +1 -1
  27. package/dist/commands/reclaim.d.ts +18 -0
  28. package/dist/commands/reclaim.d.ts.map +1 -0
  29. package/dist/commands/reclaim.js +214 -0
  30. package/dist/commands/reclaim.js.map +1 -0
  31. package/dist/commands/sign.d.ts.map +1 -1
  32. package/dist/commands/sign.js +40 -94
  33. package/dist/commands/sign.js.map +1 -1
  34. package/dist/commands/vote.d.ts +2 -0
  35. package/dist/commands/vote.d.ts.map +1 -1
  36. package/dist/commands/vote.js +31 -25
  37. package/dist/commands/vote.js.map +1 -1
  38. package/dist/index.js +91 -16
  39. package/dist/index.js.map +1 -1
  40. package/dist/lib/blkl.d.ts +17 -2
  41. package/dist/lib/blkl.d.ts.map +1 -1
  42. package/dist/lib/blkl.js +133 -17
  43. package/dist/lib/blkl.js.map +1 -1
  44. package/dist/lib/capacity.d.ts +12 -0
  45. package/dist/lib/capacity.d.ts.map +1 -0
  46. package/dist/lib/capacity.js +18 -0
  47. package/dist/lib/capacity.js.map +1 -0
  48. package/dist/lib/config.d.ts +7 -0
  49. package/dist/lib/config.d.ts.map +1 -0
  50. package/dist/lib/config.js +35 -0
  51. package/dist/lib/config.js.map +1 -0
  52. package/dist/lib/defaults.d.ts +19 -0
  53. package/dist/lib/defaults.d.ts.map +1 -1
  54. package/dist/lib/defaults.js +34 -7
  55. package/dist/lib/defaults.js.map +1 -1
  56. package/dist/lib/governance-v4.d.ts +39 -0
  57. package/dist/lib/governance-v4.d.ts.map +1 -0
  58. package/dist/lib/governance-v4.js +194 -0
  59. package/dist/lib/governance-v4.js.map +1 -0
  60. package/dist/lib/gui-bundle.html +485 -316
  61. package/dist/lib/gui-server.d.ts.map +1 -1
  62. package/dist/lib/gui-server.js +300 -246
  63. package/dist/lib/gui-server.js.map +1 -1
  64. package/dist/lib/hints.d.ts +1 -1
  65. package/dist/lib/hints.d.ts.map +1 -1
  66. package/dist/lib/hints.js +3 -9
  67. package/dist/lib/hints.js.map +1 -1
  68. package/dist/lib/proposals.d.ts +17 -9
  69. package/dist/lib/proposals.d.ts.map +1 -1
  70. package/dist/lib/proposals.js +4 -26
  71. package/dist/lib/proposals.js.map +1 -1
  72. package/dist/lib/rpc.d.ts +5 -0
  73. package/dist/lib/rpc.d.ts.map +1 -1
  74. package/dist/lib/rpc.js +21 -0
  75. package/dist/lib/rpc.js.map +1 -1
  76. package/dist/lib/treasury-status.d.ts +28 -0
  77. package/dist/lib/treasury-status.d.ts.map +1 -0
  78. package/dist/lib/treasury-status.js +70 -0
  79. package/dist/lib/treasury-status.js.map +1 -0
  80. package/dist/lib/treasury.d.ts +15 -0
  81. package/dist/lib/treasury.d.ts.map +1 -0
  82. package/dist/lib/treasury.js +62 -0
  83. package/dist/lib/treasury.js.map +1 -0
  84. package/dist/lib/tx-deps.d.ts +9 -0
  85. package/dist/lib/tx-deps.d.ts.map +1 -0
  86. package/dist/lib/tx-deps.js +15 -0
  87. package/dist/lib/tx-deps.js.map +1 -0
  88. package/dist/lib/witness.d.ts +13 -11
  89. package/dist/lib/witness.d.ts.map +1 -1
  90. package/dist/lib/witness.js +85 -48
  91. package/dist/lib/witness.js.map +1 -1
  92. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import { loadConfig } from "./config.js";
1
2
  import { createServer } from "node:http";
2
3
  import { join, dirname as pathDirname } from "node:path";
3
4
  import { existsSync, readFileSync } from "node:fs";
@@ -6,14 +7,21 @@ import { createRequire } from "node:module";
6
7
  const _require = createRequire(import.meta.url);
7
8
  const _cliVersion = _require("../../package.json").version;
8
9
  import { secp256k1 } from "@noble/curves/secp256k1.js";
9
- import { listProposals, loadProposal, saveProposal, getProposalsDir, computeProposalIdHash, computeVoteDigestHash, voteSigningMessage, signingMessage, isReviewWindowPassed, isVoteApproved, isReadyToExecute, countYes, SIG_THRESHOLD, REVIEW_WINDOW_MS, } from "./proposals.js";
10
+ import { listProposals, loadProposal, saveProposal, getProposalsDir, computeProposalIdHash, computeVoteDigestHash, voteSigningMessage, isReviewWindowPassed, isVoteApproved, countYes, REVIEW_WINDOW_MS, } from "./proposals.js";
10
11
  import { getLiveCell } from "./rpc.js";
11
12
  import { resolveRegistryOutpoint } from "./registry.js";
12
13
  import { parseRegistryPayload } from "@ckb-firewall/sdk";
13
- import { hexToBytes, bytesToHex, strip0x, encodeRegistryPayload, extractGovernanceHeaderRaw, parseGovernanceHeader, insertSorted, removeEntry, } from "./blkl.js";
14
+ import { hexToBytes, bytesToHex, strip0x, extractGovernanceHeaderRaw, parseGovernanceHeader, governanceTreasuryLockHash, scriptToMoleculeBytes, } from "./blkl.js";
14
15
  import { computeMerkleProof, verifyMerkleProof } from "./validator-set.js";
15
16
  import { TESTNET_GOVERNANCE_PUBKEYS, TESTNET_CONTRACT_OUTPOINTS, SECP256K1_DEP_GROUP, } from "./defaults.js";
16
- import { ckbBlake2b, buildGov1WitnessV3, buildGovernanceSigWitness, buildWitnessArgs, encodeAbsoluteTimestampSince, } from "./witness.js";
17
+ import { ckbBlake2b, buildGov1WitnessV4, buildValidatorVoteWitness, buildWitnessArgs, encodeRelativeTimestampSince, } from "./witness.js";
18
+ import { assertProposalCellMatches, assertProposalAnchorTypeMatches, encodeProposalCellData, loadRegistryStateForProposal, parseRegistryTypeIdValue, proposalCellDataHash, proposalV4Fields, } from "./governance-v4.js";
19
+ import { hexCapacity, occupiedCapacityShannons, parseCapacity } from "./capacity.js";
20
+ import { loadTreasuryStatus } from "./treasury-status.js";
21
+ const DEFAULT_FEE_SHANNONS = 100000n;
22
+ // Treasury-lock change outputs have 64-byte args — minimum occupied capacity is
23
+ // 8 (capacity field) + 117 (lock script molecule) = 125 CKB = 12,500,000,000 shannons.
24
+ const MIN_CHANGE_SHANNONS = 125n * 100000000n;
17
25
  // ── /api/data handler ─────────────────────────────────────────────────────────
18
26
  async function buildApiData(opts) {
19
27
  const proposals = listProposals();
@@ -22,10 +30,15 @@ async function buildApiData(opts) {
22
30
  const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
23
31
  const cell = await getLiveCell(opts.rpcUrl, txHash, index);
24
32
  const payload = parseRegistryPayload(cell.data);
33
+ const governanceHeaderRaw = extractGovernanceHeaderRaw(cell.data);
34
+ const governanceHeader = governanceHeaderRaw ? parseGovernanceHeader(governanceHeaderRaw) : null;
25
35
  registry = {
26
36
  txHash,
27
37
  index,
28
38
  version: payload.version,
39
+ threshold: governanceHeader?.threshold ?? null,
40
+ validatorCount: governanceHeader?.validatorCount ?? null,
41
+ treasury: await loadTreasuryStatus(opts.rpcUrl, cell, governanceHeader),
29
42
  error: null,
30
43
  entries: payload.entries.map((e) => ({
31
44
  identifier: e.identifier,
@@ -38,6 +51,9 @@ async function buildApiData(opts) {
38
51
  txHash: opts.registryTx,
39
52
  index: opts.registryIndex,
40
53
  version: 0,
54
+ threshold: null,
55
+ validatorCount: null,
56
+ treasury: null,
41
57
  error: err instanceof Error ? err.message : String(err),
42
58
  entries: [],
43
59
  };
@@ -78,6 +94,28 @@ function apiErr(res, msg, status = 400) {
78
94
  res.writeHead(status, { "Content-Type": "application/json" });
79
95
  res.end(JSON.stringify({ ok: false, error: msg }));
80
96
  }
97
+ function parseOutputIndex(value, name) {
98
+ const raw = String(value ?? "").trim();
99
+ if (!/^\d+$/.test(raw))
100
+ throw new Error(`${name} must be a non-negative integer.`);
101
+ return Number.parseInt(raw, 10);
102
+ }
103
+ function parseOptionalTxHash(value, name) {
104
+ const raw = String(value ?? "").trim();
105
+ if (!raw)
106
+ return undefined;
107
+ if (!/^0x[0-9a-fA-F]{64}$/.test(raw))
108
+ throw new Error(`${name} must be a 0x-prefixed 32-byte transaction hash.`);
109
+ return raw;
110
+ }
111
+ function extractProposalOutpoint(proposal, body) {
112
+ const txHash = parseOptionalTxHash(body.proposalTx, "proposalTx") || proposal.proposalCellTxHash?.trim();
113
+ const indexRaw = body.proposalIndex ?? (proposal.proposalCellIndex === undefined ? undefined : String(proposal.proposalCellIndex));
114
+ if (!txHash || indexRaw === undefined) {
115
+ throw new Error("Proposal cell outpoint is missing. Anchor the proposal cell first, then record its tx hash and output index.");
116
+ }
117
+ return { txHash, index: parseOutputIndex(indexRaw, "proposalIndex") };
118
+ }
81
119
  // ── /api/propose ──────────────────────────────────────────────────────────────
82
120
  function isValidHex(v) {
83
121
  const c = strip0x(v);
@@ -167,20 +205,18 @@ async function handleVote(body, opts) {
167
205
  throw new Error("Proposal already executed");
168
206
  if (proposal.status === "rejected")
169
207
  throw new Error("Proposal was rejected");
170
- if (proposal.signatures.length > 0)
171
- throw new Error("Cannot vote — signing has already begun");
172
208
  const pubkeyBytes = secp256k1.getPublicKey(pkBytes, true);
173
209
  const pubkey = bytesToHex(new Uint8Array(pubkeyBytes));
174
210
  const validatorSet = TESTNET_GOVERNANCE_PUBKEYS.map((k) => bytesToHex(k));
175
211
  const merkleResult = computeMerkleProof(validatorSet, pubkey);
176
212
  if (!merkleResult) {
177
213
  pkBytes.fill(0);
178
- throw new Error(`Key is not an authorized validator (pubkey: ${pubkey.slice(0, 20)}…)`);
214
+ throw new Error(`Key is not an authorized governance voter (pubkey: ${pubkey.slice(0, 20)}…)`);
179
215
  }
180
216
  const { proof: merkleProof, leafIndex: merkleLeafIndex } = merkleResult;
181
217
  if (proposal.votes.some((v) => v.pubkey.toLowerCase() === pubkey.toLowerCase())) {
182
218
  pkBytes.fill(0);
183
- throw new Error("This validator already voted on this proposal");
219
+ throw new Error("This governance voter already voted on this proposal");
184
220
  }
185
221
  const timestamp = new Date().toISOString();
186
222
  const msgHash = voteSigningMessage(proposal.proposalIdHash, choice, timestamp, pubkey);
@@ -198,70 +234,6 @@ async function handleVote(body, opts) {
198
234
  saveProposal(proposal);
199
235
  return { ok: true, yesCount, approved, status: proposal.status, pubkey };
200
236
  }
201
- // ── /api/sign ─────────────────────────────────────────────────────────────────
202
- async function handleSign(body, opts) {
203
- if (typeof body !== "object" || body === null)
204
- throw new Error("Invalid body");
205
- const b = body;
206
- const proposalId = String(b.proposalId ?? "").trim();
207
- if (!proposalId)
208
- throw new Error("proposalId required");
209
- const signerIndex = Number(b.signerIndex);
210
- if (!Number.isInteger(signerIndex) || signerIndex < 0 || signerIndex >= 5)
211
- throw new Error("signerIndex must be 0–4");
212
- const pkHex = String(b.privateKey ?? "").trim();
213
- let pkBytes;
214
- try {
215
- pkBytes = hexToBytes(pkHex);
216
- if (pkBytes.length !== 32)
217
- throw new Error("must be 32 bytes");
218
- secp256k1.getPublicKey(pkBytes);
219
- }
220
- catch (e) {
221
- throw new Error("Invalid private key: " + (e instanceof Error ? e.message : String(e)));
222
- }
223
- const proposal = loadProposal(proposalId);
224
- if (proposal.status === "executed")
225
- throw new Error("Already executed");
226
- if (proposal.status === "rejected")
227
- throw new Error("Proposal was rejected");
228
- if (!isReviewWindowPassed(proposal)) {
229
- const ms = new Date(proposal.reviewWindowEndsAt).getTime() - Date.now();
230
- const h2 = Math.floor(ms / 3_600_000), m = Math.floor((ms % 3_600_000) / 60_000);
231
- throw new Error(`Review window not passed yet — ${h2}h ${m}m remaining`);
232
- }
233
- if (!isVoteApproved(proposal))
234
- throw new Error("Vote threshold not met");
235
- if (proposal.signatures.some((s) => s.signerIndex === signerIndex))
236
- throw new Error(`Signer ${signerIndex} already signed`);
237
- const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
238
- const cell = await getLiveCell(opts.rpcUrl, txHash, index);
239
- const currentPayload = parseRegistryPayload(cell.data);
240
- const govHeaderRaw = extractGovernanceHeaderRaw(cell.data);
241
- const oldBlkl = hexToBytes(cell.data);
242
- const newEntries = proposal.action === "add"
243
- ? insertSorted(currentPayload.entries, { identifier: proposal.lockArgs, expiresAt: BigInt(proposal.expiresAt) })
244
- : removeEntry(currentPayload.entries, proposal.lockArgs);
245
- const newBlkl = encodeRegistryPayload({ version: currentPayload.version, entries: newEntries }, govHeaderRaw ?? undefined);
246
- const oldRoot = ckbBlake2b(oldBlkl);
247
- const newRoot = ckbBlake2b(newBlkl);
248
- const pubKey = bytesToHex(new Uint8Array(secp256k1.getPublicKey(pkBytes, true)));
249
- const reviewWindowEndMs = BigInt(new Date(proposal.reviewWindowEndsAt).getTime());
250
- const msgHash = signingMessage(proposal, oldRoot, newRoot, reviewWindowEndMs);
251
- const recoveredSig = secp256k1.sign(msgHash, pkBytes, { lowS: true, format: "recovered" });
252
- const sigBytes = new Uint8Array(65);
253
- sigBytes.set(recoveredSig.slice(1), 0);
254
- sigBytes[64] = recoveredSig[0] ?? 0;
255
- pkBytes.fill(0);
256
- const govHeader = govHeaderRaw ? parseGovernanceHeader(govHeaderRaw) : null;
257
- const effectiveThreshold = govHeader?.threshold ?? SIG_THRESHOLD;
258
- const sigHex = bytesToHex(sigBytes);
259
- proposal.signatures.push({ signerIndex, signature: sigHex, timestamp: new Date().toISOString() });
260
- if (proposal.signatures.length >= effectiveThreshold)
261
- proposal.status = "approved";
262
- saveProposal(proposal);
263
- return { ok: true, sigCount: proposal.signatures.length, effectiveThreshold, ready: isReadyToExecute(proposal), pubKey, signature: sigHex };
264
- }
265
237
  // ── /api/execute ──────────────────────────────────────────────────────────────
266
238
  async function handleExecute(body, opts) {
267
239
  if (typeof body !== "object" || body === null)
@@ -272,112 +244,245 @@ async function handleExecute(body, opts) {
272
244
  throw new Error("proposalId required");
273
245
  const proposal = loadProposal(proposalId);
274
246
  if (proposal.status === "executed")
275
- throw new Error(`Already executed — tx: ${proposal.txHash ?? "unknown"}`);
247
+ throw new Error("Proposal already executed");
276
248
  if (!isReviewWindowPassed(proposal))
277
- throw new Error("Review window not passed");
249
+ throw new Error("Local review window has not passed");
278
250
  if (!isVoteApproved(proposal))
279
251
  throw new Error("Vote threshold not met");
280
- if (proposal.signatures.length < SIG_THRESHOLD)
281
- throw new Error(`Only ${proposal.signatures.length}/${SIG_THRESHOLD} signatures`);
282
- if (proposal.expiresAt !== "0") {
283
- const expiryMs = BigInt(proposal.expiresAt) * 1000n;
284
- if (BigInt(Date.now()) >= expiryMs)
285
- throw new Error(`Proposal has already expired (${new Date(Number(expiryMs)).toISOString()})`);
252
+ const outpoint = extractProposalOutpoint(proposal, b);
253
+ const state = await loadRegistryStateForProposal(opts.rpcUrl, opts.registryTx, opts.registryIndex, proposal);
254
+ const proposalCell = await getLiveCell(opts.rpcUrl, outpoint.txHash, outpoint.index);
255
+ const proposalDataHash = assertProposalCellMatches(proposal, proposalCell.data, state.registryTypeIdValue);
256
+ const fields = proposalV4Fields(proposal, state.registryTypeIdValue);
257
+ if (bytesToHex(fields.proposalDataHash) !== bytesToHex(proposalDataHash)) {
258
+ throw new Error("Internal proposal hash mismatch");
286
259
  }
287
- // Verify vote signatures
260
+ proposal.proposalDataHash = bytesToHex(proposalDataHash);
261
+ proposal.reviewDelayMs = fields.reviewDelayMs.toString();
262
+ proposal.proposalCellTxHash = outpoint.txHash;
263
+ proposal.proposalCellIndex = outpoint.index;
288
264
  for (const v of proposal.votes) {
289
- const sb = hexToBytes(v.signature);
290
- if (sb.length !== 65)
291
- throw new Error(`Vote from ${v.pubkey.slice(0, 14)} has invalid sig length`);
265
+ const sigBytes = hexToBytes(v.signature);
266
+ if (sigBytes.length !== 65)
267
+ throw new Error(`Vote from ${v.pubkey.slice(0, 14)}... has invalid signature length`);
292
268
  const msgHash = voteSigningMessage(proposal.proposalIdHash, v.vote, v.timestamp, v.pubkey);
293
- const sig65v = new Uint8Array(65);
294
- sig65v[0] = sb[64];
295
- sig65v.set(sb.subarray(0, 64), 1);
296
- let recoveredPk;
297
- try {
298
- recoveredPk = bytesToHex(new Uint8Array(secp256k1.recoverPublicKey(sig65v, msgHash)));
299
- }
300
- catch {
301
- throw new Error(`Vote from ${v.pubkey.slice(0, 14)}… has unrecoverable signature`);
302
- }
303
- if (recoveredPk !== v.pubkey)
304
- throw new Error(`Vote from ${v.pubkey.slice(0, 14)}… signature mismatch`);
269
+ const sig65 = new Uint8Array(65);
270
+ sig65[0] = sigBytes[64];
271
+ sig65.set(sigBytes.subarray(0, 64), 1);
272
+ const recoveredPubkey = bytesToHex(new Uint8Array(secp256k1.recoverPublicKey(sig65, msgHash)));
273
+ if (recoveredPubkey !== v.pubkey)
274
+ throw new Error(`Vote signature does not match pubkey ${v.pubkey.slice(0, 14)}...`);
305
275
  }
306
- const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
307
- const cell = await getLiveCell(opts.rpcUrl, txHash, index);
308
- const currentPayload = parseRegistryPayload(cell.data);
309
- const govHeaderRaw = extractGovernanceHeaderRaw(cell.data);
310
- const govHeader = govHeaderRaw ? parseGovernanceHeader(govHeaderRaw) : null;
311
- const oldBlkl = hexToBytes(cell.data);
312
- const oldRoot = ckbBlake2b(oldBlkl);
313
- // Verify Merkle proofs against on-chain validator set
314
- if (govHeader && govHeader.validatorCount > 0) {
315
- const rootHex = bytesToHex(govHeader.validatorMerkleRoot);
276
+ if (state.governanceHeader?.validatorCount) {
277
+ const rootHex = bytesToHex(state.governanceHeader.validatorMerkleRoot);
316
278
  for (const v of proposal.votes) {
317
- if (!Array.isArray(v.merkleProof) || typeof v.merkleLeafIndex !== "number")
318
- throw new Error(`Vote from ${v.pubkey.slice(0, 14)} missing Merkle proof`);
319
- if (!verifyMerkleProof(rootHex, v.pubkey, v.merkleProof, v.merkleLeafIndex))
320
- throw new Error(`Vote from ${v.pubkey.slice(0, 14)}… not in on-chain validator set`);
279
+ if (!verifyMerkleProof(rootHex, v.pubkey, v.merkleProof, v.merkleLeafIndex)) {
280
+ throw new Error(`Vote from ${v.pubkey.slice(0, 14)}... is not in the on-chain governance voter set`);
281
+ }
321
282
  }
322
283
  }
323
- const newEntries = proposal.action === "add"
324
- ? insertSorted(currentPayload.entries, { identifier: proposal.lockArgs, expiresAt: BigInt(proposal.expiresAt) })
325
- : removeEntry(currentPayload.entries, proposal.lockArgs);
326
- const newBlkl = encodeRegistryPayload({ version: currentPayload.version, entries: newEntries }, govHeaderRaw ?? undefined);
327
- const newRoot = ckbBlake2b(newBlkl);
328
- // Verify governance signer signatures
329
- const effectiveThreshold = govHeader?.threshold ?? SIG_THRESHOLD;
330
- if (govHeader && govHeader.pubkeys.length > 0) {
331
- const reviewWindowEndMsV = BigInt(new Date(proposal.reviewWindowEndsAt).getTime());
332
- const msgHash = signingMessage(proposal, oldRoot, newRoot, reviewWindowEndMsV);
333
- for (const s of proposal.signatures) {
334
- const sb = hexToBytes(s.signature);
335
- if (sb.length !== 65)
336
- throw new Error(`Sig from signer ${s.signerIndex} invalid length`);
337
- const sig65s = new Uint8Array(65);
338
- sig65s[0] = sb[64];
339
- sig65s.set(sb.subarray(0, 64), 1);
340
- let rk;
341
- try {
342
- rk = bytesToHex(new Uint8Array(secp256k1.recoverPublicKey(sig65s, msgHash)));
343
- }
344
- catch {
345
- throw new Error(`Sig from signer ${s.signerIndex} unrecoverable`);
346
- }
347
- const expected = bytesToHex(govHeader.pubkeys[s.signerIndex]);
348
- if (rk !== expected)
349
- throw new Error(`Sig from signer ${s.signerIndex} does not match on-chain pubkey`);
284
+ const proposalCapacity = parseCapacity(proposalCell.capacity);
285
+ const proposalChangeCapacity = proposalCapacity - DEFAULT_FEE_SHANNONS;
286
+ const treasuryLockHash = governanceTreasuryLockHash(state.governanceHeader);
287
+ const minChange = treasuryLockHash
288
+ ? MIN_CHANGE_SHANNONS
289
+ : occupiedCapacityShannons({ lock: proposalCell.lock, type: null, data: "0x" });
290
+ if (proposalChangeCapacity < minChange) {
291
+ throw new Error(`Proposal cell capacity ${proposalCapacity} shannons is too small to return change after fee`);
292
+ }
293
+ let registryOutputCapacity = parseCapacity(state.cell.capacity);
294
+ let extraTreasuryOutputCapacity = 0n;
295
+ let treasuryLockScript;
296
+ if (treasuryLockHash) {
297
+ treasuryLockScript = state.governanceHeader?.treasuryLockScript;
298
+ if (!treasuryLockScript) {
299
+ throw new Error("Registry treasury lock script is missing from the governance header. " +
300
+ "A full treasury lock script (v3 header) is required to safely route change capacity.");
350
301
  }
302
+ assertProposalAnchorTypeMatches({
303
+ proposalCellType: proposalCell.type,
304
+ registryTypeIdValue: state.registryTypeIdValue,
305
+ governanceHeader: state.governanceHeader,
306
+ reclaimDelayMs: fields.reviewDelayMs,
307
+ });
308
+ const proposalLockHash = ckbBlake2b(scriptToMoleculeBytes(proposalCell.lock));
309
+ if (bytesToHex(proposalLockHash) !== bytesToHex(treasuryLockHash)) {
310
+ throw new Error("This registry uses treasury-funded anchors, but the proposal cell is not locked to the registry treasury");
311
+ }
312
+ const registryInputCapacity = parseCapacity(state.cell.capacity);
313
+ const minRegistryCapacity = occupiedCapacityShannons({
314
+ lock: state.cell.lock,
315
+ type: state.cell.type,
316
+ data: state.newBlkl,
317
+ });
318
+ registryOutputCapacity = minRegistryCapacity;
319
+ if (registryOutputCapacity > registryInputCapacity) {
320
+ throw new Error(`Registry update needs ${registryOutputCapacity - registryInputCapacity} shannons of treasury capacity for growth. ` +
321
+ "Use the CLI execute command with --treasury-cell inputs.");
322
+ }
323
+ extraTreasuryOutputCapacity = registryInputCapacity - registryOutputCapacity;
351
324
  }
352
- // Recompute + verify vote digest
353
- const recomputedVoteDigest = computeVoteDigestHash(proposal.votes);
354
- if (recomputedVoteDigest !== proposal.voteDigestHash)
355
- throw new Error("Vote digest hash mismatch — votes may have been tampered");
356
- const reviewWindowEndMs = BigInt(new Date(proposal.reviewWindowEndsAt).getTime());
357
- const proposalIdBytes = hexToBytes(proposal.proposalIdHash);
358
- const voteDigestBytes = hexToBytes(proposal.voteDigestHash);
359
- const signers = proposal.signatures.slice(0, effectiveThreshold).map((s) => ({ index: s.signerIndex, sig: hexToBytes(s.signature) }));
360
- const gov1 = buildGov1WitnessV3({ proposalIdHash: proposalIdBytes, voteDigestHash: voteDigestBytes, oldRoot, newRoot, reviewWindowEndMs });
361
- const sigWitness = buildGovernanceSigWitness(signers);
362
- const witnessBytes = buildWitnessArgs({ lock: sigWitness, inputType: gov1 });
325
+ const yesVotes = proposal.votes
326
+ .filter((v) => v.vote === "yes")
327
+ .sort((a, b) => a.pubkey.localeCompare(b.pubkey));
328
+ const gov1 = buildGov1WitnessV4({
329
+ proposalIdHash: hexToBytes(proposal.proposalIdHash),
330
+ voteDigestHash: hexToBytes(proposal.voteDigestHash),
331
+ oldRoot: state.oldRoot,
332
+ newRoot: state.newRoot,
333
+ proposalDataHash,
334
+ reviewDelayMs: fields.reviewDelayMs,
335
+ });
336
+ const voteWitness = buildValidatorVoteWitness(yesVotes.map((v) => ({
337
+ pubkey: hexToBytes(v.pubkey),
338
+ vote: v.vote,
339
+ timestamp: v.timestamp,
340
+ signature: hexToBytes(v.signature),
341
+ merkleLeafIndex: v.merkleLeafIndex,
342
+ merkleProof: v.merkleProof.map(hexToBytes),
343
+ })));
344
+ const witnessBytes = buildWitnessArgs({ lock: voteWitness, inputType: gov1 });
363
345
  const txJson = {
364
346
  transaction: {
365
347
  version: "0x0",
366
348
  cell_deps: [
367
349
  { out_point: { tx_hash: SECP256K1_DEP_GROUP.txHash, index: "0x0" }, dep_type: "dep_group" },
368
- { out_point: { tx_hash: TESTNET_CONTRACT_OUTPOINTS.blacklistRegistry.txHash, index: `0x${TESTNET_CONTRACT_OUTPOINTS.blacklistRegistry.index.toString(16)}` }, dep_type: "code" },
369
- { out_point: { tx_hash: TESTNET_CONTRACT_OUTPOINTS.governanceLock.txHash, index: `0x${TESTNET_CONTRACT_OUTPOINTS.governanceLock.index.toString(16)}` }, dep_type: "code" },
350
+ {
351
+ out_point: {
352
+ tx_hash: TESTNET_CONTRACT_OUTPOINTS.blacklistRegistry.txHash,
353
+ index: `0x${TESTNET_CONTRACT_OUTPOINTS.blacklistRegistry.index.toString(16)}`,
354
+ },
355
+ dep_type: "code",
356
+ },
357
+ {
358
+ out_point: {
359
+ tx_hash: TESTNET_CONTRACT_OUTPOINTS.governanceLock.txHash,
360
+ index: `0x${TESTNET_CONTRACT_OUTPOINTS.governanceLock.index.toString(16)}`,
361
+ },
362
+ dep_type: "code",
363
+ },
364
+ ...(treasuryLockHash ? [{
365
+ out_point: {
366
+ tx_hash: TESTNET_CONTRACT_OUTPOINTS.proposalAnchor.txHash,
367
+ index: `0x${TESTNET_CONTRACT_OUTPOINTS.proposalAnchor.index.toString(16)}`,
368
+ },
369
+ dep_type: "code",
370
+ }] : []),
370
371
  ],
371
372
  header_deps: [],
372
- inputs: [{ since: encodeAbsoluteTimestampSince(reviewWindowEndMs), previous_output: { tx_hash: cell.txHash, index: `0x${cell.index.toString(16)}` } }],
373
- outputs: [{ capacity: cell.capacity, lock: cell.lock, type: cell.type }],
374
- outputs_data: [bytesToHex(newBlkl)],
375
- witnesses: [bytesToHex(witnessBytes)],
373
+ inputs: [
374
+ {
375
+ since: "0x0",
376
+ previous_output: { tx_hash: state.cell.txHash, index: `0x${state.cell.index.toString(16)}` },
377
+ },
378
+ {
379
+ since: encodeRelativeTimestampSince(fields.reviewDelayMs),
380
+ previous_output: { tx_hash: outpoint.txHash, index: `0x${outpoint.index.toString(16)}` },
381
+ },
382
+ ],
383
+ outputs: [
384
+ { capacity: hexCapacity(registryOutputCapacity), lock: state.cell.lock, type: state.cell.type },
385
+ // Merge proposal change + extra treasury capacity into one output to avoid
386
+ // creating a cell below the minimum 125 CKB for treasury-lock's 64-byte args.
387
+ { capacity: hexCapacity(proposalChangeCapacity + extraTreasuryOutputCapacity), lock: treasuryLockScript ?? proposalCell.lock, type: null },
388
+ ],
389
+ outputs_data: [
390
+ bytesToHex(state.newBlkl),
391
+ "0x",
392
+ ],
393
+ witnesses: [bytesToHex(witnessBytes), bytesToHex(buildWitnessArgs({}))],
376
394
  },
377
395
  multisig_configs: {},
378
396
  signatures: {},
379
397
  };
380
- return { ok: true, proposalId: proposal.id, txJson, filename: `gov_execute_tx_${proposal.id}.json` };
398
+ saveProposal(proposal);
399
+ return {
400
+ ok: true,
401
+ proposal,
402
+ txJson,
403
+ filename: `gov_execute_tx_${proposal.id}.json`,
404
+ proposalCell: outpoint,
405
+ proposalDataHash: bytesToHex(proposalDataHash),
406
+ since: encodeRelativeTimestampSince(fields.reviewDelayMs),
407
+ };
408
+ }
409
+ // ── /api/anchor ───────────────────────────────────────────────────────────────
410
+ async function handleAnchor(body, opts) {
411
+ if (typeof body !== "object" || body === null)
412
+ throw new Error("Invalid body");
413
+ const b = body;
414
+ const proposalId = String(b.proposalId ?? "").trim();
415
+ if (!proposalId)
416
+ throw new Error("proposalId required");
417
+ const proposal = loadProposal(proposalId);
418
+ const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
419
+ const registryCell = await getLiveCell(opts.rpcUrl, txHash, index);
420
+ if (!registryCell.type)
421
+ throw new Error("Registry cell has no type script");
422
+ const registryTypeIdValue = parseRegistryTypeIdValue(registryCell.type.args);
423
+ const governanceHeaderRaw = extractGovernanceHeaderRaw(registryCell.data);
424
+ const governanceHeader = governanceHeaderRaw ? parseGovernanceHeader(governanceHeaderRaw) : null;
425
+ const proposalData = encodeProposalCellData(proposal, registryTypeIdValue);
426
+ const proposalDataHashHex = bytesToHex(proposalCellDataHash(proposal, registryTypeIdValue));
427
+ proposal.proposalDataHash = proposalDataHashHex;
428
+ proposal.reviewDelayMs = proposal.reviewDelayMs ?? String(REVIEW_WINDOW_MS);
429
+ const proposalTx = parseOptionalTxHash(b.proposalTx, "proposalTx");
430
+ let anchorVerified = false;
431
+ if (proposalTx) {
432
+ const proposalIndex = parseOutputIndex(b.proposalIndex, "proposalIndex");
433
+ const proposalCell = await getLiveCell(opts.rpcUrl, proposalTx, proposalIndex);
434
+ const liveHash = assertProposalCellMatches(proposal, proposalCell.data, registryTypeIdValue);
435
+ if (bytesToHex(liveHash) !== proposalDataHashHex) {
436
+ throw new Error("Live proposal-cell hash does not match the expected PBLK data");
437
+ }
438
+ assertProposalAnchorTypeMatches({
439
+ proposalCellType: proposalCell.type,
440
+ registryTypeIdValue,
441
+ governanceHeader,
442
+ reclaimDelayMs: BigInt(proposal.reviewDelayMs),
443
+ });
444
+ const treasuryLockHash = governanceTreasuryLockHash(governanceHeader);
445
+ if (treasuryLockHash) {
446
+ const proposalLockHash = ckbBlake2b(scriptToMoleculeBytes(proposalCell.lock));
447
+ if (bytesToHex(proposalLockHash) !== bytesToHex(treasuryLockHash)) {
448
+ throw new Error("This registry uses treasury-funded anchors, but the proposal cell is not locked to the registry treasury");
449
+ }
450
+ }
451
+ proposal.proposalCellTxHash = proposalTx;
452
+ proposal.proposalCellIndex = proposalIndex;
453
+ anchorVerified = true;
454
+ }
455
+ else if (b.proposalIndex !== undefined && String(b.proposalIndex).trim()) {
456
+ throw new Error("proposalTx is required when proposalIndex is provided");
457
+ }
458
+ saveProposal(proposal);
459
+ const treasuryLockHash = governanceTreasuryLockHash(governanceHeader);
460
+ const toAddress = String(b.toAddress ?? "").trim();
461
+ const command = treasuryLockHash ? [
462
+ "ckb-firewall",
463
+ "anchor",
464
+ "--proposal", proposal.id,
465
+ "--tx-out", `gov_anchor_tx_${proposal.id}.json`,
466
+ ].join(" ") : toAddress ? [
467
+ "ckb-cli",
468
+ "--url", opts.rpcUrl,
469
+ "wallet", "transfer",
470
+ "--to-address", toAddress,
471
+ "--capacity", "62",
472
+ "--to-data", bytesToHex(proposalData),
473
+ "--fee-rate", "1000",
474
+ "--output-format", "json",
475
+ ].map((p) => (/\s/.test(p) ? JSON.stringify(p) : p)).join(" ") : null;
476
+ return {
477
+ ok: true,
478
+ proposal,
479
+ proposalData: bytesToHex(proposalData),
480
+ proposalDataHash: proposalDataHashHex,
481
+ reviewDelayMs: proposal.reviewDelayMs,
482
+ anchorVerified,
483
+ treasuryBacked: Boolean(treasuryLockHash),
484
+ command,
485
+ };
381
486
  }
382
487
  // ── /api/import ───────────────────────────────────────────────────────────────
383
488
  function handleImport(body) {
@@ -388,8 +493,8 @@ function handleImport(body) {
388
493
  for (const k of requiredStrings)
389
494
  if (typeof p[k] !== "string")
390
495
  throw new Error(`Missing field: ${k}`);
391
- if (!Array.isArray(p.votes) || !Array.isArray(p.signatures))
392
- throw new Error("votes and signatures must be arrays");
496
+ if (!Array.isArray(p.votes))
497
+ throw new Error("votes must be an array");
393
498
  const computed = computeProposalIdHash({
394
499
  action: p.action, lockArgs: p.lockArgs,
395
500
  expiresAt: p.expiresAt, evidence: p.evidence,
@@ -416,31 +521,37 @@ function handleImport(body) {
416
521
  existing = incoming;
417
522
  }
418
523
  if (existing.proposalIdHash === incoming.proposalIdHash) {
419
- const signingStarted = existing.signatures.length > 0 || incoming.signatures.length > 0;
420
- let mergedVotes;
421
- let mergedVoteDigest;
422
- if (signingStarted) {
423
- mergedVotes = existing.signatures.length > 0 ? existing.votes : incoming.votes;
424
- mergedVoteDigest = existing.signatures.length > 0 ? existing.voteDigestHash : incoming.voteDigestHash;
425
- }
426
- else {
427
- const votesByPk = new Map(existing.votes.map((v) => [v.pubkey.toLowerCase(), v]));
428
- for (const v of incoming.votes) {
429
- const prev = votesByPk.get(v.pubkey.toLowerCase());
430
- if (!prev || v.timestamp > prev.timestamp)
431
- votesByPk.set(v.pubkey.toLowerCase(), v);
524
+ const votesByPk = new Map(existing.votes.map((v) => [v.pubkey.toLowerCase(), v]));
525
+ for (const v of incoming.votes) {
526
+ const prev = votesByPk.get(v.pubkey.toLowerCase());
527
+ if (!prev || v.timestamp > prev.timestamp) {
528
+ votesByPk.set(v.pubkey.toLowerCase(), v);
432
529
  }
433
- mergedVotes = [...votesByPk.values()];
434
- mergedVoteDigest = computeVoteDigestHash(mergedVotes);
435
530
  }
436
- const sigBySigner = new Map(existing.signatures.map((s) => [s.signerIndex, s]));
437
- for (const s of incoming.signatures)
438
- sigBySigner.set(s.signerIndex, s);
439
- const mergedSigs = [...sigBySigner.values()];
531
+ const mergedVotes = [...votesByPk.values()];
532
+ const mergedVoteDigest = computeVoteDigestHash(mergedVotes);
440
533
  const rankStatus = (st) => ["pending-review", "voting", "approved", "rejected", "executed"].indexOf(st);
441
- const merged = { ...incoming, votes: mergedVotes, voteDigestHash: mergedVoteDigest, signatures: mergedSigs, status: rankStatus(existing.status) >= rankStatus(incoming.status) ? existing.status : incoming.status };
534
+ const merged = {
535
+ ...incoming,
536
+ votes: mergedVotes,
537
+ voteDigestHash: mergedVoteDigest,
538
+ signatures: [],
539
+ status: rankStatus(existing.status) >= rankStatus(incoming.status) ? existing.status : incoming.status,
540
+ };
541
+ const proposalDataHash = existing.proposalDataHash ?? incoming.proposalDataHash;
542
+ const reviewDelayMs = existing.reviewDelayMs ?? incoming.reviewDelayMs;
543
+ const proposalCellTxHash = existing.proposalCellTxHash ?? incoming.proposalCellTxHash;
544
+ const proposalCellIndex = existing.proposalCellIndex ?? incoming.proposalCellIndex;
545
+ if (proposalDataHash !== undefined)
546
+ merged.proposalDataHash = proposalDataHash;
547
+ if (reviewDelayMs !== undefined)
548
+ merged.reviewDelayMs = reviewDelayMs;
549
+ if (proposalCellTxHash !== undefined)
550
+ merged.proposalCellTxHash = proposalCellTxHash;
551
+ if (proposalCellIndex !== undefined)
552
+ merged.proposalCellIndex = proposalCellIndex;
442
553
  saveProposal(merged);
443
- return { ok: true, merged: true, id: incoming.id, votes: mergedVotes.length, signatures: mergedSigs.length };
554
+ return { ok: true, merged: true, id: incoming.id, votes: mergedVotes.length };
444
555
  }
445
556
  throw new Error(`A different proposal with ID ${incoming.id} already exists locally`);
446
557
  }
@@ -476,13 +587,14 @@ function _buildGuiHtml(apiDataJson) {
476
587
  window.TFW_PROPOSALS = ${safeJson(d.proposals ?? [])};
477
588
  window.TFW_REGISTRY_ENTRIES = ${safeJson(d.registry?.entries ?? [])};
478
589
  window.TFW_META = ${safeJson({
479
- threshold: 3,
480
- governanceSetSize: 5,
590
+ threshold: d.registry?.threshold ?? 3,
591
+ governanceSetSize: d.registry?.validatorCount ?? 5,
481
592
  reviewWindowHours: 72,
482
593
  registryTxHash: d.registry?.txHash ?? null,
483
594
  registryError: d.registry?.error ?? null,
595
+ treasury: d.registry?.treasury ?? null,
484
596
  yourPubkey: null,
485
- yourSignerIndex: 0,
597
+ proposerName: loadConfig().proposerName ?? null,
486
598
  ...d.meta,
487
599
  })};
488
600
  </script>`;
@@ -812,9 +924,8 @@ function countdown(iso){
812
924
  return '<span style="color:var(--yellow)">'+h2+'h '+m+'m remaining</span>';
813
925
  }
814
926
  function countYes(votes){ return (votes||[]).filter(function(v){return v.vote==='yes';}).length; }
815
- function sigCount(p){ return (p.signatures||[]).length; }
816
927
  function reviewPassed(p){ return Date.now()>=new Date(p.reviewWindowEndsAt).getTime(); }
817
- function isReady(p){ return reviewPassed(p)&&countYes(p.votes)>=3&&sigCount(p)>=3; }
928
+ function isReady(p){ return reviewPassed(p)&&countYes(p.votes)>=3; }
818
929
  function prog(v,max){
819
930
  var pct=Math.min(100,Math.round(v/max*100));
820
931
  var cls=v>=max?'prog-g':'prog-y';
@@ -920,12 +1031,6 @@ function renderOverview(){
920
1031
  action.forEach(function(p){ out+=propCard(p,true); });
921
1032
  out+='</div>';
922
1033
  }
923
- // awaiting signatures
924
- if(approved.length){
925
- out+='<div class="section"><div class="sec-hdr">&#9998; Awaiting Signatures</div>';
926
- approved.forEach(function(p){ out+=propCard(p,true); });
927
- out+='</div>';
928
- }
929
1034
  // recent
930
1035
  var recent=ps.slice().sort(function(a,b){return b.submittedAt.localeCompare(a.submittedAt);}).slice(0,6);
931
1036
  if(recent.length){
@@ -1022,14 +1127,11 @@ function propCard(p,compact){
1022
1127
  out+='</div>';
1023
1128
  out+='<div class="card-foot">';
1024
1129
  out+=prog(countYes(p.votes),3);
1025
- out+=prog(sigCount(p),3);
1026
1130
  out+='<span style="flex:1"></span>';
1027
1131
  out+=regHint;
1028
1132
  // quick action buttons
1029
- if((p.status==='pending-review'||p.status==='voting')&&!p.signatures.length){
1133
+ if(p.status==='pending-review'||p.status==='voting'){
1030
1134
  out+='<button class="btn btn-p" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openVoteForm(\''+h(p.id)+'\')">Vote &rarr;</button>';
1031
- } else if(reviewPassed(p)&&countYes(p.votes)>=3&&p.status!=='executed'&&p.status!=='rejected'){
1032
- out+='<button class="btn btn-g" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openSignForm(\''+h(p.id)+'\')">Sign &rarr;</button>';
1033
1135
  } else if(isReady(p)){
1034
1136
  out+='<button class="btn btn-p" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openExecuteForm(\''+h(p.id)+'\')">Execute &rarr;</button>';
1035
1137
  }
@@ -1109,25 +1211,11 @@ function openProposal(id){
1109
1211
  });}
1110
1212
  out+='</div>';
1111
1213
 
1112
- // signatures
1113
- var sigs=p.signatures||[];
1114
- out+='<div class="msec"><div class="msec-ttl">Signatures &nbsp;'+prog(sigs.length,3)+'</div>';
1115
- if(!sigs.length){out+='<div style="color:var(--subtle);font-size:12px">No signatures yet.</div>';}
1116
- else{sigs.forEach(function(s){
1117
- out+='<div class="vrow"><span class="vch" style="color:var(--dim);width:64px">Signer #'+h(String(s.signerIndex))+'</span>'
1118
- +'<code class="vpk">'+h(s.signature.slice(0,22))+'&hellip;</code>'
1119
- +'<span class="vts">'+fmtDate(s.timestamp)+'</span></div>';
1120
- });}
1121
- out+='</div>';
1122
-
1123
1214
  // Actions
1124
1215
  out+='<div class="msec"><div class="msec-ttl">Actions</div><div class="act-btns">';
1125
- if((p.status==='pending-review'||p.status==='voting')&&!p.signatures.length){
1216
+ if(p.status==='pending-review'||p.status==='voting'){
1126
1217
  out+='<button class="btn btn-p" onclick="closeModal();openVoteForm(\''+h(p.id)+'\')">Cast Vote</button>';
1127
1218
  }
1128
- if(reviewPassed(p)&&countYes(p.votes)>=3&&p.status!=='executed'&&p.status!=='rejected'){
1129
- out+='<button class="btn btn-g" onclick="closeModal();openSignForm(\''+h(p.id)+'\')">Sign</button>';
1130
- }
1131
1219
  if(isReady(p)){
1132
1220
  out+='<button class="btn btn-p" onclick="closeModal();openExecuteForm(\''+h(p.id)+'\')">Build Execute TX</button>';
1133
1221
  }
@@ -1139,9 +1227,6 @@ function openProposal(id){
1139
1227
  if(p.status==='pending-review'||p.status==='voting'){
1140
1228
  out+=cmdRow('Vote on this proposal','ckb-firewall vote --proposal '+p.id,'cmd-v');
1141
1229
  }
1142
- if(p.status==='approved'||(reviewPassed(p)&&countYes(p.votes)>=3)){
1143
- out+=cmdRow('Sign this proposal (3-of-5 required)','ckb-firewall sign --proposal '+p.id,'cmd-s');
1144
- }
1145
1230
  if(isReady(p)){
1146
1231
  out+=cmdRow('Execute on-chain','ckb-firewall execute --proposal '+p.id,'cmd-e');
1147
1232
  }
@@ -1186,7 +1271,7 @@ function openAddr(identifier){
1186
1271
  out+='<div class="card" data-pid="'+h(p.id)+'" style="margin-bottom:8px">';
1187
1272
  out+='<div class="card-top">'+actionBadge(p)+'<span class="card-id">#'+h(p.id)+'</span><span style="flex:1"></span>'+statusBadge(p)+'</div>';
1188
1273
  out+='<div class="card-meta">'+h(p.classification)+' / '+h(p.severity)+' &middot; '+fmtDate(p.submittedAt)+'</div>';
1189
- out+='<div class="card-foot">'+prog(countYes(p.votes),3)+prog(sigCount(p),3)+'</div>';
1274
+ out+='<div class="card-foot">'+prog(countYes(p.votes),3)+'</div>';
1190
1275
  out+='</div>';
1191
1276
  });
1192
1277
  out+='</div>';
@@ -1307,41 +1392,10 @@ function submitVote(id){
1307
1392
  .catch(function(e){ setDisabled('v-submit',false); showErr('v-err',e.message); });
1308
1393
  }
1309
1394
 
1310
- // ── sign form ─────────────────────────────────────────────────────────────────
1311
- function openSignForm(id){
1312
- var p=D.proposals.find(function(x){return x.id===id;});
1313
- if(!p)return;
1314
- var html='';
1315
- html+='<div class="sec-note sec-note-warn">&#128274; Private key zeroed immediately after signing. Never stored or transmitted.</div>';
1316
- html+='<div class="form-row"><label class="form-lbl" for="s-idx">Signer Index <small>(0–4, your position in governance set)</small></label>';
1317
- html+='<input id="s-idx" class="fi" type="number" min="0" max="4" placeholder="0–4"></div>';
1318
- html+='<div class="form-row"><label class="form-lbl" for="s-pk">Private Key <small>(32 bytes, hex)</small></label>';
1319
- html+='<input id="s-pk" class="fi fi-mono" type="password" placeholder="64 hex chars" autocomplete="off" autocorrect="off" spellcheck="false"></div>';
1320
- html+='<div id="s-err" class="form-err"></div>';
1321
- html+='<div id="s-ok" class="form-ok"></div>';
1322
- html+='<div class="btn-row"><button class="btn btn-g" id="s-submit" onclick="submitSign(\''+h(id)+'\')">Sign</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
1323
- openAct('Sign — Proposal #'+h(id),html);
1324
- }
1325
-
1326
- function submitSign(id){
1327
- hideMsg('s-err'); hideMsg('s-ok'); setDisabled('s-submit',true);
1328
- var body={proposalId:id,signerIndex:Number(fval('s-idx')),privateKey:fval('s-pk').trim()};
1329
- fetch('/api/sign',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
1330
- .then(function(r){return r.json();})
1331
- .then(function(d){
1332
- setDisabled('s-submit',false);
1333
- document.getElementById('s-pk').value='';
1334
- if(!d.ok){showErr('s-err',d.error||'Unknown error');return;}
1335
- showOk('s-ok','Signed! Sigs: '+d.sigCount+'/'+d.effectiveThreshold+(d.ready?' — Ready to execute!':''));
1336
- load().then(function(){ setTimeout(closeAct,1500); });
1337
- })
1338
- .catch(function(e){ setDisabled('s-submit',false); showErr('s-err',e.message); });
1339
- }
1340
-
1341
1395
  // ── execute form ──────────────────────────────────────────────────────────────
1342
1396
  function openExecuteForm(id){
1343
1397
  var html='';
1344
- html+='<div class="sec-note sec-note-tip">&#9888; This verifies all signatures and builds the transaction JSON for download. Submit it via ckb-cli or a CKB wallet.</div>';
1398
+ html+='<div class="sec-note sec-note-tip">&#9888; This verifies validator votes and builds the transaction JSON for download. Submit it via ckb-cli or a CKB wallet.</div>';
1345
1399
  html+='<div id="e-err" class="form-err"></div>';
1346
1400
  html+='<div id="e-ok" class="form-ok"></div>';
1347
1401
  html+='<div class="btn-row"><button class="btn btn-p" id="e-submit" onclick="submitExecute(\''+h(id)+'\')">Build &amp; Download TX</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
@@ -1398,7 +1452,7 @@ function submitImport(){
1398
1452
  .then(function(d){
1399
1453
  setDisabled('i-submit',false);
1400
1454
  if(!d.ok){showErr('i-err',d.error||'Unknown error');return;}
1401
- showOk('i-ok',d.merged?'Merged proposal #'+d.id+' ('+d.votes+' votes, '+d.signatures+' sigs)':'Imported proposal #'+d.id);
1455
+ showOk('i-ok',d.merged?'Merged proposal #'+d.id+' ('+d.votes+' votes)':'Imported proposal #'+d.id);
1402
1456
  load().then(function(){ setTimeout(closeAct,1500); });
1403
1457
  })
1404
1458
  .catch(function(e){ setDisabled('i-submit',false); showErr('i-err',e.message); });
@@ -1500,9 +1554,9 @@ async function handleRequest(req, res, opts) {
1500
1554
  }
1501
1555
  return;
1502
1556
  }
1503
- if (url === "/api/sign" && method === "POST") {
1557
+ if (url === "/api/anchor" && method === "POST") {
1504
1558
  try {
1505
- apiOk(res, await handleSign(await readJsonBody(req), opts));
1559
+ apiOk(res, await handleAnchor(await readJsonBody(req), opts));
1506
1560
  }
1507
1561
  catch (e) {
1508
1562
  apiErr(res, e instanceof Error ? e.message : String(e));