594 lines
17 KiB
TypeScript
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);
|
|
}
|