rspace-online/lib/folk-transaction-builder.ts

594 lines
17 KiB
TypeScript

/**
* <folk-transaction-builder> — Safe multi-sig transaction builder canvas shape.
* Three modes: Compose, Pending, History.
* Uses existing rwallet API endpoints for propose/confirm/execute.
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 380px;
min-height: 480px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #7c3aed, #6366f1);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.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);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
/* Safe selector */
.safe-selector {
display: flex;
gap: 6px;
padding: 8px 12px;
border-bottom: 1px solid var(--rs-border, #e2e8f0);
font-size: 12px;
}
.safe-selector input, .safe-selector select {
flex: 1;
padding: 5px 8px;
border: 1px solid var(--rs-input-border, #e2e8f0);
border-radius: 6px;
font-size: 11px;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
outline: none;
}
.safe-selector select { flex: 0 0 100px; }
.safe-selector input:focus { border-color: #7c3aed; }
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--rs-border, #e2e8f0);
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
font-size: 12px;
font-weight: 600;
color: #64748b;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover { color: #7c3aed; }
.tab.active { color: #7c3aed; border-bottom-color: #7c3aed; }
/* Tab panels */
.tab-panel {
display: none;
flex: 1;
overflow-y: auto;
padding: 12px;
}
.tab-panel.active { display: flex; flex-direction: column; gap: 10px; }
/* Form elements */
label {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
font-weight: 600;
color: #64748b;
}
input, textarea {
padding: 7px 10px;
border: 1px solid var(--rs-input-border, #e2e8f0);
border-radius: 6px;
font-size: 12px;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
outline: none;
font-family: inherit;
}
input:focus, textarea:focus { border-color: #7c3aed; }
textarea { resize: vertical; min-height: 50px; }
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:hover { opacity: 0.9; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #6366f1);
color: white;
}
.btn-success {
background: #10b981;
color: white;
}
.btn-warning {
background: #f59e0b;
color: white;
}
/* Transaction cards */
.tx-card {
border: 1px solid var(--rs-border, #e2e8f0);
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
}
.tx-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.tx-card-header .nonce {
font-weight: 700;
color: #7c3aed;
}
.tx-card-header .status {
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
.status-pending { background: #fef3c7; color: #92400e; }
.status-executed { background: #d1fae5; color: #065f46; }
.status-failed { background: #fee2e2; color: #991b1b; }
.tx-card .detail {
display: flex;
justify-content: space-between;
color: #64748b;
font-size: 11px;
margin-top: 4px;
}
.tx-card .addr {
font-family: 'SF Mono', Monaco, monospace;
font-size: 10px;
color: #475569;
}
.tx-card .actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.empty-state {
text-align: center;
padding: 40px 20px;
color: #94a3b8;
font-size: 13px;
}
.empty-state .icon { font-size: 32px; margin-bottom: 8px; }
.info-bar {
font-size: 11px;
padding: 6px 12px;
background: #f0f9ff;
color: #0369a1;
border-radius: 6px;
text-align: center;
}
.error-bar {
font-size: 11px;
padding: 6px 12px;
background: #fef2f2;
color: #991b1b;
border-radius: 6px;
text-align: center;
}
.loading {
text-align: center;
padding: 20px;
color: #94a3b8;
font-size: 12px;
}
`;
const CHAINS: Record<string, { name: string; explorer: string }> = {
"1": { name: "Ethereum", explorer: "https://etherscan.io" },
"10": { name: "Optimism", explorer: "https://optimistic.etherscan.io" },
"100": { name: "Gnosis", explorer: "https://gnosisscan.io" },
"137": { name: "Polygon", explorer: "https://polygonscan.com" },
"42161": { name: "Arbitrum", explorer: "https://arbiscan.io" },
"8453": { name: "Base", explorer: "https://basescan.org" },
};
export class FolkTransactionBuilder extends FolkShape {
static override tagName = "folk-transaction-builder";
#safeAddress = "";
#chainId = "100"; // Default to Gnosis
#activeTab: "compose" | "pending" | "history" = "compose";
#statusMessage = "";
#statusType: "info" | "error" = "info";
#loading = false;
#pendingTxs: any[] = [];
#historyTxs: any[] = [];
#safeInfo: any = null;
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;
}
override connectedCallback() {
super.connectedCallback();
this.#render();
}
#getToken(): string | null {
try {
const sess = JSON.parse(localStorage.getItem("encryptid_session") || "{}");
return sess?.accessToken || null;
} catch { return null; }
}
#getSpaceSlug(): string {
return (window as any).__spaceSlug || location.pathname.split("/")[1] || "demo";
}
#apiBase(): string {
const space = this.#getSpaceSlug();
return `/${space}/rwallet/api/safe/${this.#chainId}/${this.#safeAddress}`;
}
async #apiFetch(path: string, opts?: RequestInit): Promise<any> {
const token = this.#getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const res = await fetch(`${this.#apiBase()}${path}`, { ...opts, headers });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
throw new Error(err.error || `HTTP ${res.status}`);
}
return res.json();
}
async #loadSafeInfo() {
if (!this.#safeAddress) return;
try {
this.#safeInfo = await this.#apiFetch("/info");
this.#render();
} catch (e: any) {
this.#showStatus(`Safe info: ${e.message}`, "error");
}
}
async #loadPendingTxs() {
if (!this.#safeAddress) return;
this.#loading = true;
this.#render();
try {
const data = await this.#apiFetch("/transfers");
// Filter to pending (not executed) — transfers endpoint returns all
this.#pendingTxs = (data.results || []).filter((tx: any) => !tx.executionDate && tx.isQueued !== false);
this.#historyTxs = (data.results || []).filter((tx: any) => !!tx.executionDate);
} catch (e: any) {
this.#showStatus(`Load error: ${e.message}`, "error");
}
this.#loading = false;
this.#render();
}
#showStatus(msg: string, type: "info" | "error" = "info") {
this.#statusMessage = msg;
this.#statusType = type;
this.#render();
if (type === "info") setTimeout(() => { this.#statusMessage = ""; this.#render(); }, 5000);
}
async #proposeTx() {
const root = this.shadowRoot!;
const to = (root.querySelector("#tx-to") as HTMLInputElement)?.value?.trim();
const value = (root.querySelector("#tx-value") as HTMLInputElement)?.value?.trim() || "0";
const data = (root.querySelector("#tx-data") as HTMLTextAreaElement)?.value?.trim() || "0x";
const desc = (root.querySelector("#tx-desc") as HTMLInputElement)?.value?.trim();
if (!to) { this.#showStatus("Recipient address required", "error"); return; }
this.#loading = true;
this.#render();
try {
await this.#apiFetch("/propose", {
method: "POST",
body: JSON.stringify({ to, value, data, description: desc }),
});
this.#showStatus("Transaction proposed");
// Clear form
const toEl = root.querySelector("#tx-to") as HTMLInputElement;
if (toEl) toEl.value = "";
const valEl = root.querySelector("#tx-value") as HTMLInputElement;
if (valEl) valEl.value = "";
const dataEl = root.querySelector("#tx-data") as HTMLTextAreaElement;
if (dataEl) dataEl.value = "";
const descEl = root.querySelector("#tx-desc") as HTMLInputElement;
if (descEl) descEl.value = "";
// Refresh pending
await this.#loadPendingTxs();
} catch (e: any) {
this.#showStatus(`Propose failed: ${e.message}`, "error");
}
this.#loading = false;
this.#render();
}
async #confirmTx(safeTxHash: string) {
this.#loading = true;
this.#render();
try {
await this.#apiFetch("/confirm", {
method: "POST",
body: JSON.stringify({ safeTxHash }),
});
this.#showStatus("Transaction confirmed");
await this.#loadPendingTxs();
} catch (e: any) {
this.#showStatus(`Confirm failed: ${e.message}`, "error");
}
this.#loading = false;
this.#render();
}
async #executeTx(safeTxHash: string) {
this.#loading = true;
this.#render();
try {
await this.#apiFetch("/execute", {
method: "POST",
body: JSON.stringify({ safeTxHash }),
});
this.#showStatus("Transaction executed");
await this.#loadPendingTxs();
} catch (e: any) {
this.#showStatus(`Execute failed: ${e.message}`, "error");
}
this.#loading = false;
this.#render();
}
#shortenAddr(addr: string): string {
if (!addr || addr.length < 12) return addr || "";
return addr.slice(0, 6) + "..." + addr.slice(-4);
}
#setTab(tab: "compose" | "pending" | "history") {
this.#activeTab = tab;
if (tab === "pending" || tab === "history") this.#loadPendingTxs();
this.#render();
}
#render() {
const root = this.shadowRoot;
if (!root) return;
const chain = CHAINS[this.#chainId] || { name: `Chain ${this.#chainId}`, explorer: "" };
const threshold = this.#safeInfo?.threshold || "?";
root.innerHTML = `
<div class="header">
<div class="header-title">🔐 Transaction Builder</div>
<div class="header-actions">
<button class="refresh-btn" title="Refresh">↻</button>
</div>
</div>
<div class="content">
<div class="safe-selector">
<input id="safe-addr" type="text" placeholder="Safe address (0x...)" value="${this.#safeAddress}" />
<select id="chain-select">
${Object.entries(CHAINS).map(([id, c]) =>
`<option value="${id}" ${id === this.#chainId ? "selected" : ""}>${c.name}</option>`
).join("")}
</select>
</div>
<div class="tabs">
<button class="tab ${this.#activeTab === "compose" ? "active" : ""}" data-tab="compose">Compose</button>
<button class="tab ${this.#activeTab === "pending" ? "active" : ""}" data-tab="pending">Pending</button>
<button class="tab ${this.#activeTab === "history" ? "active" : ""}" data-tab="history">History</button>
</div>
${this.#statusMessage ? `<div class="${this.#statusType === "error" ? "error-bar" : "info-bar"}">${this.#escapeHtml(this.#statusMessage)}</div>` : ""}
<!-- Compose -->
<div class="tab-panel ${this.#activeTab === "compose" ? "active" : ""}">
${this.#safeAddress ? `
<div class="info-bar">Safe on ${chain.name} | Threshold: ${threshold}</div>
<label>Recipient Address
<input id="tx-to" type="text" placeholder="0x..." />
</label>
<label>Value (wei)
<input id="tx-value" type="text" placeholder="0" />
</label>
<label>Description
<input id="tx-desc" type="text" placeholder="What is this transaction for?" />
</label>
<label>Calldata (optional)
<textarea id="tx-data" placeholder="0x"></textarea>
</label>
<button class="btn btn-primary propose-btn" ${this.#loading ? "disabled" : ""}>
${this.#loading ? "Proposing..." : "Propose Transaction"}
</button>
` : `
<div class="empty-state">
<div class="icon">🔐</div>
Enter a Safe address above to start
</div>
`}
</div>
<!-- Pending -->
<div class="tab-panel ${this.#activeTab === "pending" ? "active" : ""}">
${this.#loading ? '<div class="loading">Loading...</div>' :
this.#pendingTxs.length === 0 ? `
<div class="empty-state">
<div class="icon">✅</div>
No pending transactions
</div>
` : this.#pendingTxs.map(tx => `
<div class="tx-card">
<div class="tx-card-header">
<span class="nonce">#${tx.nonce ?? "?"}</span>
<span class="status status-pending">${tx.confirmations?.length || 0}/${threshold} confirmed</span>
</div>
<div class="addr">To: ${this.#shortenAddr(tx.to)}</div>
<div class="detail">
<span>Value: ${tx.value || "0"} wei</span>
</div>
<div class="actions">
<button class="btn btn-success confirm-btn" data-hash="${tx.safeTxHash || ""}">Confirm</button>
<button class="btn btn-warning execute-btn" data-hash="${tx.safeTxHash || ""}">Execute</button>
</div>
</div>
`).join("")}
</div>
<!-- History -->
<div class="tab-panel ${this.#activeTab === "history" ? "active" : ""}">
${this.#loading ? '<div class="loading">Loading...</div>' :
this.#historyTxs.length === 0 ? `
<div class="empty-state">
<div class="icon">📜</div>
No transaction history
</div>
` : this.#historyTxs.slice(0, 50).map(tx => {
const executed = !!tx.executionDate;
return `
<div class="tx-card">
<div class="tx-card-header">
<span class="nonce">#${tx.nonce ?? "?"}</span>
<span class="status ${executed ? "status-executed" : "status-failed"}">${executed ? "Executed" : "Failed"}</span>
</div>
<div class="addr">To: ${this.#shortenAddr(tx.to)}</div>
<div class="detail">
<span>Value: ${tx.value || "0"} wei</span>
<span>${tx.executionDate ? new Date(tx.executionDate).toLocaleDateString() : ""}</span>
</div>
${tx.transactionHash && chain.explorer ? `
<div class="detail">
<a href="${chain.explorer}/tx/${tx.transactionHash}" target="_blank" style="color: #7c3aed; text-decoration: none; font-size: 10px;">View on explorer ↗</a>
</div>
` : ""}
</div>`;
}).join("")}
</div>
</div>
`;
// Re-attach event listeners
root.querySelector("#safe-addr")?.addEventListener("change", (e) => {
this.#safeAddress = (e.target as HTMLInputElement).value.trim();
this.#loadSafeInfo();
this.dispatchEvent(new CustomEvent("folk-transform", { bubbles: true }));
});
root.querySelector("#chain-select")?.addEventListener("change", (e) => {
this.#chainId = (e.target as HTMLSelectElement).value;
if (this.#safeAddress) this.#loadSafeInfo();
this.dispatchEvent(new CustomEvent("folk-transform", { bubbles: true }));
});
root.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", () => this.#setTab(tab.getAttribute("data-tab") as any));
});
root.querySelector(".propose-btn")?.addEventListener("click", () => this.#proposeTx());
root.querySelectorAll(".confirm-btn").forEach(btn => {
btn.addEventListener("click", () => this.#confirmTx(btn.getAttribute("data-hash") || ""));
});
root.querySelectorAll(".execute-btn").forEach(btn => {
btn.addEventListener("click", () => this.#executeTx(btn.getAttribute("data-hash") || ""));
});
root.querySelector(".refresh-btn")?.addEventListener("click", () => {
if (this.#safeAddress) {
this.#loadSafeInfo();
this.#loadPendingTxs();
}
});
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkTransactionBuilder {
const shape = FolkShape.fromData(data) as FolkTransactionBuilder;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-transaction-builder",
safeAddress: this.#safeAddress,
chainId: this.#chainId,
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.safeAddress !== undefined) this.#safeAddress = data.safeAddress;
if (data.chainId !== undefined) this.#chainId = data.chainId;
this.#render();
}
}
if (!customElements.get("folk-transaction-builder")) {
customElements.define("folk-transaction-builder", FolkTransactionBuilder);
}