@ckb-firewall/cli 0.3.0 → 0.4.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.
- package/dist/commands/gui.d.ts +6 -0
- package/dist/commands/gui.d.ts.map +1 -0
- package/dist/commands/gui.js +112 -0
- package/dist/commands/gui.js.map +1 -0
- package/dist/index.js +11 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/Transaction Firewall.html +201 -0
- package/dist/lib/gui-bundle.html +4097 -0
- package/dist/lib/gui-server.d.ts +12 -0
- package/dist/lib/gui-server.d.ts.map +1 -0
- package/dist/lib/gui-server.js +1580 -0
- package/dist/lib/gui-server.js.map +1 -0
- package/dist/lib/portless.d.ts +41 -0
- package/dist/lib/portless.d.ts.map +1 -0
- package/dist/lib/portless.js +194 -0
- package/dist/lib/portless.js.map +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,1580 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { join, dirname as pathDirname } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
const _require = createRequire(import.meta.url);
|
|
7
|
+
const _cliVersion = _require("../../package.json").version;
|
|
8
|
+
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 { getLiveCell } from "./rpc.js";
|
|
11
|
+
import { resolveRegistryOutpoint } from "./registry.js";
|
|
12
|
+
import { parseRegistryPayload } from "@ckb-firewall/sdk";
|
|
13
|
+
import { hexToBytes, bytesToHex, strip0x, encodeRegistryPayload, extractGovernanceHeaderRaw, parseGovernanceHeader, insertSorted, removeEntry, } from "./blkl.js";
|
|
14
|
+
import { computeMerkleProof, verifyMerkleProof } from "./validator-set.js";
|
|
15
|
+
import { TESTNET_GOVERNANCE_PUBKEYS, TESTNET_CONTRACT_OUTPOINTS, SECP256K1_DEP_GROUP, } from "./defaults.js";
|
|
16
|
+
import { ckbBlake2b, buildGov1WitnessV3, buildGovernanceSigWitness, buildWitnessArgs, encodeAbsoluteTimestampSince, } from "./witness.js";
|
|
17
|
+
// ── /api/data handler ─────────────────────────────────────────────────────────
|
|
18
|
+
async function buildApiData(opts) {
|
|
19
|
+
const proposals = listProposals();
|
|
20
|
+
let registry;
|
|
21
|
+
try {
|
|
22
|
+
const { txHash, index } = await resolveRegistryOutpoint(opts.rpcUrl, opts.registryTx, opts.registryIndex);
|
|
23
|
+
const cell = await getLiveCell(opts.rpcUrl, txHash, index);
|
|
24
|
+
const payload = parseRegistryPayload(cell.data);
|
|
25
|
+
registry = {
|
|
26
|
+
txHash,
|
|
27
|
+
index,
|
|
28
|
+
version: payload.version,
|
|
29
|
+
error: null,
|
|
30
|
+
entries: payload.entries.map((e) => ({
|
|
31
|
+
identifier: e.identifier,
|
|
32
|
+
expiresAt: e.expiresAt === 0n ? null : String(e.expiresAt),
|
|
33
|
+
})),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
registry = {
|
|
38
|
+
txHash: opts.registryTx,
|
|
39
|
+
index: opts.registryIndex,
|
|
40
|
+
version: 0,
|
|
41
|
+
error: err instanceof Error ? err.message : String(err),
|
|
42
|
+
entries: [],
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify({
|
|
46
|
+
proposals,
|
|
47
|
+
registry,
|
|
48
|
+
meta: {
|
|
49
|
+
rpcUrl: opts.rpcUrl,
|
|
50
|
+
fetchedAt: new Date().toISOString(),
|
|
51
|
+
cliVersion: _cliVersion,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// ── request body reader ───────────────────────────────────────────────────────
|
|
56
|
+
function readJsonBody(req) {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
let body = "";
|
|
59
|
+
req.on("data", (chunk) => {
|
|
60
|
+
body += chunk.toString();
|
|
61
|
+
if (body.length > 128 * 1024)
|
|
62
|
+
req.destroy(new Error("Request body too large"));
|
|
63
|
+
});
|
|
64
|
+
req.on("end", () => { try {
|
|
65
|
+
resolve(JSON.parse(body));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
reject(new Error("Invalid JSON body"));
|
|
69
|
+
} });
|
|
70
|
+
req.on("error", reject);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function apiOk(res, data) {
|
|
74
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
|
|
75
|
+
res.end(JSON.stringify(data));
|
|
76
|
+
}
|
|
77
|
+
function apiErr(res, msg, status = 400) {
|
|
78
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
79
|
+
res.end(JSON.stringify({ ok: false, error: msg }));
|
|
80
|
+
}
|
|
81
|
+
// ── /api/propose ──────────────────────────────────────────────────────────────
|
|
82
|
+
function isValidHex(v) {
|
|
83
|
+
const c = strip0x(v);
|
|
84
|
+
return c.length > 0 && /^[0-9a-fA-F]+$/.test(c) && c.length % 2 === 0;
|
|
85
|
+
}
|
|
86
|
+
async function handlePropose(body) {
|
|
87
|
+
if (typeof body !== "object" || body === null)
|
|
88
|
+
throw new Error("Invalid body");
|
|
89
|
+
const b = body;
|
|
90
|
+
const action = String(b.action ?? "");
|
|
91
|
+
if (action !== "add" && action !== "remove")
|
|
92
|
+
throw new Error('action must be "add" or "remove"');
|
|
93
|
+
const rawArgs = String(b.lockArgs ?? "").trim();
|
|
94
|
+
if (!rawArgs || !isValidHex(rawArgs))
|
|
95
|
+
throw new Error("lockArgs must be valid even-length hex");
|
|
96
|
+
const lockArgs = `0x${strip0x(rawArgs).toLowerCase()}`;
|
|
97
|
+
const evidence = String(b.evidence ?? "").trim();
|
|
98
|
+
if (evidence.length < 10)
|
|
99
|
+
throw new Error("evidence must be at least 10 characters");
|
|
100
|
+
const classification = String(b.classification ?? "");
|
|
101
|
+
if (!["theft", "scam", "hack", "sanctions", "other"].includes(classification))
|
|
102
|
+
throw new Error("Invalid classification");
|
|
103
|
+
const severity = String(b.severity ?? "");
|
|
104
|
+
if (!["critical", "high", "medium", "low"].includes(severity))
|
|
105
|
+
throw new Error("Invalid severity");
|
|
106
|
+
const rationale = String(b.rationale ?? "").trim();
|
|
107
|
+
if (rationale.length < 20)
|
|
108
|
+
throw new Error("rationale must be at least 20 characters");
|
|
109
|
+
const proposer = String(b.proposer ?? "").trim();
|
|
110
|
+
if (!proposer)
|
|
111
|
+
throw new Error("proposer is required");
|
|
112
|
+
let expiresAt = "0";
|
|
113
|
+
if (action === "add" && b.expiresAt && b.expiresAt !== "0") {
|
|
114
|
+
const n = Number(b.expiresAt);
|
|
115
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0)
|
|
116
|
+
throw new Error("Invalid expiresAt timestamp");
|
|
117
|
+
if (n <= Math.floor(Date.now() / 1000))
|
|
118
|
+
throw new Error("expiresAt must be in the future");
|
|
119
|
+
expiresAt = String(n);
|
|
120
|
+
}
|
|
121
|
+
const submittedAt = new Date().toISOString();
|
|
122
|
+
const reviewWindowEndsAt = new Date(Date.now() + REVIEW_WINDOW_MS).toISOString();
|
|
123
|
+
const proposalIdHash = computeProposalIdHash({
|
|
124
|
+
action: action, lockArgs, expiresAt, evidence,
|
|
125
|
+
classification: classification, severity: severity,
|
|
126
|
+
rationale, proposer, submittedAt,
|
|
127
|
+
});
|
|
128
|
+
const proposal = {
|
|
129
|
+
id: proposalIdHash.slice(2, 14),
|
|
130
|
+
proposalIdHash, action: action,
|
|
131
|
+
lockArgs, expiresAt, evidence,
|
|
132
|
+
classification: classification,
|
|
133
|
+
severity: severity,
|
|
134
|
+
rationale, proposer, submittedAt, reviewWindowEndsAt,
|
|
135
|
+
status: "pending-review",
|
|
136
|
+
votes: [], voteDigestHash: computeVoteDigestHash([]), signatures: [],
|
|
137
|
+
};
|
|
138
|
+
saveProposal(proposal);
|
|
139
|
+
return { ok: true, id: proposal.id, proposal };
|
|
140
|
+
}
|
|
141
|
+
// ── /api/vote ─────────────────────────────────────────────────────────────────
|
|
142
|
+
async function handleVote(body, opts) {
|
|
143
|
+
if (typeof body !== "object" || body === null)
|
|
144
|
+
throw new Error("Invalid body");
|
|
145
|
+
const b = body;
|
|
146
|
+
const proposalId = String(b.proposalId ?? "").trim();
|
|
147
|
+
if (!proposalId)
|
|
148
|
+
throw new Error("proposalId required");
|
|
149
|
+
const choice = String(b.vote ?? "");
|
|
150
|
+
if (choice !== "yes" && choice !== "no" && choice !== "abstain")
|
|
151
|
+
throw new Error("vote must be yes, no, or abstain");
|
|
152
|
+
const pkHex = String(b.privateKey ?? "").trim();
|
|
153
|
+
if (!pkHex)
|
|
154
|
+
throw new Error("privateKey required");
|
|
155
|
+
let pkBytes;
|
|
156
|
+
try {
|
|
157
|
+
pkBytes = hexToBytes(pkHex);
|
|
158
|
+
if (pkBytes.length !== 32)
|
|
159
|
+
throw new Error("must be 32 bytes");
|
|
160
|
+
secp256k1.getPublicKey(pkBytes);
|
|
161
|
+
}
|
|
162
|
+
catch (e) {
|
|
163
|
+
throw new Error("Invalid private key: " + (e instanceof Error ? e.message : String(e)));
|
|
164
|
+
}
|
|
165
|
+
const proposal = loadProposal(proposalId);
|
|
166
|
+
if (proposal.status === "executed")
|
|
167
|
+
throw new Error("Proposal already executed");
|
|
168
|
+
if (proposal.status === "rejected")
|
|
169
|
+
throw new Error("Proposal was rejected");
|
|
170
|
+
if (proposal.signatures.length > 0)
|
|
171
|
+
throw new Error("Cannot vote — signing has already begun");
|
|
172
|
+
const pubkeyBytes = secp256k1.getPublicKey(pkBytes, true);
|
|
173
|
+
const pubkey = bytesToHex(new Uint8Array(pubkeyBytes));
|
|
174
|
+
const validatorSet = TESTNET_GOVERNANCE_PUBKEYS.map((k) => bytesToHex(k));
|
|
175
|
+
const merkleResult = computeMerkleProof(validatorSet, pubkey);
|
|
176
|
+
if (!merkleResult) {
|
|
177
|
+
pkBytes.fill(0);
|
|
178
|
+
throw new Error(`Key is not an authorized validator (pubkey: ${pubkey.slice(0, 20)}…)`);
|
|
179
|
+
}
|
|
180
|
+
const { proof: merkleProof, leafIndex: merkleLeafIndex } = merkleResult;
|
|
181
|
+
if (proposal.votes.some((v) => v.pubkey.toLowerCase() === pubkey.toLowerCase())) {
|
|
182
|
+
pkBytes.fill(0);
|
|
183
|
+
throw new Error("This validator already voted on this proposal");
|
|
184
|
+
}
|
|
185
|
+
const timestamp = new Date().toISOString();
|
|
186
|
+
const msgHash = voteSigningMessage(proposal.proposalIdHash, choice, timestamp, pubkey);
|
|
187
|
+
const sigRaw = secp256k1.sign(msgHash, pkBytes, { lowS: true, format: "recovered" });
|
|
188
|
+
const sigBytes = new Uint8Array(65);
|
|
189
|
+
sigBytes.set(sigRaw.slice(1), 0);
|
|
190
|
+
sigBytes[64] = sigRaw[0] ?? 0;
|
|
191
|
+
pkBytes.fill(0);
|
|
192
|
+
proposal.votes.push({ pubkey, vote: choice, timestamp, signature: bytesToHex(sigBytes), merkleLeafIndex, merkleProof });
|
|
193
|
+
proposal.voteDigestHash = computeVoteDigestHash(proposal.votes);
|
|
194
|
+
const yesCount = countYes(proposal.votes);
|
|
195
|
+
const approved = isVoteApproved(proposal);
|
|
196
|
+
if (proposal.status === "pending-review" || proposal.status === "voting")
|
|
197
|
+
proposal.status = approved ? "approved" : "voting";
|
|
198
|
+
saveProposal(proposal);
|
|
199
|
+
return { ok: true, yesCount, approved, status: proposal.status, pubkey };
|
|
200
|
+
}
|
|
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
|
+
// ── /api/execute ──────────────────────────────────────────────────────────────
|
|
266
|
+
async function handleExecute(body, opts) {
|
|
267
|
+
if (typeof body !== "object" || body === null)
|
|
268
|
+
throw new Error("Invalid body");
|
|
269
|
+
const b = body;
|
|
270
|
+
const proposalId = String(b.proposalId ?? "").trim();
|
|
271
|
+
if (!proposalId)
|
|
272
|
+
throw new Error("proposalId required");
|
|
273
|
+
const proposal = loadProposal(proposalId);
|
|
274
|
+
if (proposal.status === "executed")
|
|
275
|
+
throw new Error(`Already executed — tx: ${proposal.txHash ?? "unknown"}`);
|
|
276
|
+
if (!isReviewWindowPassed(proposal))
|
|
277
|
+
throw new Error("Review window not passed");
|
|
278
|
+
if (!isVoteApproved(proposal))
|
|
279
|
+
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()})`);
|
|
286
|
+
}
|
|
287
|
+
// Verify vote signatures
|
|
288
|
+
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`);
|
|
292
|
+
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`);
|
|
305
|
+
}
|
|
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);
|
|
316
|
+
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`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
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`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
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 });
|
|
363
|
+
const txJson = {
|
|
364
|
+
transaction: {
|
|
365
|
+
version: "0x0",
|
|
366
|
+
cell_deps: [
|
|
367
|
+
{ 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" },
|
|
370
|
+
],
|
|
371
|
+
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)],
|
|
376
|
+
},
|
|
377
|
+
multisig_configs: {},
|
|
378
|
+
signatures: {},
|
|
379
|
+
};
|
|
380
|
+
return { ok: true, proposalId: proposal.id, txJson, filename: `gov_execute_tx_${proposal.id}.json` };
|
|
381
|
+
}
|
|
382
|
+
// ── /api/import ───────────────────────────────────────────────────────────────
|
|
383
|
+
function handleImport(body) {
|
|
384
|
+
if (typeof body !== "object" || body === null)
|
|
385
|
+
throw new Error("Invalid body");
|
|
386
|
+
const p = body;
|
|
387
|
+
const requiredStrings = ["id", "proposalIdHash", "action", "lockArgs", "expiresAt", "evidence", "classification", "severity", "rationale", "proposer", "submittedAt", "reviewWindowEndsAt", "status", "voteDigestHash"];
|
|
388
|
+
for (const k of requiredStrings)
|
|
389
|
+
if (typeof p[k] !== "string")
|
|
390
|
+
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");
|
|
393
|
+
const computed = computeProposalIdHash({
|
|
394
|
+
action: p.action, lockArgs: p.lockArgs,
|
|
395
|
+
expiresAt: p.expiresAt, evidence: p.evidence,
|
|
396
|
+
classification: p.classification, severity: p.severity,
|
|
397
|
+
rationale: p.rationale, proposer: p.proposer, submittedAt: p.submittedAt,
|
|
398
|
+
});
|
|
399
|
+
if (computed !== p.proposalIdHash)
|
|
400
|
+
throw new Error("proposalIdHash integrity check failed — file may be tampered");
|
|
401
|
+
const expectedId = computed.slice(2, 14);
|
|
402
|
+
if (!/^[0-9a-f]{12}$/.test(String(p.id)) || String(p.id) !== expectedId)
|
|
403
|
+
throw new Error("id does not match proposalIdHash");
|
|
404
|
+
const recomputedDigest = computeVoteDigestHash(p.votes);
|
|
405
|
+
if (recomputedDigest !== p.voteDigestHash)
|
|
406
|
+
throw new Error("voteDigestHash integrity check failed — votes may be tampered");
|
|
407
|
+
const incoming = body;
|
|
408
|
+
const dir = getProposalsDir();
|
|
409
|
+
const destPath = join(dir, `${incoming.id}.json`);
|
|
410
|
+
if (existsSync(destPath)) {
|
|
411
|
+
let existing;
|
|
412
|
+
try {
|
|
413
|
+
existing = loadProposal(incoming.id);
|
|
414
|
+
}
|
|
415
|
+
catch {
|
|
416
|
+
existing = incoming;
|
|
417
|
+
}
|
|
418
|
+
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);
|
|
432
|
+
}
|
|
433
|
+
mergedVotes = [...votesByPk.values()];
|
|
434
|
+
mergedVoteDigest = computeVoteDigestHash(mergedVotes);
|
|
435
|
+
}
|
|
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()];
|
|
440
|
+
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 };
|
|
442
|
+
saveProposal(merged);
|
|
443
|
+
return { ok: true, merged: true, id: incoming.id, votes: mergedVotes.length, signatures: mergedSigs.length };
|
|
444
|
+
}
|
|
445
|
+
throw new Error(`A different proposal with ID ${incoming.id} already exists locally`);
|
|
446
|
+
}
|
|
447
|
+
saveProposal(incoming);
|
|
448
|
+
return { ok: true, merged: false, id: incoming.id };
|
|
449
|
+
}
|
|
450
|
+
// ── GUI bundle serving ────────────────────────────────────────────────────────
|
|
451
|
+
// gui-bundle.html is assembled by scripts/build-gui.js during `npm run build`.
|
|
452
|
+
// Source files live in src/lib/gui/ — edit those to change the UI.
|
|
453
|
+
//
|
|
454
|
+
// Per request: read the bundle once, cache it, then replace the sentinel
|
|
455
|
+
// comment with a <script> that sets window.TFW_* globals from live data.
|
|
456
|
+
const _GUI_BUNDLE_PATH = (() => {
|
|
457
|
+
try {
|
|
458
|
+
const __dir = pathDirname(fileURLToPath(import.meta.url));
|
|
459
|
+
const p = join(__dir, "gui-bundle.html");
|
|
460
|
+
return existsSync(p) ? p : null;
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
})();
|
|
466
|
+
let _guiTemplate = null;
|
|
467
|
+
function _buildGuiHtml(apiDataJson) {
|
|
468
|
+
if (!_guiTemplate) {
|
|
469
|
+
if (!_GUI_BUNDLE_PATH)
|
|
470
|
+
throw new Error("gui-bundle.html not found — run npm run build first");
|
|
471
|
+
_guiTemplate = readFileSync(_GUI_BUNDLE_PATH, "utf8");
|
|
472
|
+
}
|
|
473
|
+
const d = JSON.parse(apiDataJson);
|
|
474
|
+
const safeJson = (v) => JSON.stringify(v).replace(/<\/script/gi, "<\\/script");
|
|
475
|
+
const liveScript = `<script>
|
|
476
|
+
window.TFW_PROPOSALS = ${safeJson(d.proposals ?? [])};
|
|
477
|
+
window.TFW_REGISTRY_ENTRIES = ${safeJson(d.registry?.entries ?? [])};
|
|
478
|
+
window.TFW_META = ${safeJson({
|
|
479
|
+
threshold: 3,
|
|
480
|
+
governanceSetSize: 5,
|
|
481
|
+
reviewWindowHours: 72,
|
|
482
|
+
registryTxHash: d.registry?.txHash ?? null,
|
|
483
|
+
registryError: d.registry?.error ?? null,
|
|
484
|
+
yourPubkey: null,
|
|
485
|
+
yourSignerIndex: 0,
|
|
486
|
+
...d.meta,
|
|
487
|
+
})};
|
|
488
|
+
</script>`;
|
|
489
|
+
return _guiTemplate.replace("<!-- LIVE_DATA_PLACEHOLDER -->", liveScript);
|
|
490
|
+
}
|
|
491
|
+
// ── HTML SPA ──────────────────────────────────────────────────────────────────
|
|
492
|
+
// Single self-contained page — no external dependencies.
|
|
493
|
+
/* eslint-disable max-len */
|
|
494
|
+
const HTML = `<!DOCTYPE html>
|
|
495
|
+
<html lang="en">
|
|
496
|
+
<head>
|
|
497
|
+
<meta charset="UTF-8">
|
|
498
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
499
|
+
<title>CKB Firewall</title>
|
|
500
|
+
<style>
|
|
501
|
+
:root{
|
|
502
|
+
--bg:#0d1117;--surf:#161b22;--surf2:#1c2128;--surf3:#22272e;
|
|
503
|
+
--border:#30363d;--border2:#21262d;
|
|
504
|
+
--text:#c9d1d9;--dim:#8b949e;--subtle:#6e7681;
|
|
505
|
+
--blue:#58a6ff;--green:#3fb950;--red:#f85149;
|
|
506
|
+
--yellow:#d29922;--cyan:#79c0ff;--purple:#bc8cff;
|
|
507
|
+
--font:'SF Mono','Cascadia Code','Fira Code',ui-monospace,monospace;
|
|
508
|
+
--sans:system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
509
|
+
}
|
|
510
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
511
|
+
html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:14px;line-height:1.5}
|
|
512
|
+
a{color:var(--blue);text-decoration:none}
|
|
513
|
+
a:hover{text-decoration:underline}
|
|
514
|
+
code,.mono{font-family:var(--font);font-size:12px}
|
|
515
|
+
button{font-family:inherit}
|
|
516
|
+
::-webkit-scrollbar{width:6px;height:6px}
|
|
517
|
+
::-webkit-scrollbar-track{background:var(--surf)}
|
|
518
|
+
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
|
519
|
+
|
|
520
|
+
/* header */
|
|
521
|
+
header{
|
|
522
|
+
display:flex;align-items:center;gap:20px;
|
|
523
|
+
padding:0 24px;height:52px;
|
|
524
|
+
background:var(--surf);border-bottom:1px solid var(--border);
|
|
525
|
+
position:sticky;top:0;z-index:100;
|
|
526
|
+
}
|
|
527
|
+
.logo{display:flex;align-items:center;gap:8px;font-weight:700;white-space:nowrap;color:var(--cyan);letter-spacing:.3px;font-size:15px}
|
|
528
|
+
.logo-hex{font-size:18px}
|
|
529
|
+
nav{display:flex;gap:2px;flex:1}
|
|
530
|
+
.tab-btn{
|
|
531
|
+
padding:6px 14px;border:none;background:none;color:var(--dim);
|
|
532
|
+
cursor:pointer;border-radius:6px;font-size:13px;transition:color .15s,background .15s;
|
|
533
|
+
}
|
|
534
|
+
.tab-btn:hover{color:var(--text);background:var(--surf2)}
|
|
535
|
+
.tab-btn.active{color:var(--text);background:var(--surf3);font-weight:500}
|
|
536
|
+
.hdr-status{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:11px;white-space:nowrap}
|
|
537
|
+
.dot{width:7px;height:7px;border-radius:50%;background:var(--subtle);flex-shrink:0}
|
|
538
|
+
.dot.ok{background:var(--green)}
|
|
539
|
+
.dot.err{background:var(--red)}
|
|
540
|
+
.dot.spin{background:var(--yellow);animation:pulse 1.2s infinite}
|
|
541
|
+
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
|
|
542
|
+
|
|
543
|
+
/* pages */
|
|
544
|
+
.page{display:none;padding:24px;max-width:1120px;margin:0 auto}
|
|
545
|
+
.page.active{display:block}
|
|
546
|
+
|
|
547
|
+
/* stat tiles */
|
|
548
|
+
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:28px}
|
|
549
|
+
.stat{background:var(--surf);border:1px solid var(--border);border-radius:8px;padding:16px 20px}
|
|
550
|
+
.stat-val{font-size:30px;font-weight:700;line-height:1;margin-bottom:4px}
|
|
551
|
+
.stat-lbl{color:var(--dim);font-size:11px;text-transform:uppercase;letter-spacing:.7px}
|
|
552
|
+
.stat-sub{color:var(--subtle);font-size:11px;margin-top:3px}
|
|
553
|
+
|
|
554
|
+
/* section */
|
|
555
|
+
.section{margin-bottom:28px}
|
|
556
|
+
.sec-hdr{
|
|
557
|
+
display:flex;align-items:center;gap:8px;
|
|
558
|
+
margin-bottom:12px;padding-bottom:8px;
|
|
559
|
+
border-bottom:1px solid var(--border2);
|
|
560
|
+
font-size:11px;font-weight:600;text-transform:uppercase;
|
|
561
|
+
letter-spacing:.7px;color:var(--dim);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* badges */
|
|
565
|
+
.badge{
|
|
566
|
+
display:inline-flex;align-items:center;padding:2px 8px;
|
|
567
|
+
border-radius:10px;font-size:11px;font-weight:500;white-space:nowrap;
|
|
568
|
+
}
|
|
569
|
+
.b-yellow{background:rgba(210,153,34,.15);color:var(--yellow);border:1px solid rgba(210,153,34,.3)}
|
|
570
|
+
.b-green {background:rgba(63,185,80,.15) ;color:var(--green) ;border:1px solid rgba(63,185,80,.3) }
|
|
571
|
+
.b-red {background:rgba(248,81,73,.15) ;color:var(--red) ;border:1px solid rgba(248,81,73,.3) }
|
|
572
|
+
.b-blue {background:rgba(88,166,255,.15);color:var(--blue) ;border:1px solid rgba(88,166,255,.3) }
|
|
573
|
+
.b-dim {background:rgba(139,148,158,.1);color:var(--dim) ;border:1px solid rgba(139,148,158,.2)}
|
|
574
|
+
.b-cyan {background:rgba(121,192,255,.15);color:var(--cyan) ;border:1px solid rgba(121,192,255,.3)}
|
|
575
|
+
|
|
576
|
+
/* cards */
|
|
577
|
+
.card{
|
|
578
|
+
background:var(--surf);border:1px solid var(--border);
|
|
579
|
+
border-radius:8px;padding:14px 18px;margin-bottom:8px;
|
|
580
|
+
cursor:pointer;transition:border-color .15s;
|
|
581
|
+
}
|
|
582
|
+
.card:hover{border-color:var(--blue)}
|
|
583
|
+
.card-top{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
584
|
+
.card-action{font-weight:700;font-size:11px;text-transform:uppercase;letter-spacing:.6px}
|
|
585
|
+
.add-col{color:var(--green)}.rem-col{color:var(--red)}
|
|
586
|
+
.card-args{font-family:var(--font);font-size:12px;color:var(--text)}
|
|
587
|
+
.card-id{font-family:var(--font);font-size:11px;color:var(--subtle)}
|
|
588
|
+
.card-meta{color:var(--dim);font-size:12px;margin-top:6px;line-height:1.7}
|
|
589
|
+
.card-foot{display:flex;align-items:center;gap:12px;margin-top:10px;flex-wrap:wrap}
|
|
590
|
+
|
|
591
|
+
/* progress */
|
|
592
|
+
.prog{display:flex;align-items:center;gap:7px;font-size:11px}
|
|
593
|
+
.prog-track{height:4px;background:var(--border);border-radius:2px;width:60px}
|
|
594
|
+
.prog-fill{height:100%;border-radius:2px;transition:width .3s}
|
|
595
|
+
.prog-g{background:var(--green)}.prog-y{background:var(--yellow)}
|
|
596
|
+
|
|
597
|
+
/* table */
|
|
598
|
+
.tbl-wrap{overflow-x:auto;border:1px solid var(--border);border-radius:8px}
|
|
599
|
+
table{width:100%;border-collapse:collapse}
|
|
600
|
+
th{
|
|
601
|
+
text-align:left;padding:9px 14px;font-size:11px;
|
|
602
|
+
text-transform:uppercase;letter-spacing:.7px;
|
|
603
|
+
color:var(--dim);font-weight:600;
|
|
604
|
+
background:var(--surf);border-bottom:1px solid var(--border);
|
|
605
|
+
}
|
|
606
|
+
td{padding:10px 14px;border-bottom:1px solid var(--border2);font-size:13px;vertical-align:middle}
|
|
607
|
+
tr:last-child td{border-bottom:none}
|
|
608
|
+
tr.click{cursor:pointer}
|
|
609
|
+
tr.click:hover td{background:var(--surf2)}
|
|
610
|
+
.la{font-family:var(--font);font-size:12px}
|
|
611
|
+
|
|
612
|
+
/* filter bar */
|
|
613
|
+
.fbar{display:flex;gap:10px;margin-bottom:16px;flex-wrap:wrap;align-items:center}
|
|
614
|
+
.inp{
|
|
615
|
+
background:var(--surf);border:1px solid var(--border);
|
|
616
|
+
border-radius:6px;padding:7px 12px;color:var(--text);
|
|
617
|
+
font-size:13px;outline:none;transition:border-color .15s;
|
|
618
|
+
}
|
|
619
|
+
.inp:focus{border-color:var(--blue)}
|
|
620
|
+
.inp.srch{min-width:240px;flex:1;max-width:380px}
|
|
621
|
+
.ftabs{display:flex;gap:4px;flex-wrap:wrap}
|
|
622
|
+
.ftab{
|
|
623
|
+
padding:5px 12px;border:1px solid var(--border);background:none;
|
|
624
|
+
color:var(--dim);border-radius:6px;cursor:pointer;font-size:12px;transition:all .15s;
|
|
625
|
+
}
|
|
626
|
+
.ftab:hover{color:var(--text);border-color:var(--dim)}
|
|
627
|
+
.ftab.active{background:var(--surf2);color:var(--text);border-color:var(--blue);font-weight:500}
|
|
628
|
+
label.ck{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12px;cursor:pointer;user-select:none}
|
|
629
|
+
label.ck input{accent-color:var(--blue)}
|
|
630
|
+
|
|
631
|
+
/* modal */
|
|
632
|
+
.overlay{
|
|
633
|
+
position:fixed;inset:0;background:rgba(0,0,0,.7);
|
|
634
|
+
display:flex;align-items:flex-start;justify-content:center;
|
|
635
|
+
padding:32px 16px;z-index:200;overflow-y:auto;
|
|
636
|
+
}
|
|
637
|
+
.overlay.hidden{display:none}
|
|
638
|
+
.modal{
|
|
639
|
+
background:var(--surf);border:1px solid var(--border);
|
|
640
|
+
border-radius:10px;width:100%;max-width:700px;
|
|
641
|
+
box-shadow:0 24px 80px rgba(0,0,0,.6);
|
|
642
|
+
}
|
|
643
|
+
.mhdr{
|
|
644
|
+
display:flex;align-items:center;gap:12px;
|
|
645
|
+
padding:16px 22px;border-bottom:1px solid var(--border);
|
|
646
|
+
}
|
|
647
|
+
.mtitle{font-weight:600;font-size:14px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
648
|
+
.mclose{
|
|
649
|
+
background:none;border:none;color:var(--dim);cursor:pointer;
|
|
650
|
+
font-size:18px;line-height:1;padding:4px 6px;border-radius:4px;flex-shrink:0;
|
|
651
|
+
}
|
|
652
|
+
.mclose:hover{color:var(--text);background:var(--surf2)}
|
|
653
|
+
.mbody{padding:20px 22px;max-height:calc(90vh - 80px);overflow-y:auto}
|
|
654
|
+
.msec{margin-bottom:20px}
|
|
655
|
+
.msec-ttl{
|
|
656
|
+
font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.7px;
|
|
657
|
+
color:var(--dim);margin-bottom:10px;padding-bottom:6px;
|
|
658
|
+
border-bottom:1px solid var(--border2);
|
|
659
|
+
}
|
|
660
|
+
.kv{display:grid;grid-template-columns:130px 1fr;gap:6px 14px;font-size:13px}
|
|
661
|
+
.kk{color:var(--dim)}
|
|
662
|
+
.kv-val{word-break:break-all}
|
|
663
|
+
|
|
664
|
+
/* cmd block */
|
|
665
|
+
.cmd{
|
|
666
|
+
background:var(--surf2);border:1px solid var(--border2);
|
|
667
|
+
border-radius:6px;padding:10px 14px;margin-bottom:8px;
|
|
668
|
+
display:flex;align-items:center;gap:10px;
|
|
669
|
+
}
|
|
670
|
+
.cmd-lbl{font-size:11px;color:var(--subtle);margin-bottom:4px}
|
|
671
|
+
.cmd-text{font-family:var(--font);font-size:12px;flex:1;color:var(--cyan);overflow-x:auto;white-space:nowrap}
|
|
672
|
+
.copy-btn{
|
|
673
|
+
background:var(--surf);border:1px solid var(--border);
|
|
674
|
+
border-radius:5px;padding:4px 10px;color:var(--dim);
|
|
675
|
+
cursor:pointer;font-size:11px;transition:all .15s;white-space:nowrap;flex-shrink:0;
|
|
676
|
+
}
|
|
677
|
+
.copy-btn:hover{color:var(--text);border-color:var(--blue)}
|
|
678
|
+
.copy-btn.ok{color:var(--green);border-color:var(--green)}
|
|
679
|
+
|
|
680
|
+
/* vote rows */
|
|
681
|
+
.vrow{display:flex;align-items:center;gap:10px;padding:7px 0;border-bottom:1px solid var(--border2);font-size:12px}
|
|
682
|
+
.vrow:last-child{border-bottom:none}
|
|
683
|
+
.vch{font-weight:700;width:52px}
|
|
684
|
+
.vy{color:var(--green)}.vn{color:var(--red)}.va{color:var(--yellow)}
|
|
685
|
+
.vpk{font-family:var(--font);color:var(--dim);flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
686
|
+
.vts{color:var(--subtle);white-space:nowrap}
|
|
687
|
+
|
|
688
|
+
/* misc */
|
|
689
|
+
.empty{text-align:center;padding:52px 24px;color:var(--dim)}
|
|
690
|
+
.empty-ico{font-size:28px;margin-bottom:12px;opacity:.5}
|
|
691
|
+
.empty-txt{font-size:14px;margin-bottom:6px}
|
|
692
|
+
.empty-sub{font-size:12px;color:var(--subtle)}
|
|
693
|
+
.err-bar{
|
|
694
|
+
background:rgba(248,81,73,.08);border:1px solid rgba(248,81,73,.3);
|
|
695
|
+
border-radius:6px;padding:10px 14px;margin-bottom:16px;
|
|
696
|
+
font-size:12px;color:var(--red);
|
|
697
|
+
}
|
|
698
|
+
.reg-pill{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:11px}
|
|
699
|
+
.tbl-foot{color:var(--subtle);font-size:11px;padding:10px 14px;text-align:right}
|
|
700
|
+
|
|
701
|
+
/* ── forms & action buttons ── */
|
|
702
|
+
.form-row{margin-bottom:14px}
|
|
703
|
+
.form-lbl{display:block;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.6px;color:var(--dim);margin-bottom:6px}
|
|
704
|
+
.form-lbl small{text-transform:none;letter-spacing:0;font-weight:400;color:var(--subtle)}
|
|
705
|
+
.fi{width:100%;background:var(--surf2);border:1px solid var(--border);border-radius:6px;padding:9px 12px;color:var(--text);font-size:13px;outline:none;font-family:inherit;transition:border-color .15s;resize:vertical}
|
|
706
|
+
.fi:focus{border-color:var(--blue)}
|
|
707
|
+
select.fi option{background:var(--surf2)}
|
|
708
|
+
.fi-mono{font-family:var(--font);font-size:12px}
|
|
709
|
+
.form-err{color:var(--red);font-size:12px;padding:9px 13px;background:rgba(248,81,73,.08);border:1px solid rgba(248,81,73,.3);border-radius:5px;margin-top:10px;display:none}
|
|
710
|
+
.form-ok{color:var(--green);font-size:12px;padding:9px 13px;background:rgba(63,185,80,.08);border:1px solid rgba(63,185,80,.3);border-radius:5px;margin-top:10px;display:none}
|
|
711
|
+
.sec-note{padding:10px 14px;border-radius:6px;font-size:12px;margin-bottom:14px;line-height:1.6}
|
|
712
|
+
.sec-note-info{background:rgba(88,166,255,.07);border:1px solid rgba(88,166,255,.25);color:var(--blue)}
|
|
713
|
+
.sec-note-warn{background:rgba(248,81,73,.07);border:1px solid rgba(248,81,73,.25);color:var(--red)}
|
|
714
|
+
.sec-note-tip{background:rgba(210,153,34,.07);border:1px solid rgba(210,153,34,.25);color:var(--yellow)}
|
|
715
|
+
.radio-grp{display:flex;gap:8px;flex-wrap:wrap}
|
|
716
|
+
.radio-card{display:flex;align-items:center;gap:8px;padding:9px 14px;border:1px solid var(--border);border-radius:6px;cursor:pointer;transition:all .15s;flex:1;min-width:70px}
|
|
717
|
+
.radio-card:has(input:checked){border-color:var(--blue);background:rgba(88,166,255,.07)}
|
|
718
|
+
.radio-card input{accent-color:var(--blue)}
|
|
719
|
+
.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 18px;border-radius:6px;font-size:13px;font-weight:500;cursor:pointer;border:none;transition:all .15s;font-family:inherit;text-decoration:none;line-height:1.4;white-space:nowrap}
|
|
720
|
+
.btn:disabled{opacity:.4;cursor:not-allowed}
|
|
721
|
+
.btn-p{background:var(--blue);color:#000}.btn-p:hover:not(:disabled){filter:brightness(1.1)}
|
|
722
|
+
.btn-g{background:var(--green);color:#000}.btn-g:hover:not(:disabled){filter:brightness(1.1)}
|
|
723
|
+
.btn-r{background:rgba(248,81,73,.15);color:var(--red);border:1px solid rgba(248,81,73,.3)}.btn-r:hover:not(:disabled){background:rgba(248,81,73,.25)}
|
|
724
|
+
.btn-d{background:var(--surf2);color:var(--text);border:1px solid var(--border)}.btn-d:hover:not(:disabled){border-color:var(--blue)}
|
|
725
|
+
.btn-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:20px}
|
|
726
|
+
.act-btns{display:flex;gap:8px;flex-wrap:wrap;padding:14px 0 6px}
|
|
727
|
+
.hdr-btn{padding:5px 13px;border:1px solid var(--border);background:none;color:var(--dim);border-radius:6px;cursor:pointer;font-size:12px;font-family:inherit;transition:all .15s}
|
|
728
|
+
.hdr-btn:hover{color:var(--text);border-color:var(--blue)}
|
|
729
|
+
.hdr-btn-p{border-color:var(--blue);color:var(--blue);background:rgba(88,166,255,.08)}
|
|
730
|
+
.hdr-btn-p:hover{background:rgba(88,166,255,.15)}
|
|
731
|
+
</style>
|
|
732
|
+
</head>
|
|
733
|
+
<body>
|
|
734
|
+
|
|
735
|
+
<header>
|
|
736
|
+
<div class="logo"><span class="logo-hex">⬡</span> CKB Firewall</div>
|
|
737
|
+
<nav>
|
|
738
|
+
<button class="tab-btn active" data-tab="overview">Overview</button>
|
|
739
|
+
<button class="tab-btn" data-tab="registry">Registry</button>
|
|
740
|
+
<button class="tab-btn" data-tab="proposals">Proposals</button>
|
|
741
|
+
</nav>
|
|
742
|
+
<div style="display:flex;gap:6px;align-items:center;margin-left:auto">
|
|
743
|
+
<button class="hdr-btn hdr-btn-p" onclick="openCreate()">+ New Proposal</button>
|
|
744
|
+
<button class="hdr-btn" onclick="openImportForm()">⇧ Import</button>
|
|
745
|
+
</div>
|
|
746
|
+
<div class="hdr-status" style="margin-left:12px">
|
|
747
|
+
<span class="dot spin" id="dot"></span>
|
|
748
|
+
<span id="status-txt">Connecting…</span>
|
|
749
|
+
</div>
|
|
750
|
+
</header>
|
|
751
|
+
|
|
752
|
+
<div id="page-overview" class="page active"></div>
|
|
753
|
+
<div id="page-registry" class="page">
|
|
754
|
+
<div class="fbar">
|
|
755
|
+
<input id="reg-q" class="inp srch" placeholder="Search lock-args…" oninput="renderReg()">
|
|
756
|
+
<label class="ck"><input type="checkbox" id="reg-exp" onchange="renderReg()"> Show expired</label>
|
|
757
|
+
</div>
|
|
758
|
+
<div id="reg-body"></div>
|
|
759
|
+
</div>
|
|
760
|
+
<div id="page-proposals" class="page">
|
|
761
|
+
<div style="display:flex;justify-content:flex-end;margin-bottom:12px">
|
|
762
|
+
<button class="btn btn-p" onclick="openCreate()">+ New Proposal</button>
|
|
763
|
+
</div>
|
|
764
|
+
<div class="fbar">
|
|
765
|
+
<div class="ftabs" id="prop-ftabs">
|
|
766
|
+
<button class="ftab active" data-s="" onclick="setPF('')">All</button>
|
|
767
|
+
<button class="ftab" data-s="pending-review" onclick="setPF('pending-review')">Pending Review</button>
|
|
768
|
+
<button class="ftab" data-s="voting" onclick="setPF('voting')">Voting</button>
|
|
769
|
+
<button class="ftab" data-s="approved" onclick="setPF('approved')">Approved</button>
|
|
770
|
+
<button class="ftab" data-s="executed" onclick="setPF('executed')">Executed</button>
|
|
771
|
+
<button class="ftab" data-s="rejected" onclick="setPF('rejected')">Rejected</button>
|
|
772
|
+
</div>
|
|
773
|
+
</div>
|
|
774
|
+
<div id="prop-body"></div>
|
|
775
|
+
</div>
|
|
776
|
+
|
|
777
|
+
<div class="overlay hidden" id="overlay">
|
|
778
|
+
<div class="modal" id="modal">
|
|
779
|
+
<div class="mhdr">
|
|
780
|
+
<div class="mtitle" id="m-title"></div>
|
|
781
|
+
<button class="mclose" id="m-close">✕</button>
|
|
782
|
+
</div>
|
|
783
|
+
<div class="mbody" id="m-body"></div>
|
|
784
|
+
</div>
|
|
785
|
+
</div>
|
|
786
|
+
|
|
787
|
+
<div class="overlay hidden" id="act-overlay">
|
|
788
|
+
<div class="modal" id="act-modal" style="max-width:540px">
|
|
789
|
+
<div class="mhdr">
|
|
790
|
+
<div class="mtitle" id="act-title"></div>
|
|
791
|
+
<button class="mclose" id="act-close">✕</button>
|
|
792
|
+
</div>
|
|
793
|
+
<div class="mbody" id="act-body"></div>
|
|
794
|
+
</div>
|
|
795
|
+
</div>
|
|
796
|
+
|
|
797
|
+
<script>
|
|
798
|
+
'use strict';
|
|
799
|
+
|
|
800
|
+
// ── state ────────────────────────────────────────────────────────────────────
|
|
801
|
+
var D = { proposals: [], registry: null, meta: null };
|
|
802
|
+
var UI = { tab: 'overview', pf: '', err: null };
|
|
803
|
+
|
|
804
|
+
// ── utils ────────────────────────────────────────────────────────────────────
|
|
805
|
+
function h(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
|
806
|
+
function trunc(s,n){ return (!s||s.length<=n)?s:s.slice(0,Math.floor(n/2))+'…'+s.slice(-Math.floor(n/2)-1); }
|
|
807
|
+
function fmtDate(iso){ if(!iso)return'—'; var d=new Date(iso); return d.toISOString().slice(0,16).replace('T',' ')+' UTC'; }
|
|
808
|
+
function countdown(iso){
|
|
809
|
+
var ms=new Date(iso).getTime()-Date.now();
|
|
810
|
+
if(ms<=0)return '<span style="color:var(--green)">✓ passed</span>';
|
|
811
|
+
var h2=Math.floor(ms/3600000),m=Math.floor((ms%3600000)/60000);
|
|
812
|
+
return '<span style="color:var(--yellow)">'+h2+'h '+m+'m remaining</span>';
|
|
813
|
+
}
|
|
814
|
+
function countYes(votes){ return (votes||[]).filter(function(v){return v.vote==='yes';}).length; }
|
|
815
|
+
function sigCount(p){ return (p.signatures||[]).length; }
|
|
816
|
+
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; }
|
|
818
|
+
function prog(v,max){
|
|
819
|
+
var pct=Math.min(100,Math.round(v/max*100));
|
|
820
|
+
var cls=v>=max?'prog-g':'prog-y';
|
|
821
|
+
return '<span class="prog"><span class="prog-track"><span class="prog-fill '+cls+'" style="width:'+pct+'%"></span></span>'+v+'/'+max+'</span>';
|
|
822
|
+
}
|
|
823
|
+
function statusBadge(p){
|
|
824
|
+
if(isReady(p)) return '<span class="badge b-green">ready to execute</span>';
|
|
825
|
+
var m={'pending-review':'<span class="badge b-yellow">pending review</span>','voting':'<span class="badge b-cyan">voting</span>','approved':'<span class="badge b-blue">approved</span>','executed':'<span class="badge b-dim">executed</span>','rejected':'<span class="badge b-red">rejected</span>'};
|
|
826
|
+
return m[p.status]||'<span class="badge b-dim">'+h(p.status)+'</span>';
|
|
827
|
+
}
|
|
828
|
+
function actionBadge(p){
|
|
829
|
+
var cls=p.action==='add'?'add-col':'rem-col';
|
|
830
|
+
return '<span class="card-action '+cls+'">'+h(p.action)+'</span>';
|
|
831
|
+
}
|
|
832
|
+
function empty(txt,sub){
|
|
833
|
+
return '<div class="empty"><div class="empty-ico">○</div><div class="empty-txt">'+h(txt)+'</div><div class="empty-sub">'+h(sub)+'</div></div>';
|
|
834
|
+
}
|
|
835
|
+
function cmdRow(label,cmd,key){
|
|
836
|
+
return '<div class="cmd-lbl">'+h(label)+'</div>'
|
|
837
|
+
+'<div class="cmd"><span class="cmd-text">'+h(cmd)+'</span>'
|
|
838
|
+
+'<button class="copy-btn" data-copy="'+h(cmd)+'" data-key="'+h(key)+'">Copy</button></div>';
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// lookup maps
|
|
842
|
+
function pByAddr(){
|
|
843
|
+
var m={};
|
|
844
|
+
D.proposals.forEach(function(p){ var k=(p.lockArgs||'').toLowerCase(); if(!m[k])m[k]=[]; m[k].push(p); });
|
|
845
|
+
return m;
|
|
846
|
+
}
|
|
847
|
+
function regByAddr(){
|
|
848
|
+
var m={};
|
|
849
|
+
if(D.registry&&D.registry.entries) D.registry.entries.forEach(function(e){ m[e.identifier.toLowerCase()]=e; });
|
|
850
|
+
return m;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ── data fetch ────────────────────────────────────────────────────────────────
|
|
854
|
+
function load(){
|
|
855
|
+
document.getElementById('dot').className='dot spin';
|
|
856
|
+
return fetch('/api/data')
|
|
857
|
+
.then(function(r){return r.json();})
|
|
858
|
+
.then(function(d){
|
|
859
|
+
D.proposals=d.proposals||[];
|
|
860
|
+
D.registry=d.registry||{entries:[],error:null};
|
|
861
|
+
D.meta=d.meta||{};
|
|
862
|
+
UI.err=null;
|
|
863
|
+
document.getElementById('dot').className=D.registry.error?'dot err':'dot ok';
|
|
864
|
+
document.getElementById('status-txt').textContent='Updated '+new Date().toLocaleTimeString();
|
|
865
|
+
render();
|
|
866
|
+
})
|
|
867
|
+
.catch(function(e){
|
|
868
|
+
UI.err=e.message;
|
|
869
|
+
document.getElementById('dot').className='dot err';
|
|
870
|
+
document.getElementById('status-txt').textContent='Error';
|
|
871
|
+
render();
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
setInterval(load,15000);
|
|
875
|
+
|
|
876
|
+
// ── tab routing ───────────────────────────────────────────────────────────────
|
|
877
|
+
document.querySelectorAll('.tab-btn').forEach(function(b){
|
|
878
|
+
b.addEventListener('click',function(){ setTab(b.dataset.tab); });
|
|
879
|
+
});
|
|
880
|
+
function setTab(name){
|
|
881
|
+
UI.tab=name;
|
|
882
|
+
document.querySelectorAll('.tab-btn').forEach(function(b){ b.classList.toggle('active',b.dataset.tab===name); });
|
|
883
|
+
document.querySelectorAll('.page').forEach(function(p){ p.classList.toggle('active',p.id==='page-'+name); });
|
|
884
|
+
}
|
|
885
|
+
function setPF(s){
|
|
886
|
+
UI.pf=s;
|
|
887
|
+
document.querySelectorAll('.ftab').forEach(function(b){ b.classList.toggle('active',b.dataset.s===s); });
|
|
888
|
+
renderProps();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// ── overview ──────────────────────────────────────────────────────────────────
|
|
892
|
+
function renderOverview(){
|
|
893
|
+
var ps=D.proposals;
|
|
894
|
+
var entries=(D.registry&&D.registry.entries)||[];
|
|
895
|
+
var now=Date.now()/1000;
|
|
896
|
+
var active=entries.filter(function(e){return !e.expiresAt||Number(e.expiresAt)>now;}).length;
|
|
897
|
+
var expired=entries.filter(function(e){return e.expiresAt&&Number(e.expiresAt)<=now;}).length;
|
|
898
|
+
var open=ps.filter(function(p){return p.status==='pending-review'||p.status==='voting';});
|
|
899
|
+
var ready=ps.filter(isReady);
|
|
900
|
+
var approved=ps.filter(function(p){return p.status==='approved'&&!isReady(p);});
|
|
901
|
+
var executed=ps.filter(function(p){return p.status==='executed';});
|
|
902
|
+
|
|
903
|
+
var out='';
|
|
904
|
+
if(UI.err) out+='<div class="err-bar">⚠ Could not reach server: '+h(UI.err)+'</div>';
|
|
905
|
+
if(D.registry&&D.registry.error) out+='<div class="err-bar">⚠ Registry unavailable — '+h(D.registry.error)+' <span style="color:var(--subtle);font-size:11px">(proposals still shown below)</span></div>';
|
|
906
|
+
|
|
907
|
+
// stats
|
|
908
|
+
out+='<div class="stats">';
|
|
909
|
+
out+=stat(String(active),'Active Entries',expired?expired+' expired':'','var(--cyan)');
|
|
910
|
+
out+=stat(String(open.length),'Open for Voting',open.length?'need your vote':'','var(--blue)');
|
|
911
|
+
out+=stat(String(ready.length),'Ready to Execute','','var(--green)');
|
|
912
|
+
out+=stat(String(approved.length),'Awaiting Sigs','','var(--yellow)');
|
|
913
|
+
out+=stat(String(executed.length),'Executed','historical','var(--dim)');
|
|
914
|
+
out+='</div>';
|
|
915
|
+
|
|
916
|
+
// action required
|
|
917
|
+
var action=ready.concat(open);
|
|
918
|
+
if(action.length){
|
|
919
|
+
out+='<div class="section"><div class="sec-hdr">⚡ Action Required <span class="badge b-yellow" style="margin-left:4px">'+action.length+'</span></div>';
|
|
920
|
+
action.forEach(function(p){ out+=propCard(p,true); });
|
|
921
|
+
out+='</div>';
|
|
922
|
+
}
|
|
923
|
+
// awaiting signatures
|
|
924
|
+
if(approved.length){
|
|
925
|
+
out+='<div class="section"><div class="sec-hdr">✎ Awaiting Signatures</div>';
|
|
926
|
+
approved.forEach(function(p){ out+=propCard(p,true); });
|
|
927
|
+
out+='</div>';
|
|
928
|
+
}
|
|
929
|
+
// recent
|
|
930
|
+
var recent=ps.slice().sort(function(a,b){return b.submittedAt.localeCompare(a.submittedAt);}).slice(0,6);
|
|
931
|
+
if(recent.length){
|
|
932
|
+
out+='<div class="section"><div class="sec-hdr">📋 Recent Proposals</div>';
|
|
933
|
+
recent.forEach(function(p){ out+=propCard(p,false); });
|
|
934
|
+
out+='</div>';
|
|
935
|
+
} else if(!UI.err){
|
|
936
|
+
out+=empty('No proposals yet','Run: ckb-firewall propose');
|
|
937
|
+
}
|
|
938
|
+
document.getElementById('page-overview').innerHTML=out;
|
|
939
|
+
}
|
|
940
|
+
function stat(val,lbl,sub,color){
|
|
941
|
+
return '<div class="stat"><div class="stat-val" style="color:'+color+'">'+h(val)+'</div>'
|
|
942
|
+
+'<div class="stat-lbl">'+h(lbl)+'</div>'+(sub?'<div class="stat-sub">'+h(sub)+'</div>':'')+'</div>';
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ── registry ──────────────────────────────────────────────────────────────────
|
|
946
|
+
function renderReg(){
|
|
947
|
+
var q=((document.getElementById('reg-q')||{}).value||'').toLowerCase().trim();
|
|
948
|
+
var showExp=((document.getElementById('reg-exp')||{}).checked)||false;
|
|
949
|
+
var pa=pByAddr();
|
|
950
|
+
var out='';
|
|
951
|
+
|
|
952
|
+
if(!D.registry){out+='<div style="color:var(--dim);padding:40px;text-align:center">Loading registry…</div>';document.getElementById('reg-body').innerHTML=out;return;}
|
|
953
|
+
if(D.registry.error) out+='<div class="err-bar">'+h(D.registry.error)+'</div>';
|
|
954
|
+
|
|
955
|
+
var now=Date.now()/1000;
|
|
956
|
+
var all=D.registry.entries||[];
|
|
957
|
+
var shown=all.filter(function(e){
|
|
958
|
+
var exp=e.expiresAt?Number(e.expiresAt):null;
|
|
959
|
+
var isExp=exp&&exp<=now;
|
|
960
|
+
if(!showExp&&isExp)return false;
|
|
961
|
+
if(q&&!e.identifier.toLowerCase().includes(q))return false;
|
|
962
|
+
return true;
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
if(!shown.length){
|
|
966
|
+
document.getElementById('reg-body').innerHTML=empty(q?'No matching entries':'Registry is empty',q?'Try a different search':'');
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
out+='<div class="tbl-wrap"><table><thead><tr>'
|
|
971
|
+
+'<th>Lock Args</th><th>Expires</th><th>Status</th><th>Proposals</th>'
|
|
972
|
+
+'</tr></thead><tbody>';
|
|
973
|
+
shown.forEach(function(e){
|
|
974
|
+
var exp=e.expiresAt?Number(e.expiresAt):null;
|
|
975
|
+
var isExp=exp&&exp<=now;
|
|
976
|
+
var expTxt=!exp?'never':new Date(exp*1000).toISOString().slice(0,10);
|
|
977
|
+
var statusCell=isExp?'<span class="badge b-red">expired</span>':'<span class="badge b-green">active</span>';
|
|
978
|
+
var rel=pa[e.identifier.toLowerCase()]||[];
|
|
979
|
+
var propCell=!rel.length?'<span style="color:var(--subtle)">—</span>':rel.map(function(p){
|
|
980
|
+
var bc=p.status==='executed'?'b-dim':p.status==='rejected'?'b-red':'b-yellow';
|
|
981
|
+
return '<span class="badge '+bc+' click" style="cursor:pointer;margin-right:4px" data-pid="'+h(p.id)+'">'+h(p.action)+' #'+h(p.id)+'</span>';
|
|
982
|
+
}).join('');
|
|
983
|
+
out+='<tr class="click" data-addr="'+h(e.identifier)+'">'
|
|
984
|
+
+'<td><code class="la">'+h(e.identifier)+'</code></td>'
|
|
985
|
+
+'<td style="color:'+(isExp?'var(--red)':'var(--dim)')+'">'+h(expTxt)+'</td>'
|
|
986
|
+
+'<td>'+statusCell+'</td>'
|
|
987
|
+
+'<td>'+propCell+'</td>'
|
|
988
|
+
+'</tr>';
|
|
989
|
+
});
|
|
990
|
+
out+='</tbody></table>';
|
|
991
|
+
out+='<div class="tbl-foot">'+shown.length+' of '+all.length+' entries'+(D.registry.txHash?' · cell <code style="font-size:11px">'+h(D.registry.txHash.slice(0,18))+'…</code>':'')+'</div>';
|
|
992
|
+
out+='</div>';
|
|
993
|
+
document.getElementById('reg-body').innerHTML=out;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// ── proposals ─────────────────────────────────────────────────────────────────
|
|
997
|
+
function renderProps(){
|
|
998
|
+
var ps=D.proposals.filter(function(p){return !UI.pf||p.status===UI.pf;})
|
|
999
|
+
.slice().sort(function(a,b){return b.submittedAt.localeCompare(a.submittedAt);});
|
|
1000
|
+
document.getElementById('prop-body').innerHTML=
|
|
1001
|
+
ps.length?ps.map(function(p){return propCard(p,false);}).join(''):empty('No proposals','Run: ckb-firewall propose');
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// ── proposal card ─────────────────────────────────────────────────────────────
|
|
1005
|
+
function propCard(p,compact){
|
|
1006
|
+
var rb=regByAddr();
|
|
1007
|
+
var e=rb[(p.lockArgs||'').toLowerCase()];
|
|
1008
|
+
var now=Date.now()/1000;
|
|
1009
|
+
var inReg=!!e;
|
|
1010
|
+
var isExpReg=inReg&&e.expiresAt&&Number(e.expiresAt)<=now;
|
|
1011
|
+
var regHint=!inReg?'<span style="color:var(--subtle);font-size:11px">not in registry</span>'
|
|
1012
|
+
:isExpReg?'<span class="badge b-dim">registry: expired</span>'
|
|
1013
|
+
:'<span class="badge b-green">registry: active</span>';
|
|
1014
|
+
|
|
1015
|
+
var out='<div class="card" data-pid="'+h(p.id)+'">';
|
|
1016
|
+
out+='<div class="card-top">';
|
|
1017
|
+
out+=actionBadge(p);
|
|
1018
|
+
out+='<code class="card-args">'+trunc(h(p.lockArgs),42)+'</code>';
|
|
1019
|
+
out+='<span class="card-id">#'+h(p.id)+'</span>';
|
|
1020
|
+
out+='<span style="flex:1"></span>';
|
|
1021
|
+
out+=statusBadge(p);
|
|
1022
|
+
out+='</div>';
|
|
1023
|
+
out+='<div class="card-foot">';
|
|
1024
|
+
out+=prog(countYes(p.votes),3);
|
|
1025
|
+
out+=prog(sigCount(p),3);
|
|
1026
|
+
out+='<span style="flex:1"></span>';
|
|
1027
|
+
out+=regHint;
|
|
1028
|
+
// quick action buttons
|
|
1029
|
+
if((p.status==='pending-review'||p.status==='voting')&&!p.signatures.length){
|
|
1030
|
+
out+='<button class="btn btn-p" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openVoteForm(\''+h(p.id)+'\')">Vote →</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 →</button>';
|
|
1033
|
+
} else if(isReady(p)){
|
|
1034
|
+
out+='<button class="btn btn-p" style="padding:3px 10px;font-size:11px" onclick="event.stopPropagation();openExecuteForm(\''+h(p.id)+'\')">Execute →</button>';
|
|
1035
|
+
}
|
|
1036
|
+
out+='<a class="btn btn-d" style="padding:3px 10px;font-size:11px" href="/api/export/'+h(p.id)+'" download="proposal-'+h(p.id)+'.json" onclick="event.stopPropagation()">↓ JSON</a>';
|
|
1037
|
+
out+='</div>';
|
|
1038
|
+
if(!compact){
|
|
1039
|
+
out+='<div class="card-meta">';
|
|
1040
|
+
out+=h(p.classification)+' / '+h(p.severity)+' · ';
|
|
1041
|
+
out+='Review: '+countdown(p.reviewWindowEndsAt)+' · ';
|
|
1042
|
+
out+='by '+h(p.proposer);
|
|
1043
|
+
if(p.evidence){
|
|
1044
|
+
var isUrl=/^https?:\/\//.test(p.evidence);
|
|
1045
|
+
out+='<br>Evidence: '+(isUrl?'<a href="'+h(p.evidence)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()">'+h(p.evidence)+'</a>':h(p.evidence.slice(0,80))+(p.evidence.length>80?'…':''));
|
|
1046
|
+
}
|
|
1047
|
+
out+='</div>';
|
|
1048
|
+
}
|
|
1049
|
+
out+='</div>';
|
|
1050
|
+
return out;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// ── proposal modal ────────────────────────────────────────────────────────────
|
|
1054
|
+
function openProposal(id){
|
|
1055
|
+
var p=D.proposals.find(function(x){return x.id===id;});
|
|
1056
|
+
if(!p)return;
|
|
1057
|
+
var rb=regByAddr();
|
|
1058
|
+
var e=rb[(p.lockArgs||'').toLowerCase()];
|
|
1059
|
+
var now=Date.now()/1000;
|
|
1060
|
+
var inReg=!!e;
|
|
1061
|
+
var isExpReg=inReg&&e.expiresAt&&Number(e.expiresAt)<=now;
|
|
1062
|
+
|
|
1063
|
+
var regVal=!inReg?'<span style="color:var(--subtle)">not in registry</span>'
|
|
1064
|
+
:isExpReg?'<span class="badge b-dim">expired — '+new Date(Number(e.expiresAt)*1000).toISOString().slice(0,10)+'</span>'
|
|
1065
|
+
:e.expiresAt?'<span class="badge b-green">active — expires '+new Date(Number(e.expiresAt)*1000).toISOString().slice(0,10)+'</span>'
|
|
1066
|
+
:'<span class="badge b-green">active — permanent</span>';
|
|
1067
|
+
|
|
1068
|
+
var out='';
|
|
1069
|
+
|
|
1070
|
+
// metadata
|
|
1071
|
+
out+='<div class="msec"><div class="kv">';
|
|
1072
|
+
out+='<span class="kk">Action</span><span class="kv-val">'+actionBadge(p)+'</span>';
|
|
1073
|
+
out+='<span class="kk">Lock Args</span><span class="kv-val"><code style="font-size:11px;word-break:break-all">'+h(p.lockArgs)+'</code></span>';
|
|
1074
|
+
out+='<span class="kk">Registry</span><span class="kv-val">'+regVal+'</span>';
|
|
1075
|
+
out+='<span class="kk">Classification</span><span class="kv-val">'+h(p.classification)+' / '+h(p.severity)+'</span>';
|
|
1076
|
+
out+='<span class="kk">Proposer</span><span class="kv-val">'+h(p.proposer)+'</span>';
|
|
1077
|
+
out+='<span class="kk">Submitted</span><span class="kv-val">'+fmtDate(p.submittedAt)+'</span>';
|
|
1078
|
+
out+='<span class="kk">Review ends</span><span class="kv-val">'+countdown(p.reviewWindowEndsAt)+'</span>';
|
|
1079
|
+
if(p.expiresAt&&p.expiresAt!=='0'){
|
|
1080
|
+
out+='<span class="kk">Entry expires</span><span class="kv-val">'+fmtDate(new Date(Number(p.expiresAt)*1000).toISOString())+'</span>';
|
|
1081
|
+
}
|
|
1082
|
+
if(p.txHash){
|
|
1083
|
+
out+='<span class="kk">Tx Hash</span><span class="kv-val"><code style="font-size:11px">'+h(p.txHash)+'</code></span>';
|
|
1084
|
+
}
|
|
1085
|
+
out+='</div>';
|
|
1086
|
+
|
|
1087
|
+
if(p.evidence){
|
|
1088
|
+
var isUrl=/^https?:\/\//.test(p.evidence);
|
|
1089
|
+
out+='<div style="margin-top:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:.6px">Evidence</div>';
|
|
1090
|
+
out+=isUrl?'<a href="'+h(p.evidence)+'" target="_blank" rel="noopener" style="font-size:13px">'+h(p.evidence)+'</a>'
|
|
1091
|
+
:'<p style="font-size:13px;color:var(--dim)">'+h(p.evidence)+'</p>';
|
|
1092
|
+
out+='</div>';
|
|
1093
|
+
}
|
|
1094
|
+
if(p.rationale){
|
|
1095
|
+
out+='<div style="margin-top:12px"><div style="font-size:11px;color:var(--dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:.6px">Rationale</div>';
|
|
1096
|
+
out+='<p style="font-size:13px;color:var(--dim);line-height:1.7">'+h(p.rationale)+'</p></div>';
|
|
1097
|
+
}
|
|
1098
|
+
out+='</div>';
|
|
1099
|
+
|
|
1100
|
+
// votes
|
|
1101
|
+
out+='<div class="msec"><div class="msec-ttl">Votes '+prog(countYes(p.votes),3)+'</div>';
|
|
1102
|
+
var votes=p.votes||[];
|
|
1103
|
+
if(!votes.length){out+='<div style="color:var(--subtle);font-size:12px">No votes yet.</div>';}
|
|
1104
|
+
else{votes.forEach(function(v){
|
|
1105
|
+
var vc=v.vote==='yes'?'vy':v.vote==='no'?'vn':'va';
|
|
1106
|
+
out+='<div class="vrow"><span class="vch '+vc+'">'+v.vote.toUpperCase()+'</span>'
|
|
1107
|
+
+'<code class="vpk">'+h(v.pubkey.slice(0,20))+'…</code>'
|
|
1108
|
+
+'<span class="vts">'+fmtDate(v.timestamp)+'</span></div>';
|
|
1109
|
+
});}
|
|
1110
|
+
out+='</div>';
|
|
1111
|
+
|
|
1112
|
+
// signatures
|
|
1113
|
+
var sigs=p.signatures||[];
|
|
1114
|
+
out+='<div class="msec"><div class="msec-ttl">Signatures '+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))+'…</code>'
|
|
1119
|
+
+'<span class="vts">'+fmtDate(s.timestamp)+'</span></div>';
|
|
1120
|
+
});}
|
|
1121
|
+
out+='</div>';
|
|
1122
|
+
|
|
1123
|
+
// Actions
|
|
1124
|
+
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){
|
|
1126
|
+
out+='<button class="btn btn-p" onclick="closeModal();openVoteForm(\''+h(p.id)+'\')">Cast Vote</button>';
|
|
1127
|
+
}
|
|
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
|
+
if(isReady(p)){
|
|
1132
|
+
out+='<button class="btn btn-p" onclick="closeModal();openExecuteForm(\''+h(p.id)+'\')">Build Execute TX</button>';
|
|
1133
|
+
}
|
|
1134
|
+
out+='<a class="btn btn-d" href="/api/export/'+h(p.id)+'" download="proposal-'+h(p.id)+'.json">↓ Export JSON</a>';
|
|
1135
|
+
out+='</div></div>';
|
|
1136
|
+
|
|
1137
|
+
// CLI commands
|
|
1138
|
+
out+='<div class="msec"><div class="msec-ttl">CLI Commands</div>';
|
|
1139
|
+
if(p.status==='pending-review'||p.status==='voting'){
|
|
1140
|
+
out+=cmdRow('Vote on this proposal','ckb-firewall vote --proposal '+p.id,'cmd-v');
|
|
1141
|
+
}
|
|
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
|
+
if(isReady(p)){
|
|
1146
|
+
out+=cmdRow('Execute on-chain','ckb-firewall execute --proposal '+p.id,'cmd-e');
|
|
1147
|
+
}
|
|
1148
|
+
out+=cmdRow('Export to JSON file','ckb-firewall export --proposal '+p.id+' --out proposal-'+p.id+'.json','cmd-ex');
|
|
1149
|
+
out+=cmdRow('Check address in registry','ckb-firewall check --lock-args '+p.lockArgs,'cmd-ck');
|
|
1150
|
+
out+='</div>';
|
|
1151
|
+
|
|
1152
|
+
document.getElementById('m-title').innerHTML=
|
|
1153
|
+
actionBadge(p)+' <code style="font-size:12px">'+trunc(h(p.lockArgs),40)+'</code>'
|
|
1154
|
+
+' <span style="color:var(--subtle);font-size:12px">#'+h(p.id)+'</span>';
|
|
1155
|
+
document.getElementById('m-body').innerHTML=out;
|
|
1156
|
+
document.getElementById('overlay').classList.remove('hidden');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// ── address modal ─────────────────────────────────────────────────────────────
|
|
1160
|
+
function openAddr(identifier){
|
|
1161
|
+
var rb=regByAddr();
|
|
1162
|
+
var e=rb[identifier.toLowerCase()];
|
|
1163
|
+
var pa=pByAddr();
|
|
1164
|
+
var related=pa[identifier.toLowerCase()]||[];
|
|
1165
|
+
var now=Date.now()/1000;
|
|
1166
|
+
var exp=e&&e.expiresAt?Number(e.expiresAt):null;
|
|
1167
|
+
var isExp=exp&&exp<=now;
|
|
1168
|
+
|
|
1169
|
+
var out='<div class="msec">';
|
|
1170
|
+
out+='<div class="msec-ttl">Identifier</div>';
|
|
1171
|
+
out+='<div class="cmd"><span class="cmd-text">'+h(identifier)+'</span><button class="copy-btn" data-copy="'+h(identifier)+'" data-key="addr-c">Copy</button></div>';
|
|
1172
|
+
out+='<div style="margin-top:10px">';
|
|
1173
|
+
if(!e){out+='<span style="color:var(--subtle)">Not currently in registry</span>';}
|
|
1174
|
+
else if(isExp){out+='<span class="badge b-red">Expired</span> <span style="color:var(--dim);font-size:12px">since '+new Date(exp*1000).toISOString().slice(0,10)+'</span>';}
|
|
1175
|
+
else if(exp){out+='<span class="badge b-green">Active</span> <span style="color:var(--dim);font-size:12px">until '+new Date(exp*1000).toISOString().slice(0,10)+'</span>';}
|
|
1176
|
+
else{out+='<span class="badge b-green">Active — permanent</span>';}
|
|
1177
|
+
out+='</div></div>';
|
|
1178
|
+
|
|
1179
|
+
out+='<div class="msec"><div class="msec-ttl">Check Command</div>';
|
|
1180
|
+
out+=cmdRow('','ckb-firewall check --lock-args '+identifier,'addr-ck');
|
|
1181
|
+
out+='</div>';
|
|
1182
|
+
|
|
1183
|
+
if(related.length){
|
|
1184
|
+
out+='<div class="msec"><div class="msec-ttl">Governance Proposals ('+related.length+')</div>';
|
|
1185
|
+
related.forEach(function(p){
|
|
1186
|
+
out+='<div class="card" data-pid="'+h(p.id)+'" style="margin-bottom:8px">';
|
|
1187
|
+
out+='<div class="card-top">'+actionBadge(p)+'<span class="card-id">#'+h(p.id)+'</span><span style="flex:1"></span>'+statusBadge(p)+'</div>';
|
|
1188
|
+
out+='<div class="card-meta">'+h(p.classification)+' / '+h(p.severity)+' · '+fmtDate(p.submittedAt)+'</div>';
|
|
1189
|
+
out+='<div class="card-foot">'+prog(countYes(p.votes),3)+prog(sigCount(p),3)+'</div>';
|
|
1190
|
+
out+='</div>';
|
|
1191
|
+
});
|
|
1192
|
+
out+='</div>';
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
document.getElementById('m-title').innerHTML='<code style="font-size:12px">'+trunc(h(identifier),44)+'</code>';
|
|
1196
|
+
document.getElementById('m-body').innerHTML=out;
|
|
1197
|
+
document.getElementById('overlay').classList.remove('hidden');
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// ── modal close ───────────────────────────────────────────────────────────────
|
|
1201
|
+
document.getElementById('m-close').addEventListener('click',function(){ closeModal(); });
|
|
1202
|
+
document.getElementById('overlay').addEventListener('click',function(ev){ if(ev.target===document.getElementById('overlay'))closeModal(); });
|
|
1203
|
+
document.getElementById('act-close').addEventListener('click',function(){ closeAct(); });
|
|
1204
|
+
document.getElementById('act-overlay').addEventListener('click',function(ev){ if(ev.target===document.getElementById('act-overlay'))closeAct(); });
|
|
1205
|
+
document.addEventListener('keydown',function(ev){ if(ev.key==='Escape'){ closeAct(); closeModal(); } });
|
|
1206
|
+
function closeModal(){ document.getElementById('overlay').classList.add('hidden'); }
|
|
1207
|
+
function closeAct(){ document.getElementById('act-overlay').classList.add('hidden'); }
|
|
1208
|
+
function openAct(title,html){
|
|
1209
|
+
document.getElementById('act-title').textContent=title;
|
|
1210
|
+
document.getElementById('act-body').innerHTML=html;
|
|
1211
|
+
document.getElementById('act-overlay').classList.remove('hidden');
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// ── form helpers ──────────────────────────────────────────────────────────────
|
|
1215
|
+
function showErr(id,msg){ var el=document.getElementById(id); if(el){el.textContent=msg;el.style.display='block';} }
|
|
1216
|
+
function showOk(id,msg){ var el=document.getElementById(id); if(el){el.textContent=msg;el.style.display='block';} }
|
|
1217
|
+
function hideMsg(id){ var el=document.getElementById(id); if(el)el.style.display='none'; }
|
|
1218
|
+
function setDisabled(id,v){ var el=document.getElementById(id); if(el)el.disabled=v; }
|
|
1219
|
+
function fval(id){ var el=document.getElementById(id); return el?el.value:''; }
|
|
1220
|
+
function fsel(id){ var el=document.getElementById(id); return el?el.value:''; }
|
|
1221
|
+
function fcheck(id){ var el=document.getElementById(id); return el?el.checked:false; }
|
|
1222
|
+
|
|
1223
|
+
// ── create proposal form ──────────────────────────────────────────────────────
|
|
1224
|
+
function openCreate(){
|
|
1225
|
+
var html='';
|
|
1226
|
+
html+='<div class="sec-note sec-note-info">🔒 Your key is never sent to the server. Proposals are stored locally.</div>';
|
|
1227
|
+
html+='<div class="form-row"><label class="form-lbl">Action</label>';
|
|
1228
|
+
html+='<div class="radio-grp">';
|
|
1229
|
+
html+='<label class="radio-card"><input type="radio" name="c-action" value="add" checked onchange="toggleExpiresAt()"> <span>➕ Add to blacklist</span></label>';
|
|
1230
|
+
html+='<label class="radio-card"><input type="radio" name="c-action" value="remove" onchange="toggleExpiresAt()"> <span>➖ Remove from blacklist</span></label>';
|
|
1231
|
+
html+='</div></div>';
|
|
1232
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-lockargs">Lock Args <small>(0x-prefixed hex)</small></label>';
|
|
1233
|
+
html+='<input id="c-lockargs" class="fi fi-mono" placeholder="0xabc123..." autocomplete="off"></div>';
|
|
1234
|
+
html+='<div class="form-row" id="c-exprow"><label class="form-lbl" for="c-expires">Expires At <small>(Unix timestamp, 0 = never)</small></label>';
|
|
1235
|
+
html+='<input id="c-expires" class="fi" type="number" min="0" value="0" placeholder="0"></div>';
|
|
1236
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-cls">Classification</label>';
|
|
1237
|
+
html+='<select id="c-cls" class="fi"><option value="theft">theft</option><option value="scam">scam</option><option value="hack">hack</option><option value="sanctions">sanctions</option><option value="other">other</option></select></div>';
|
|
1238
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-sev">Severity</label>';
|
|
1239
|
+
html+='<select id="c-sev" class="fi"><option value="critical">critical</option><option value="high">high</option><option value="medium">medium</option><option value="low">low</option></select></div>';
|
|
1240
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-evidence">Evidence <small>(URL or description)</small></label>';
|
|
1241
|
+
html+='<textarea id="c-evidence" class="fi" rows="2" placeholder="https://... or describe the evidence"></textarea></div>';
|
|
1242
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-rationale">Rationale <small>(min 20 chars)</small></label>';
|
|
1243
|
+
html+='<textarea id="c-rationale" class="fi" rows="3" placeholder="Why should this address be added/removed..."></textarea></div>';
|
|
1244
|
+
html+='<div class="form-row"><label class="form-lbl" for="c-proposer">Proposer <small>(your name or handle)</small></label>';
|
|
1245
|
+
html+='<input id="c-proposer" class="fi" placeholder="e.g. alice"></div>';
|
|
1246
|
+
html+='<div id="c-err" class="form-err"></div>';
|
|
1247
|
+
html+='<div id="c-ok" class="form-ok"></div>';
|
|
1248
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="c-submit" onclick="submitCreate()">Create Proposal</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1249
|
+
openAct('New Proposal',html);
|
|
1250
|
+
}
|
|
1251
|
+
function toggleExpiresAt(){
|
|
1252
|
+
var add=document.querySelector('input[name="c-action"]:checked');
|
|
1253
|
+
var row=document.getElementById('c-exprow');
|
|
1254
|
+
if(row) row.style.display=(add&&add.value==='add')?'':'none';
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function submitCreate(){
|
|
1258
|
+
hideMsg('c-err'); hideMsg('c-ok'); setDisabled('c-submit',true);
|
|
1259
|
+
var action=(document.querySelector('input[name="c-action"]:checked')||{value:'add'}).value;
|
|
1260
|
+
var body={action:action,lockArgs:fval('c-lockargs').trim(),evidence:fval('c-evidence').trim(),
|
|
1261
|
+
classification:fsel('c-cls'),severity:fsel('c-sev'),rationale:fval('c-rationale').trim(),
|
|
1262
|
+
proposer:fval('c-proposer').trim(),expiresAt:action==='add'?fval('c-expires')||'0':'0'};
|
|
1263
|
+
fetch('/api/propose',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
1264
|
+
.then(function(r){return r.json();})
|
|
1265
|
+
.then(function(d){
|
|
1266
|
+
setDisabled('c-submit',false);
|
|
1267
|
+
if(!d.ok){showErr('c-err',d.error||'Unknown error');return;}
|
|
1268
|
+
showOk('c-ok','Proposal #'+d.id+' created!');
|
|
1269
|
+
load().then(function(){ setTimeout(closeAct,1200); });
|
|
1270
|
+
})
|
|
1271
|
+
.catch(function(e){ setDisabled('c-submit',false); showErr('c-err',e.message); });
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ── vote form ─────────────────────────────────────────────────────────────────
|
|
1275
|
+
function openVoteForm(id){
|
|
1276
|
+
var p=D.proposals.find(function(x){return x.id===id;});
|
|
1277
|
+
if(!p)return;
|
|
1278
|
+
var html='';
|
|
1279
|
+
html+='<div class="sec-note sec-note-warn">🔒 Your private key is used only locally (loopback) and zeroed immediately after signing. It is never stored or logged.</div>';
|
|
1280
|
+
html+='<div class="form-row"><label class="form-lbl">Vote</label>';
|
|
1281
|
+
html+='<div class="radio-grp">';
|
|
1282
|
+
html+='<label class="radio-card"><input type="radio" name="v-vote" value="yes" checked> <span style="color:var(--green)">✓ Yes</span></label>';
|
|
1283
|
+
html+='<label class="radio-card"><input type="radio" name="v-vote" value="no"> <span style="color:var(--red)">✗ No</span></label>';
|
|
1284
|
+
html+='<label class="radio-card"><input type="radio" name="v-vote" value="abstain"> <span style="color:var(--yellow)">— Abstain</span></label>';
|
|
1285
|
+
html+='</div></div>';
|
|
1286
|
+
html+='<div class="form-row"><label class="form-lbl" for="v-pk">Private Key <small>(32 bytes, hex)</small></label>';
|
|
1287
|
+
html+='<input id="v-pk" class="fi fi-mono" type="password" placeholder="64 hex chars" autocomplete="off" autocorrect="off" spellcheck="false"></div>';
|
|
1288
|
+
html+='<div id="v-err" class="form-err"></div>';
|
|
1289
|
+
html+='<div id="v-ok" class="form-ok"></div>';
|
|
1290
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="v-submit" onclick="submitVote(\''+h(id)+'\')">Cast Vote</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1291
|
+
openAct('Vote — Proposal #'+h(id),html);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function submitVote(id){
|
|
1295
|
+
hideMsg('v-err'); hideMsg('v-ok'); setDisabled('v-submit',true);
|
|
1296
|
+
var choice=(document.querySelector('input[name="v-vote"]:checked')||{value:'yes'}).value;
|
|
1297
|
+
var body={proposalId:id,vote:choice,privateKey:fval('v-pk').trim()};
|
|
1298
|
+
fetch('/api/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
|
|
1299
|
+
.then(function(r){return r.json();})
|
|
1300
|
+
.then(function(d){
|
|
1301
|
+
setDisabled('v-submit',false);
|
|
1302
|
+
document.getElementById('v-pk').value='';
|
|
1303
|
+
if(!d.ok){showErr('v-err',d.error||'Unknown error');return;}
|
|
1304
|
+
showOk('v-ok','Vote recorded! Yes: '+d.yesCount+'/3 — Status: '+d.status);
|
|
1305
|
+
load().then(function(){ setTimeout(closeAct,1500); });
|
|
1306
|
+
})
|
|
1307
|
+
.catch(function(e){ setDisabled('v-submit',false); showErr('v-err',e.message); });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
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">🔒 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
|
+
// ── execute form ──────────────────────────────────────────────────────────────
|
|
1342
|
+
function openExecuteForm(id){
|
|
1343
|
+
var html='';
|
|
1344
|
+
html+='<div class="sec-note sec-note-tip">⚠ This verifies all signatures and builds the transaction JSON for download. Submit it via ckb-cli or a CKB wallet.</div>';
|
|
1345
|
+
html+='<div id="e-err" class="form-err"></div>';
|
|
1346
|
+
html+='<div id="e-ok" class="form-ok"></div>';
|
|
1347
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="e-submit" onclick="submitExecute(\''+h(id)+'\')">Build & Download TX</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1348
|
+
openAct('Execute — Proposal #'+h(id),html);
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function submitExecute(id){
|
|
1352
|
+
hideMsg('e-err'); hideMsg('e-ok'); setDisabled('e-submit',true);
|
|
1353
|
+
fetch('/api/execute',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({proposalId:id})})
|
|
1354
|
+
.then(function(r){return r.json();})
|
|
1355
|
+
.then(function(d){
|
|
1356
|
+
setDisabled('e-submit',false);
|
|
1357
|
+
if(!d.ok){showErr('e-err',d.error||'Unknown error');return;}
|
|
1358
|
+
// trigger browser download
|
|
1359
|
+
var blob=new Blob([JSON.stringify(d.txJson,null,2)+'\n'],{type:'application/json'});
|
|
1360
|
+
var url=URL.createObjectURL(blob);
|
|
1361
|
+
var a=document.createElement('a');
|
|
1362
|
+
a.href=url; a.download=d.filename||('gov_execute_tx_'+id+'.json');
|
|
1363
|
+
document.body.appendChild(a); a.click();
|
|
1364
|
+
document.body.removeChild(a); URL.revokeObjectURL(url);
|
|
1365
|
+
showOk('e-ok','TX file downloaded: '+d.filename);
|
|
1366
|
+
})
|
|
1367
|
+
.catch(function(e){ setDisabled('e-submit',false); showErr('e-err',e.message); });
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
// ── import form ───────────────────────────────────────────────────────────────
|
|
1371
|
+
function openImportForm(){
|
|
1372
|
+
var html='';
|
|
1373
|
+
html+='<div class="sec-note sec-note-info">Paste or upload a proposal JSON exported from another governance participant.</div>';
|
|
1374
|
+
html+='<div class="form-row"><label class="form-lbl">Upload JSON file</label>';
|
|
1375
|
+
html+='<input type="file" accept=".json,application/json" class="fi" onchange="onImportFile(this)"></div>';
|
|
1376
|
+
html+='<div class="form-row"><label class="form-lbl" for="i-json">Or paste JSON</label>';
|
|
1377
|
+
html+='<textarea id="i-json" class="fi fi-mono" rows="10" placeholder=\'{"id":"...","proposalIdHash":"0x...",...}\'></textarea></div>';
|
|
1378
|
+
html+='<div id="i-err" class="form-err"></div>';
|
|
1379
|
+
html+='<div id="i-ok" class="form-ok"></div>';
|
|
1380
|
+
html+='<div class="btn-row"><button class="btn btn-p" id="i-submit" onclick="submitImport()">Import</button><button class="btn btn-d" onclick="closeAct()">Cancel</button></div>';
|
|
1381
|
+
openAct('Import Proposal',html);
|
|
1382
|
+
}
|
|
1383
|
+
function onImportFile(input){
|
|
1384
|
+
var file=input.files&&input.files[0];
|
|
1385
|
+
if(!file)return;
|
|
1386
|
+
var reader=new FileReader();
|
|
1387
|
+
reader.onload=function(e){ var ta=document.getElementById('i-json'); if(ta)ta.value=e.target.result; };
|
|
1388
|
+
reader.readAsText(file);
|
|
1389
|
+
}
|
|
1390
|
+
function submitImport(){
|
|
1391
|
+
hideMsg('i-err'); hideMsg('i-ok'); setDisabled('i-submit',true);
|
|
1392
|
+
var raw=fval('i-json').trim();
|
|
1393
|
+
if(!raw){setDisabled('i-submit',false);showErr('i-err','Paste or upload a proposal JSON first.');return;}
|
|
1394
|
+
var parsed;
|
|
1395
|
+
try{ parsed=JSON.parse(raw); } catch(e){ setDisabled('i-submit',false); showErr('i-err','Invalid JSON: '+e.message); return; }
|
|
1396
|
+
fetch('/api/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(parsed)})
|
|
1397
|
+
.then(function(r){return r.json();})
|
|
1398
|
+
.then(function(d){
|
|
1399
|
+
setDisabled('i-submit',false);
|
|
1400
|
+
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);
|
|
1402
|
+
load().then(function(){ setTimeout(closeAct,1500); });
|
|
1403
|
+
})
|
|
1404
|
+
.catch(function(e){ setDisabled('i-submit',false); showErr('i-err',e.message); });
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// ── event delegation ──────────────────────────────────────────────────────────
|
|
1408
|
+
document.addEventListener('click',function(ev){
|
|
1409
|
+
var t=ev.target;
|
|
1410
|
+
|
|
1411
|
+
// copy button
|
|
1412
|
+
if(t.classList&&t.classList.contains('copy-btn')){
|
|
1413
|
+
var txt=t.dataset.copy;
|
|
1414
|
+
if(!txt)return;
|
|
1415
|
+
if(navigator.clipboard){
|
|
1416
|
+
navigator.clipboard.writeText(txt).then(function(){
|
|
1417
|
+
t.textContent='Copied!';t.classList.add('ok');
|
|
1418
|
+
setTimeout(function(){t.textContent='Copy';t.classList.remove('ok');},2000);
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// proposal badge in registry table
|
|
1425
|
+
var pidEl=t.closest('[data-pid]');
|
|
1426
|
+
if(pidEl&&!t.closest('.modal')){
|
|
1427
|
+
openProposal(pidEl.dataset.pid);
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// registry row
|
|
1432
|
+
var addrEl=t.closest('[data-addr]');
|
|
1433
|
+
if(addrEl){
|
|
1434
|
+
openAddr(addrEl.dataset.addr);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
// ── render ────────────────────────────────────────────────────────────────────
|
|
1440
|
+
function render(){
|
|
1441
|
+
renderOverview();
|
|
1442
|
+
renderReg();
|
|
1443
|
+
renderProps();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// ── init ──────────────────────────────────────────────────────────────────────
|
|
1447
|
+
load();
|
|
1448
|
+
</script>
|
|
1449
|
+
</body>
|
|
1450
|
+
</html>`;
|
|
1451
|
+
/* eslint-enable max-len */
|
|
1452
|
+
// ── HTTP server ───────────────────────────────────────────────────────────────
|
|
1453
|
+
// createServer's callback must be synchronous — Node's http module does not
|
|
1454
|
+
// await Promises returned from request handlers. Any unhandled rejection from
|
|
1455
|
+
// an async handler silently drops the TCP connection, which the browser sees as
|
|
1456
|
+
// "Failed to fetch". We extract the async logic and attach a top-level .catch
|
|
1457
|
+
// so the connection always gets a response.
|
|
1458
|
+
async function handleRequest(req, res, opts) {
|
|
1459
|
+
const remoteAddr = req.socket.remoteAddress ?? "";
|
|
1460
|
+
if (remoteAddr !== "127.0.0.1" && remoteAddr !== "::1" && remoteAddr !== "::ffff:127.0.0.1") {
|
|
1461
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
1462
|
+
res.end("Forbidden");
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const url = req.url ?? "/";
|
|
1466
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
1467
|
+
if (url === "/" || url === "/index.html") {
|
|
1468
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
|
|
1469
|
+
if (_GUI_BUNDLE_PATH) {
|
|
1470
|
+
const apiData = await buildApiData(opts);
|
|
1471
|
+
res.end(_buildGuiHtml(apiData));
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
res.end(HTML);
|
|
1475
|
+
}
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
if (url === "/api/data" && method === "GET") {
|
|
1479
|
+
const body = await buildApiData(opts);
|
|
1480
|
+
res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache" });
|
|
1481
|
+
res.end(body);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
// ── write endpoints ───────────────────────────────────────────────────────
|
|
1485
|
+
if (url === "/api/propose" && method === "POST") {
|
|
1486
|
+
try {
|
|
1487
|
+
apiOk(res, await handlePropose(await readJsonBody(req)));
|
|
1488
|
+
}
|
|
1489
|
+
catch (e) {
|
|
1490
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1491
|
+
}
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
if (url === "/api/vote" && method === "POST") {
|
|
1495
|
+
try {
|
|
1496
|
+
apiOk(res, await handleVote(await readJsonBody(req), opts));
|
|
1497
|
+
}
|
|
1498
|
+
catch (e) {
|
|
1499
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1500
|
+
}
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1503
|
+
if (url === "/api/sign" && method === "POST") {
|
|
1504
|
+
try {
|
|
1505
|
+
apiOk(res, await handleSign(await readJsonBody(req), opts));
|
|
1506
|
+
}
|
|
1507
|
+
catch (e) {
|
|
1508
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1509
|
+
}
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (url === "/api/execute" && method === "POST") {
|
|
1513
|
+
try {
|
|
1514
|
+
apiOk(res, await handleExecute(await readJsonBody(req), opts));
|
|
1515
|
+
}
|
|
1516
|
+
catch (e) {
|
|
1517
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1518
|
+
}
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (url === "/api/import" && method === "POST") {
|
|
1522
|
+
try {
|
|
1523
|
+
apiOk(res, handleImport(await readJsonBody(req)));
|
|
1524
|
+
}
|
|
1525
|
+
catch (e) {
|
|
1526
|
+
apiErr(res, e instanceof Error ? e.message : String(e));
|
|
1527
|
+
}
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
// GET /api/export/:id
|
|
1531
|
+
const exportMatch = /^\/api\/export\/([a-f0-9]{12})$/i.exec(url);
|
|
1532
|
+
if (exportMatch && method === "GET") {
|
|
1533
|
+
try {
|
|
1534
|
+
const proposal = loadProposal(exportMatch[1]);
|
|
1535
|
+
const json = JSON.stringify(proposal, null, 2) + "\n";
|
|
1536
|
+
res.writeHead(200, {
|
|
1537
|
+
"Content-Type": "application/json",
|
|
1538
|
+
"Content-Disposition": `attachment; filename="proposal-${exportMatch[1]}.json"`,
|
|
1539
|
+
});
|
|
1540
|
+
res.end(json);
|
|
1541
|
+
}
|
|
1542
|
+
catch (e) {
|
|
1543
|
+
apiErr(res, e instanceof Error ? e.message : String(e), 404);
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
res.writeHead(404);
|
|
1548
|
+
res.end("Not found");
|
|
1549
|
+
}
|
|
1550
|
+
export function startGuiServer(opts) {
|
|
1551
|
+
return new Promise((resolve, reject) => {
|
|
1552
|
+
const server = createServer((req, res) => {
|
|
1553
|
+
handleRequest(req, res, opts).catch((err) => {
|
|
1554
|
+
if (res.headersSent)
|
|
1555
|
+
return;
|
|
1556
|
+
try {
|
|
1557
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1558
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : String(err) }));
|
|
1559
|
+
}
|
|
1560
|
+
catch {
|
|
1561
|
+
res.destroy();
|
|
1562
|
+
}
|
|
1563
|
+
});
|
|
1564
|
+
});
|
|
1565
|
+
server.once("error", reject);
|
|
1566
|
+
// Listen on all interfaces (omitting host) so both 127.0.0.1 (IPv4) and ::1
|
|
1567
|
+
// (IPv6) work. On Linux, browsers often resolve "localhost" to ::1; binding
|
|
1568
|
+
// only to 127.0.0.1 causes the HTML to load but subsequent fetch() calls to
|
|
1569
|
+
// fail with "Failed to fetch".
|
|
1570
|
+
const listenPort = opts.port === 0 ? 0 : opts.port;
|
|
1571
|
+
server.listen(listenPort, () => {
|
|
1572
|
+
const { port } = server.address();
|
|
1573
|
+
resolve({
|
|
1574
|
+
port,
|
|
1575
|
+
close: () => server.close(),
|
|
1576
|
+
});
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
//# sourceMappingURL=gui-server.js.map
|