550 lines
14 KiB
TypeScript
550 lines
14 KiB
TypeScript
/**
|
|
* folk-gov-multisig — M-of-N Multiplexor Gate
|
|
*
|
|
* Requires M of N named signers before passing. Signers can be added
|
|
* manually or auto-populated from upstream binary gates. Shows a
|
|
* multiplexor SVG diagram and progress bar.
|
|
*/
|
|
|
|
import { FolkShape } from "./folk-shape";
|
|
import { css, html } from "./tags";
|
|
import type { PortDescriptor } from "./data-types";
|
|
|
|
const HEADER_COLOR = "#6366f1";
|
|
|
|
interface Signer {
|
|
name: string;
|
|
signed: boolean;
|
|
timestamp: number;
|
|
}
|
|
|
|
const styles = css`
|
|
:host {
|
|
background: var(--rs-bg-surface, #1e293b);
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
|
|
min-width: 260px;
|
|
min-height: 180px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 8px 12px;
|
|
background: ${HEADER_COLOR};
|
|
color: white;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: move;
|
|
border-radius: 10px 10px 0 0;
|
|
}
|
|
|
|
.header-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.header-actions button {
|
|
background: transparent;
|
|
border: none;
|
|
color: white;
|
|
cursor: pointer;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.header-actions button:hover {
|
|
background: rgba(255, 255, 255, 0.2);
|
|
}
|
|
|
|
.body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 12px;
|
|
gap: 8px;
|
|
}
|
|
|
|
.title-input {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
width: 100%;
|
|
outline: none;
|
|
}
|
|
|
|
.title-input::placeholder {
|
|
color: var(--rs-text-muted, #64748b);
|
|
}
|
|
|
|
.mn-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.mn-input {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 12px;
|
|
padding: 2px 6px;
|
|
width: 40px;
|
|
text-align: center;
|
|
outline: none;
|
|
}
|
|
|
|
.mux-svg {
|
|
text-align: center;
|
|
}
|
|
|
|
.mux-svg svg {
|
|
display: block;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.progress-wrap {
|
|
position: relative;
|
|
height: 16px;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.progress-bar {
|
|
height: 100%;
|
|
border-radius: 8px;
|
|
transition: width 0.3s, background 0.3s;
|
|
background: ${HEADER_COLOR};
|
|
}
|
|
|
|
.progress-bar.complete {
|
|
background: #22c55e;
|
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
|
|
}
|
|
|
|
.signers-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
max-height: 120px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.signer-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 11px;
|
|
padding: 3px 6px;
|
|
border-radius: 4px;
|
|
background: rgba(255, 255, 255, 0.03);
|
|
color: var(--rs-text-secondary, #94a3b8);
|
|
}
|
|
|
|
.signer-item.signed {
|
|
color: #22c55e;
|
|
}
|
|
|
|
.signer-icon {
|
|
width: 14px;
|
|
text-align: center;
|
|
font-size: 10px;
|
|
}
|
|
|
|
.signer-name {
|
|
flex: 1;
|
|
}
|
|
|
|
.signer-toggle {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 10px;
|
|
padding: 1px 6px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.signer-toggle:hover {
|
|
background: rgba(255, 255, 255, 0.12);
|
|
}
|
|
|
|
.add-signer-row {
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.add-signer-input {
|
|
flex: 1;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 4px;
|
|
color: var(--rs-text-primary, #e2e8f0);
|
|
font-size: 11px;
|
|
padding: 3px 6px;
|
|
outline: none;
|
|
}
|
|
|
|
.add-signer-btn {
|
|
background: ${HEADER_COLOR};
|
|
border: none;
|
|
color: white;
|
|
border-radius: 4px;
|
|
padding: 3px 8px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.add-signer-btn:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.status-label {
|
|
font-size: 10px;
|
|
font-weight: 500;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
text-align: center;
|
|
}
|
|
|
|
.status-label.satisfied {
|
|
color: #22c55e;
|
|
}
|
|
|
|
.status-label.waiting {
|
|
color: #f59e0b;
|
|
}
|
|
`;
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
"folk-gov-multisig": FolkGovMultisig;
|
|
}
|
|
}
|
|
|
|
export class FolkGovMultisig extends FolkShape {
|
|
static override tagName = "folk-gov-multisig";
|
|
|
|
static override portDescriptors: PortDescriptor[] = [
|
|
{ name: "signer-in", type: "json", direction: "input" },
|
|
{ name: "gate-out", type: "json", direction: "output" },
|
|
];
|
|
|
|
static {
|
|
const sheet = new CSSStyleSheet();
|
|
const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n");
|
|
const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n");
|
|
sheet.replaceSync(`${parentRules}\n${childRules}`);
|
|
this.styles = sheet;
|
|
}
|
|
|
|
#title = "Multisig";
|
|
#requiredM = 2;
|
|
#signers: Signer[] = [];
|
|
|
|
// DOM refs
|
|
#titleEl!: HTMLInputElement;
|
|
#mEl!: HTMLInputElement;
|
|
#nEl!: HTMLElement;
|
|
#muxEl!: HTMLElement;
|
|
#progressBar!: HTMLElement;
|
|
#signersList!: HTMLElement;
|
|
#addInput!: HTMLInputElement;
|
|
#statusEl!: HTMLElement;
|
|
|
|
get title() { return this.#title; }
|
|
set title(v: string) {
|
|
this.#title = v;
|
|
if (this.#titleEl) this.#titleEl.value = v;
|
|
}
|
|
|
|
get requiredM() { return this.#requiredM; }
|
|
set requiredM(v: number) {
|
|
this.#requiredM = v;
|
|
if (this.#mEl) this.#mEl.value = String(v);
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
}
|
|
|
|
get signers(): Signer[] { return [...this.#signers]; }
|
|
set signers(v: Signer[]) {
|
|
this.#signers = v;
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
}
|
|
|
|
get #signedCount(): number {
|
|
return this.#signers.filter(s => s.signed).length;
|
|
}
|
|
|
|
get #isSatisfied(): boolean {
|
|
return this.#signedCount >= this.#requiredM;
|
|
}
|
|
|
|
override createRenderRoot() {
|
|
const root = super.createRenderRoot();
|
|
this.initPorts();
|
|
|
|
const wrapper = document.createElement("div");
|
|
wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;";
|
|
wrapper.innerHTML = html`
|
|
<div class="header" data-drag>
|
|
<span class="header-title">🔐 Multisig</span>
|
|
<span class="header-actions">
|
|
<button class="close-btn">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="body">
|
|
<input class="title-input" type="text" placeholder="Multisig title..." />
|
|
<div class="mn-row">
|
|
<input class="mn-m-input mn-input" type="number" min="1" />
|
|
<span>of</span>
|
|
<span class="mn-n-label">0</span>
|
|
<span>required</span>
|
|
</div>
|
|
<div class="mux-svg"></div>
|
|
<div class="progress-wrap">
|
|
<div class="progress-bar" style="width: 0%"></div>
|
|
</div>
|
|
<div class="signers-list"></div>
|
|
<div class="add-signer-row">
|
|
<input class="add-signer-input" type="text" placeholder="Add signer..." />
|
|
<button class="add-signer-btn">+</button>
|
|
</div>
|
|
<span class="status-label waiting">WAITING</span>
|
|
</div>
|
|
`;
|
|
|
|
const slot = root.querySelector("slot");
|
|
const container = slot?.parentElement as HTMLElement;
|
|
if (container) container.replaceWith(wrapper);
|
|
|
|
// Cache refs
|
|
this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement;
|
|
this.#mEl = wrapper.querySelector(".mn-m-input") as HTMLInputElement;
|
|
this.#nEl = wrapper.querySelector(".mn-n-label") as HTMLElement;
|
|
this.#muxEl = wrapper.querySelector(".mux-svg") as HTMLElement;
|
|
this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement;
|
|
this.#signersList = wrapper.querySelector(".signers-list") as HTMLElement;
|
|
this.#addInput = wrapper.querySelector(".add-signer-input") as HTMLInputElement;
|
|
this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement;
|
|
|
|
// Set initial values
|
|
this.#titleEl.value = this.#title;
|
|
this.#mEl.value = String(this.#requiredM);
|
|
this.#updateVisuals();
|
|
|
|
// Wire events
|
|
this.#titleEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#title = this.#titleEl.value;
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
this.#mEl.addEventListener("input", (e) => {
|
|
e.stopPropagation();
|
|
this.#requiredM = Math.max(1, parseInt(this.#mEl.value) || 1);
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
wrapper.querySelector(".add-signer-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const name = this.#addInput.value.trim();
|
|
if (!name) return;
|
|
if (this.#signers.some(s => s.name === name)) return;
|
|
this.#signers.push({ name, signed: false, timestamp: 0 });
|
|
this.#addInput.value = "";
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
|
|
this.#addInput.addEventListener("keydown", (e) => {
|
|
e.stopPropagation();
|
|
if (e.key === "Enter") {
|
|
wrapper.querySelector(".add-signer-btn")!.dispatchEvent(new Event("click"));
|
|
}
|
|
});
|
|
|
|
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
this.dispatchEvent(new CustomEvent("close"));
|
|
});
|
|
|
|
// Prevent drag on inputs
|
|
for (const el of wrapper.querySelectorAll("input, button")) {
|
|
el.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
}
|
|
|
|
// Handle input port
|
|
this.addEventListener("port-value-changed", ((e: CustomEvent) => {
|
|
const { name, value } = e.detail;
|
|
if (name === "signer-in" && value && typeof value === "object") {
|
|
const v = value as any;
|
|
const signerName = v.signedBy || v.who || v.name || "";
|
|
const isSatisfied = v.satisfied === true;
|
|
if (signerName && isSatisfied) {
|
|
const existing = this.#signers.find(s => s.name === signerName);
|
|
if (existing) {
|
|
existing.signed = true;
|
|
existing.timestamp = v.timestamp || Date.now();
|
|
} else {
|
|
this.#signers.push({ name: signerName, signed: true, timestamp: v.timestamp || Date.now() });
|
|
}
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
}
|
|
}
|
|
}) as EventListener);
|
|
|
|
return root;
|
|
}
|
|
|
|
#updateVisuals() {
|
|
const n = this.#signers.length;
|
|
const signed = this.#signedCount;
|
|
const satisfied = this.#isSatisfied;
|
|
const pct = n > 0 ? (signed / Math.max(this.#requiredM, 1)) * 100 : 0;
|
|
|
|
if (this.#nEl) this.#nEl.textContent = String(n);
|
|
|
|
if (this.#progressBar) {
|
|
this.#progressBar.style.width = `${Math.min(100, pct)}%`;
|
|
this.#progressBar.classList.toggle("complete", satisfied);
|
|
}
|
|
|
|
if (this.#statusEl) {
|
|
this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING";
|
|
this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`;
|
|
}
|
|
|
|
this.#renderMux();
|
|
this.#renderSigners();
|
|
}
|
|
|
|
#renderMux() {
|
|
if (!this.#muxEl) return;
|
|
const n = this.#signers.length;
|
|
if (n === 0) {
|
|
this.#muxEl.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
const W = 180;
|
|
const slotH = 14;
|
|
const gateW = 30;
|
|
const gateH = Math.max(20, n * slotH + 4);
|
|
const H = gateH + 16;
|
|
const gateX = W / 2 - gateW / 2;
|
|
const gateY = (H - gateH) / 2;
|
|
|
|
let svg = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`;
|
|
|
|
// Gate body
|
|
svg += `<rect x="${gateX}" y="${gateY}" width="${gateW}" height="${gateH}" rx="4" fill="rgba(99,102,241,0.15)" stroke="${HEADER_COLOR}" stroke-width="1.5"/>`;
|
|
svg += `<text x="${W / 2}" y="${gateY + gateH / 2 + 3}" text-anchor="middle" font-size="8" fill="${HEADER_COLOR}" font-weight="600" font-family="system-ui">${this.#requiredM}/${n}</text>`;
|
|
|
|
// Input lines (left side)
|
|
for (let i = 0; i < n; i++) {
|
|
const y = gateY + 2 + slotH * i + slotH / 2;
|
|
const signed = this.#signers[i].signed;
|
|
const color = signed ? "#22c55e" : "rgba(255,255,255,0.2)";
|
|
svg += `<line x1="10" y1="${y}" x2="${gateX}" y2="${y}" stroke="${color}" stroke-width="1.5"/>`;
|
|
svg += `<circle cx="10" cy="${y}" r="3" fill="${color}"/>`;
|
|
}
|
|
|
|
// Output line (right side)
|
|
const outY = gateY + gateH / 2;
|
|
const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)";
|
|
svg += `<line x1="${gateX + gateW}" y1="${outY}" x2="${W - 10}" y2="${outY}" stroke="${outColor}" stroke-width="1.5"/>`;
|
|
svg += `<polygon points="${W - 10},${outY - 4} ${W - 2},${outY} ${W - 10},${outY + 4}" fill="${outColor}"/>`;
|
|
|
|
svg += "</svg>";
|
|
this.#muxEl.innerHTML = svg;
|
|
}
|
|
|
|
#renderSigners() {
|
|
if (!this.#signersList) return;
|
|
this.#signersList.innerHTML = this.#signers.map((s, i) => {
|
|
const icon = s.signed ? "✓" : "○";
|
|
const cls = s.signed ? "signer-item signed" : "signer-item";
|
|
const btnLabel = s.signed ? "unsign" : "sign";
|
|
return `<div class="${cls}">
|
|
<span class="signer-icon">${icon}</span>
|
|
<span class="signer-name">${this.#escapeHtml(s.name)}</span>
|
|
<button class="signer-toggle" data-idx="${i}">${btnLabel}</button>
|
|
</div>`;
|
|
}).join("");
|
|
|
|
// Wire toggle buttons
|
|
this.#signersList.querySelectorAll(".signer-toggle").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const idx = parseInt((btn as HTMLElement).dataset.idx!);
|
|
const signer = this.#signers[idx];
|
|
signer.signed = !signer.signed;
|
|
signer.timestamp = signer.signed ? Date.now() : 0;
|
|
this.#updateVisuals();
|
|
this.#emitPort();
|
|
this.dispatchEvent(new CustomEvent("content-change"));
|
|
});
|
|
btn.addEventListener("pointerdown", (e) => e.stopPropagation());
|
|
});
|
|
}
|
|
|
|
#escapeHtml(text: string): string {
|
|
const div = document.createElement("div");
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
#emitPort() {
|
|
this.setPortValue("gate-out", {
|
|
satisfied: this.#isSatisfied,
|
|
signed: this.#signedCount,
|
|
required: this.#requiredM,
|
|
total: this.#signers.length,
|
|
signers: this.#signers.filter(s => s.signed).map(s => s.name),
|
|
});
|
|
}
|
|
|
|
override toJSON() {
|
|
return {
|
|
...super.toJSON(),
|
|
type: "folk-gov-multisig",
|
|
title: this.#title,
|
|
requiredM: this.#requiredM,
|
|
signers: this.#signers,
|
|
};
|
|
}
|
|
|
|
static override fromData(data: Record<string, any>): FolkGovMultisig {
|
|
const shape = FolkShape.fromData.call(this, data) as FolkGovMultisig;
|
|
if (data.title !== undefined) shape.title = data.title;
|
|
if (data.requiredM !== undefined) shape.requiredM = data.requiredM;
|
|
if (data.signers !== undefined) shape.signers = data.signers;
|
|
return shape;
|
|
}
|
|
|
|
override applyData(data: Record<string, any>): void {
|
|
super.applyData(data);
|
|
if (data.title !== undefined && data.title !== this.#title) this.title = data.title;
|
|
if (data.requiredM !== undefined && data.requiredM !== this.#requiredM) this.requiredM = data.requiredM;
|
|
if (data.signers !== undefined && JSON.stringify(data.signers) !== JSON.stringify(this.#signers)) this.signers = data.signers;
|
|
}
|
|
}
|