Last active
April 20, 2026 03:44
-
-
Save wenakita/0e22a96d40d5afddaa35130aabd552e7 to your computer and use it in GitHub Desktop.
agent 4626 — delegate signing authority to the 4626 agent on your Zora CSW. source of content coin.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>agent 4626 — delegate signing on your Zora wallet</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet" /> | |
| <style> | |
| :root { | |
| --bg: #0a0a0a; --fg: #fafafa; --muted: #8b8b8b; --dim: #444; | |
| --accent: #c89a2b; --pos: #6bd392; --neg: #ee6a6a; --info: #6aa3ee; | |
| --rule: #1a1a1a; | |
| color-scheme: dark; | |
| } | |
| html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; } | |
| body { | |
| background: var(--bg); color: var(--fg); | |
| font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; | |
| font-size: 12px; line-height: 1.5; | |
| display: flex; align-items: center; justify-content: center; | |
| background-image: | |
| radial-gradient(#141414 1px, transparent 1px), | |
| radial-gradient(#141414 1px, transparent 1px); | |
| background-size: 48px 48px; background-position: 0 0, 24px 24px; | |
| } | |
| .card { | |
| width: min(92vw, 580px); max-height: 94vh; | |
| background: linear-gradient(180deg, #0f0f0f 0%, #070707 100%); | |
| border: 1px solid #1a1a1a; border-radius: 2px; | |
| padding: 28px 32px; box-sizing: border-box; | |
| display: flex; flex-direction: column; gap: 16px; | |
| overflow-y: auto; | |
| } | |
| .kicker { color: var(--muted); font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; } | |
| .headline { | |
| font-family: "Instrument Serif", ui-serif, Georgia, serif; | |
| font-size: clamp(28px, 5.5vw, 44px); font-weight: 400; line-height: 1.05; | |
| color: var(--fg); margin: 0; letter-spacing: -0.01em; | |
| } | |
| .headline em { font-style: italic; color: var(--accent); } | |
| .sub { color: var(--muted); font-size: 12px; line-height: 1.6; margin: 0; max-width: 52ch; } | |
| .sub a, .footnote a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--dim); text-underline-offset: 3px; } | |
| .sub a:hover, .footnote a:hover { text-decoration-color: var(--accent); } | |
| .chips { display: flex; gap: 6px; flex-wrap: wrap; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; } | |
| .chip { padding: 3px 8px; border-radius: 999px; border: 1px solid #1a1a1a; color: var(--muted); } | |
| .chip.ok { color: var(--pos); border-color: #1f5b38; background: #0a1a10; } | |
| .chip.warn { color: var(--accent); border-color: #3a2d13; background: #1a1205; } | |
| .chip.err { color: var(--neg); border-color: #5c2020; background: #1a0a0a; } | |
| .field { display: flex; flex-direction: column; gap: 6px; } | |
| .label { color: var(--muted); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; } | |
| .label-row { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; } | |
| .label-row .hint { color: var(--dim); font-size: 10px; letter-spacing: 0.02em; text-transform: none; } | |
| .label-row .hint a { color: var(--muted); } | |
| input[type="text"] { | |
| background: #070707; border: 1px solid #1a1a1a; color: var(--fg); | |
| font-family: inherit; font-size: 12px; padding: 10px 12px; | |
| border-radius: 2px; letter-spacing: 0; | |
| } | |
| input[type="text"]:focus { outline: 0; border-color: var(--accent); } | |
| input[type="text"].error { border-color: var(--neg); } | |
| .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } | |
| button.primary { | |
| flex: 1; min-width: 140px; | |
| padding: 12px 16px; background: var(--fg); color: #000; | |
| border: 0; border-radius: 2px; cursor: pointer; | |
| font-family: inherit; font-size: 12px; letter-spacing: 0.06em; | |
| text-transform: uppercase; | |
| transition: background 0.15s ease, transform 0.1s ease; | |
| } | |
| button.primary:hover:not(:disabled) { background: #e5e5e5; } | |
| button.primary:active { transform: translateY(1px); } | |
| button.primary:disabled { opacity: 0.5; cursor: not-allowed; background: #333; color: #666; } | |
| .preview-panel, .tx-panel, .diag-panel, .error-panel { | |
| border: 1px solid #1a1a1a; background: #070707; padding: 12px 14px; | |
| border-radius: 2px; font-size: 11px; | |
| } | |
| .preview-panel { border-color: #1f3a5c; background: #0a1220; } | |
| .preview-panel .title { color: var(--info); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 10px; } | |
| .error-panel { border-color: #5c2020; background: #1a0a0a; color: var(--neg); } | |
| .row { display: flex; justify-content: space-between; gap: 12px; padding: 4px 0; align-items: baseline; } | |
| .k { color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; font-size: 10px; white-space: nowrap; } | |
| .v { color: var(--fg); font-family: "JetBrains Mono", monospace; text-align: right; word-break: break-all; font-size: 11px; } | |
| .v a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--dim); text-underline-offset: 3px; } | |
| .v a:hover { text-decoration-color: var(--accent); } | |
| /* Wallet picker */ | |
| .picker-overlay { | |
| position: fixed; inset: 0; background: rgba(0,0,0,0.75); | |
| display: none; align-items: center; justify-content: center; z-index: 100; | |
| } | |
| .picker-overlay.show { display: flex; } | |
| .picker { | |
| background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 4px; | |
| padding: 20px; width: min(90vw, 420px); | |
| display: flex; flex-direction: column; gap: 8px; | |
| max-height: 88vh; overflow-y: auto; | |
| } | |
| .picker .h { font-size: 11px; letter-spacing: 0.15em; text-transform: uppercase; color: var(--muted); margin-bottom: 10px; } | |
| .picker button.option { | |
| display: flex; align-items: center; gap: 12px; | |
| padding: 12px 14px; background: #1a1a1a; border: 1px solid #2a2a2a; | |
| color: var(--fg); cursor: pointer; border-radius: 3px; text-align: left; | |
| font-family: inherit; font-size: 12px; transition: all 0.15s ease; | |
| } | |
| .picker button.option:hover:not(:disabled) { background: #242424; border-color: var(--accent); } | |
| .picker button.option:disabled { opacity: 0.5; cursor: wait; } | |
| .picker button.option img, | |
| .picker button.option .badge-icon { | |
| width: 28px; height: 28px; border-radius: 4px; flex-shrink: 0; | |
| display: flex; align-items: center; justify-content: center; | |
| font-weight: bold; font-size: 11px; background: #1a1a1a; | |
| } | |
| .picker button.option .meta { display: flex; flex-direction: column; gap: 2px; } | |
| .picker button.option .name { color: var(--fg); } | |
| .picker button.option .sub { font-size: 10px; color: var(--muted); letter-spacing: 0.04em; text-transform: uppercase; } | |
| .picker .status { | |
| padding: 10px 14px; background: #1a1205; border: 1px solid #3a2d13; | |
| border-radius: 3px; color: var(--accent); font-size: 11px; line-height: 1.5; | |
| } | |
| .picker .status.err { background: #1a0a0a; border-color: #5c2020; color: var(--neg); } | |
| .picker .cancel { text-align: center; background: transparent; border: 0; color: var(--muted); padding: 8px; margin-top: 4px; cursor: pointer; font-family: inherit; font-size: 11px; } | |
| .picker .cancel:hover { color: var(--fg); } | |
| .picker .featured { | |
| background: #0a1222 !important; border-color: #1f3a5c !important; | |
| } | |
| .picker .featured:hover:not(:disabled) { background: #0e1a33 !important; border-color: var(--info) !important; } | |
| .picker .featured .badge-icon { background: var(--info); color: #0a0a0a; } | |
| .wc-pane { | |
| display: flex; flex-direction: column; gap: 12px; align-items: center; | |
| padding: 16px 8px 4px; | |
| } | |
| .wc-pane h3 { margin: 0; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg); } | |
| .wc-pane .wc-sub { color: var(--muted); font-size: 11px; text-align: center; max-width: 36ch; line-height: 1.6; margin: 0; } | |
| .wc-pane .wc-qr { | |
| width: 240px; height: 240px; background: #fff; border-radius: 4px; | |
| padding: 10px; box-sizing: border-box; | |
| display: flex; align-items: center; justify-content: center; | |
| position: relative; | |
| } | |
| .wc-pane .wc-qr img { width: 100%; height: 100%; display: block; } | |
| .wc-pane .wc-qr .wc-qr-fallback { | |
| color: #333; font-size: 10px; text-align: center; padding: 12px; | |
| } | |
| .wc-pane .wc-actions { display: flex; gap: 8px; width: 100%; } | |
| .wc-pane .wc-actions button { | |
| flex: 1; padding: 10px 12px; background: #1a1a1a; color: var(--fg); | |
| border: 1px solid #2a2a2a; border-radius: 3px; cursor: pointer; | |
| font-family: inherit; font-size: 11px; letter-spacing: 0.04em; | |
| } | |
| .wc-pane .wc-actions button:hover { background: #242424; border-color: var(--info); } | |
| .wc-pane .wc-uri-text { | |
| font-size: 9px; color: var(--dim); word-break: break-all; padding: 8px; | |
| background: #050505; border: 1px solid #1a1a1a; border-radius: 2px; | |
| max-height: 60px; overflow-y: auto; width: 100%; box-sizing: border-box; | |
| font-family: "JetBrains Mono", monospace; | |
| } | |
| .wc-pane .wc-waiting { color: var(--info); font-size: 11px; } | |
| .footnote { color: var(--dim); font-size: 10px; letter-spacing: 0.04em; line-height: 1.6; } | |
| .footnote code { color: var(--muted); } | |
| .hidden { display: none !important; } | |
| details.diag summary { | |
| list-style: none; cursor: pointer; color: var(--muted); | |
| font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; padding: 4px 0; | |
| } | |
| details.diag summary::marker, details.diag summary::-webkit-details-marker { display: none; } | |
| details.diag summary:hover { color: var(--fg); } | |
| details.diag[open] summary { margin-bottom: 6px; } | |
| .spin { | |
| width: 11px; height: 11px; border: 2px solid var(--dim); border-top-color: var(--accent); | |
| border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| .step-header { display: flex; align-items: baseline; gap: 10px; } | |
| .step-header .n { | |
| color: var(--dim); font-size: 10px; letter-spacing: 0.15em; | |
| min-width: 2.5em; | |
| } | |
| .step-header.done .n { color: var(--pos); } | |
| .step-header .t { color: var(--fg); font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="card"> | |
| <div class="kicker">4626 · AGENT · BASE</div> | |
| <h1 class="headline">meet your <em>agent</em>.</h1> | |
| <p class="sub"> | |
| Delegate signing to the 4626 agent and let it trade, rebalance, and handle governance for your Zora wallet from chat. Your funds stay put — every action lands in your wallet, nowhere else. | |
| <br><br> | |
| You stay the primary owner. Fire the agent anytime from 4626.fun. | |
| </p> | |
| <div class="chips" id="chips"> | |
| <span class="chip" id="chip-wallet">wallet · not connected</span> | |
| <span class="chip" id="chip-chain">chain · unknown</span> | |
| </div> | |
| <!-- Step 1: connect wallet --> | |
| <div class="field"> | |
| <div class="step-header" id="step1-header"> | |
| <span class="n">01</span> | |
| <span class="t">connect your wallet</span> | |
| </div> | |
| <p class="footnote"> | |
| Connect the browser wallet you used to sign up for Zora (Rabby / MetaMask / Coinbase Wallet). We need a signature from an existing owner of your smart wallet to authorize the agent. | |
| </p> | |
| </div> | |
| <!-- Step 2: paste CSW --> | |
| <div class="field"> | |
| <div class="step-header" id="step2-header"> | |
| <span class="n">02</span> | |
| <span class="t">your Zora smart wallet</span> | |
| </div> | |
| <div class="label-row"> | |
| <span class="label">smart wallet address</span> | |
| <span class="hint"><a href="https://help.zora.co/en/articles/where-can-i-find-my-smart-wallet-address" target="_blank" rel="noopener">where do I find this?</a></span> | |
| </div> | |
| <input type="text" id="csw-input" placeholder="0x…" autocomplete="off" spellcheck="false" /> | |
| </div> | |
| <!-- Preview panel (populated after check) --> | |
| <div class="preview-panel hidden" id="preview-panel"> | |
| <div class="title">your agent · preview</div> | |
| <div class="row"><div class="k">agent address</div><div class="v" id="preview-agent">—</div></div> | |
| <div class="row"><div class="k">status</div><div class="v" id="preview-status">—</div></div> | |
| </div> | |
| <!-- Error panel --> | |
| <div class="error-panel hidden" id="error-panel"></div> | |
| <!-- Action --> | |
| <div class="actions"> | |
| <button class="primary" id="primary-btn" type="button">connect wallet</button> | |
| </div> | |
| <!-- Tx panel (post-submit) --> | |
| <div class="tx-panel hidden" id="tx-panel"> | |
| <div class="row"><div class="k">status</div><div class="v" id="tx-status">—</div></div> | |
| <div class="row"><div class="k">tx hash</div><div class="v" id="tx-hash">—</div></div> | |
| </div> | |
| <details class="diag" id="diag-container"> | |
| <summary>⚠ diagnostics</summary> | |
| <div class="diag-panel"> | |
| <div class="row"><div class="k">window.ethereum</div><div class="v" id="diag-eth">detecting…</div></div> | |
| <div class="row"><div class="k">eip-6963 providers</div><div class="v" id="diag-6963">detecting…</div></div> | |
| <div class="row"><div class="k">selected wallet</div><div class="v" id="diag-selected">none</div></div> | |
| <div class="row"><div class="k">csw address</div><div class="v" id="diag-csw">—</div></div> | |
| <div class="row"><div class="k">origin</div><div class="v" id="diag-origin">—</div></div> | |
| <div class="row"><div class="k">last error</div><div class="v" id="diag-err">—</div></div> | |
| </div> | |
| </details> | |
| <div class="footnote"> | |
| Under the hood: a plain Base transaction calling <code>addOwnerAddress()</code> on your Coinbase Smart Wallet — ~$0.02 in gas, no bundler, no UserOp. The agent is a Privy-managed server wallet, siloed to you, gated by policy rules that prevent it from moving funds it shouldn't. | |
| </div> | |
| </div> | |
| <!-- Wallet picker modal --> | |
| <div class="picker-overlay" id="picker-overlay"> | |
| <div class="picker"> | |
| <div class="h" id="picker-title">choose a wallet</div> | |
| <div id="picker-list"></div> | |
| <div class="status hidden" id="picker-status"></div> | |
| <button class="cancel" id="picker-cancel" type="button">cancel</button> | |
| </div> | |
| </div> | |
| <script> | |
| // ─── Sandbox storage shim ──────────────────────────────────────────── | |
| // Zora's content-coin iframe runs without `allow-same-origin`, so the | |
| // browser's real localStorage / sessionStorage throw SecurityError on | |
| // access. WalletConnect's transitive deps poke at these globals even | |
| // when we provide custom storage, so we replace them with in-memory | |
| // Maps before any library code runs. | |
| (function installStorageShim() { | |
| const makeStorage = (map) => ({ | |
| get length() { return map.size; }, | |
| clear() { map.clear(); }, | |
| getItem(k) { return map.has(k) ? String(map.get(k)) : null; }, | |
| setItem(k, v) { map.set(String(k), String(v)); }, | |
| removeItem(k) { map.delete(String(k)); }, | |
| key(i) { return [...map.keys()][i] ?? null; }, | |
| }); | |
| const install = (name) => { | |
| // Does it already work? If so, leave it alone. | |
| try { | |
| const existing = window[name]; | |
| if (existing && typeof existing.setItem === "function") return; | |
| } catch { /* throws → we need a shim */ } | |
| try { | |
| Object.defineProperty(window, name, { | |
| value: makeStorage(new Map()), | |
| configurable: true, | |
| writable: false, | |
| }); | |
| } catch (e) { | |
| console.warn(`[agent] could not install ${name} shim:`, e); | |
| } | |
| }; | |
| install("localStorage"); | |
| install("sessionStorage"); | |
| })(); | |
| // ─── Constants ─────────────────────────────────────────────────────── | |
| const API_BASE = "https://4626.fun"; | |
| const PREVIEW_ENDPOINT = API_BASE + "/api/onboarding/preview-agent-owner"; | |
| const BASE_CHAIN_ID = 8453; | |
| const BASE_CHAIN_HEX = "0x2105"; | |
| const BASE_CAIP = "eip155:8453"; | |
| const WC_PROJECT_ID = "bc3dfd319b4a0ecaa25cdee7e36bd0c4"; | |
| const WC_METADATA = { | |
| name: "agent 4626", | |
| description: "Delegate signing to the 4626 agent on your Zora wallet", | |
| url: "https://4626.fun", | |
| icons: ["https://4626.fun/miniapp-icon.png"], | |
| }; | |
| const WC_SIGN_CLIENT_CDN = "https://cdn.jsdelivr.net/npm/@walletconnect/sign-client@2/+esm"; | |
| // Connection flow uses soft nudges instead of a hard timeout so we never | |
| // kill a legitimate-but-slow wallet approval. Hard cutoff is 3 minutes. | |
| const CONNECT_SOFT_NUDGES_MS = [6000, 20000, 60000]; | |
| const CONNECT_HARD_TIMEOUT_MS = 180000; | |
| // ─── State ─────────────────────────────────────────────────────────── | |
| const state = { | |
| address: null, | |
| chainId: null, | |
| providers: [], | |
| activeProvider: null, | |
| activeInfo: null, | |
| cswAddress: null, | |
| preview: null, // { alreadyOwner, agentWalletAddress, txRequest? } | |
| previewLoading: false, | |
| previewError: null, | |
| txHash: null, | |
| txStatus: null, | |
| txError: null, | |
| lastError: null, | |
| // WalletConnect | |
| wcSignClient: null, | |
| wcSession: null, | |
| wcUri: null, | |
| wcConnecting: false, | |
| }; | |
| const $ = (id) => document.getElementById(id); | |
| const els = { | |
| chipWallet: $("chip-wallet"), | |
| chipChain: $("chip-chain"), | |
| cswInput: $("csw-input"), | |
| previewPanel: $("preview-panel"), | |
| previewAgent: $("preview-agent"), | |
| previewStatus: $("preview-status"), | |
| errorPanel: $("error-panel"), | |
| primaryBtn: $("primary-btn"), | |
| txPanel: $("tx-panel"), | |
| txStatus: $("tx-status"), | |
| txHash: $("tx-hash"), | |
| diagEth: $("diag-eth"), | |
| diag6963: $("diag-6963"), | |
| diagSelected: $("diag-selected"), | |
| diagCsw: $("diag-csw"), | |
| diagOrigin: $("diag-origin"), | |
| diagErr: $("diag-err"), | |
| step1: $("step1-header"), | |
| step2: $("step2-header"), | |
| pickerOverlay: $("picker-overlay"), | |
| pickerList: $("picker-list"), | |
| pickerTitle: $("picker-title"), | |
| pickerStatus: $("picker-status"), | |
| pickerCancel: $("picker-cancel"), | |
| }; | |
| const ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/; | |
| const shortAddr = (a) => a ? `${a.slice(0,6)}…${a.slice(-4)}` : ""; | |
| // ─── EIP-6963 provider discovery ───────────────────────────────────── | |
| function setupEip6963Discovery() { | |
| window.addEventListener("eip6963:announceProvider", (event) => { | |
| const detail = event.detail; | |
| if (!detail || !detail.provider) return; | |
| if (state.providers.some((p) => p.info.uuid === detail.info?.uuid)) return; | |
| state.providers.push({ info: detail.info, provider: detail.provider }); | |
| renderDiagnostics(); | |
| }); | |
| window.dispatchEvent(new Event("eip6963:requestProvider")); | |
| setTimeout(() => window.dispatchEvent(new Event("eip6963:requestProvider")), 300); | |
| setTimeout(() => window.dispatchEvent(new Event("eip6963:requestProvider")), 1000); | |
| } | |
| // ─── Wallet picker ─────────────────────────────────────────────────── | |
| function renderPicker() { | |
| els.pickerList.innerHTML = ""; | |
| els.pickerStatus.classList.add("hidden"); | |
| els.pickerStatus.classList.remove("err"); | |
| els.pickerTitle.textContent = "choose a wallet"; | |
| // WalletConnect — featured primary. Works regardless of browser | |
| // extension popup policy or iframe sandbox restrictions. | |
| const wcBtn = mkPickerButton({ | |
| featured: true, | |
| iconBadge: "WC", | |
| name: "Mobile wallet (WalletConnect)", | |
| sub: "scan QR · works with Zora mobile, Rainbow, MetaMask, any WC wallet", | |
| onClick: connectWalletConnect, | |
| }); | |
| els.pickerList.appendChild(wcBtn); | |
| const divider = document.createElement("div"); | |
| divider.className = "divider"; | |
| divider.textContent = "or · browser extension"; | |
| els.pickerList.appendChild(divider); | |
| const providers = [...state.providers]; | |
| if (providers.length === 0 && window.ethereum) { | |
| providers.push({ | |
| info: { name: "Browser wallet (window.ethereum)", rdns: "legacy" }, | |
| provider: window.ethereum, | |
| }); | |
| } | |
| for (const { info, provider } of providers) { | |
| const btn = mkPickerButton({ | |
| featured: false, | |
| iconUrl: info.icon, | |
| iconBadge: (info.name || "?").slice(0, 2).toUpperCase(), | |
| name: info.name || info.rdns || "Unknown", | |
| sub: info.rdns || "", | |
| onClick: () => connectExtension(provider, info), | |
| }); | |
| els.pickerList.appendChild(btn); | |
| } | |
| if (providers.length === 0) { | |
| const msg = document.createElement("div"); | |
| msg.style.cssText = "color:var(--muted);font-size:11px;padding:10px 4px;line-height:1.6"; | |
| msg.textContent = "No browser-extension wallet detected. Use WalletConnect above — it works from your phone with any WC-compatible wallet, no browser extension needed."; | |
| els.pickerList.appendChild(msg); | |
| } | |
| } | |
| function mkPickerButton({ featured, iconUrl, iconBadge, name, sub, onClick }) { | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = "option" + (featured ? " featured" : ""); | |
| if (iconUrl) { | |
| const img = document.createElement("img"); | |
| img.src = iconUrl; img.alt = ""; | |
| img.onerror = () => { | |
| img.style.display = "none"; | |
| const bd = document.createElement("span"); | |
| bd.className = "badge-icon"; bd.textContent = iconBadge; | |
| btn.insertBefore(bd, btn.firstChild); | |
| }; | |
| btn.appendChild(img); | |
| } else { | |
| const bd = document.createElement("span"); | |
| bd.className = "badge-icon"; bd.textContent = iconBadge; | |
| btn.appendChild(bd); | |
| } | |
| const meta = document.createElement("span"); | |
| meta.className = "meta"; | |
| const nm = document.createElement("span"); nm.className = "name"; nm.textContent = name; | |
| meta.appendChild(nm); | |
| if (sub) { | |
| const sb = document.createElement("span"); sb.className = "sub"; sb.textContent = sub; | |
| meta.appendChild(sb); | |
| } | |
| btn.appendChild(meta); | |
| btn.addEventListener("click", onClick); | |
| return btn; | |
| } | |
| function showPicker() { renderPicker(); els.pickerOverlay.classList.add("show"); } | |
| function hidePicker() { els.pickerOverlay.classList.remove("show"); setPickerStatus(null); } | |
| function setPickerStatus(text, isError = false, withSpinner = false) { | |
| if (!text) { els.pickerStatus.classList.add("hidden"); return; } | |
| els.pickerStatus.classList.remove("hidden"); | |
| els.pickerStatus.classList.toggle("err", isError); | |
| els.pickerStatus.innerHTML = (withSpinner ? '<span class="spin"></span> ' : "") + text; | |
| } | |
| function setPickerButtonsDisabled(disabled) { | |
| els.pickerList.querySelectorAll("button.option").forEach((b) => (b.disabled = disabled)); | |
| } | |
| els.pickerCancel.addEventListener("click", hidePicker); | |
| els.pickerOverlay.addEventListener("click", (e) => { if (e.target === els.pickerOverlay) hidePicker(); }); | |
| async function connectExtension(provider, info) { | |
| setPickerButtonsDisabled(true); | |
| const name = info.name || "wallet"; | |
| // Staggered nudges rather than a hard cutoff — user might need a | |
| // moment to find the extension popup (often opens behind other tabs | |
| // or tucked in the browser toolbar). | |
| const nudges = [ | |
| { at: CONNECT_SOFT_NUDGES_MS[0], msg: `still waiting on ${name}… look in your browser's extension toolbar or pinned popup.` }, | |
| { at: CONNECT_SOFT_NUDGES_MS[1], msg: `${name} popup hasn't responded yet — if you don't see it, click your browser's extension icon to bring it forward.` }, | |
| { at: CONNECT_SOFT_NUDGES_MS[2], msg: `over a minute — cancel and try again, or pick a different wallet. Brave/Edge sometimes suppress popups inside iframes.` }, | |
| ]; | |
| const timers = []; | |
| const started = Date.now(); | |
| setPickerStatus(`opening ${name}… approve the popup to continue.`, false, true); | |
| for (const n of nudges) { | |
| timers.push(setTimeout(() => setPickerStatus(n.msg, false, true), n.at)); | |
| } | |
| const hardTimer = setTimeout(() => { /* reached in race below */ }, CONNECT_HARD_TIMEOUT_MS); | |
| timers.push(hardTimer); | |
| try { | |
| const accounts = await Promise.race([ | |
| provider.request({ method: "eth_requestAccounts" }), | |
| new Promise((_, rej) => setTimeout( | |
| () => rej(new Error(`no response from ${name} after 3 minutes — try a different wallet or reload`)), | |
| CONNECT_HARD_TIMEOUT_MS, | |
| )), | |
| ]); | |
| state.activeProvider = provider; | |
| state.activeInfo = info; | |
| state.address = Array.isArray(accounts) ? accounts[0] : null; | |
| const chain = await provider.request({ method: "eth_chainId" }); | |
| state.chainId = parseInt(chain, 16); | |
| state.lastError = null; | |
| attachProviderListeners(provider); | |
| hidePicker(); | |
| updateUi(); | |
| if (isValidAddress(els.cswInput.value)) runPreview(); | |
| } catch (err) { | |
| const fe = friendlyErr(err); | |
| setPickerStatus(`${fe}`, true); | |
| setPickerButtonsDisabled(false); | |
| state.lastError = fe; | |
| renderDiagnostics(); | |
| } finally { | |
| for (const t of timers) clearTimeout(t); | |
| } | |
| } | |
| function attachProviderListeners(provider) { | |
| if (provider._4626_attached) return; | |
| provider._4626_attached = true; | |
| provider.on?.("accountsChanged", (accs) => { | |
| state.address = (Array.isArray(accs) ? accs[0] : null) || null; | |
| state.preview = null; renderPreview(); updateUi(); | |
| }); | |
| provider.on?.("chainChanged", (hex) => { | |
| state.chainId = parseInt(hex, 16); updateUi(); | |
| }); | |
| } | |
| // ─── WalletConnect v2 (mobile-wallet QR path) ───────────────────────── | |
| // | |
| // Why this exists: browser extension wallets (Rabby / MetaMask) are | |
| // frequently suppressed inside Zora's sandboxed content-coin iframe by | |
| // Brave, Edge, and sometimes Chrome's popup policies. WalletConnect | |
| // sidesteps this entirely — the approval happens on the user's phone | |
| // over a WebSocket relay, not via a browser extension popup. | |
| // | |
| // We use SignClient directly (not EthereumProvider) so we can pass | |
| // custom in-memory storage. Transitive deps of WC still poke at | |
| // localStorage/sessionStorage, but those are shimmed at the top of | |
| // this script. | |
| class MemKVStorage { | |
| constructor() { this.map = new Map(); } | |
| async getKeys() { return [...this.map.keys()]; } | |
| async getEntries() { return [...this.map.entries()]; } | |
| async getItem(k) { return this.map.get(k); } | |
| async setItem(k, v) { this.map.set(k, v); } | |
| async removeItem(k) { this.map.delete(k); } | |
| } | |
| async function loadWcSignClient() { | |
| if (window.__wcSignClient) return window.__wcSignClient; | |
| const mod = await import(/* @vite-ignore */ WC_SIGN_CLIENT_CDN); | |
| const SC = mod.default || mod.SignClient || mod; | |
| if (typeof SC?.init !== "function") { | |
| throw new Error("WalletConnect SignClient module loaded but has no init()"); | |
| } | |
| window.__wcSignClient = SC; | |
| return SC; | |
| } | |
| async function connectWalletConnect() { | |
| if (state.wcConnecting) return; | |
| state.wcConnecting = true; | |
| setPickerButtonsDisabled(true); | |
| renderWcPane({ phase: "loading" }); | |
| let SignClient; | |
| try { | |
| SignClient = await loadWcSignClient(); | |
| } catch (e) { | |
| state.wcConnecting = false; | |
| setPickerButtonsDisabled(false); | |
| renderWcPane(null); | |
| setPickerStatus(`WalletConnect failed to load: ${friendlyErr(e)}`, true); | |
| state.lastError = "wc load: " + friendlyErr(e); | |
| renderDiagnostics(); | |
| return; | |
| } | |
| let signClient; | |
| try { | |
| signClient = await SignClient.init({ | |
| projectId: WC_PROJECT_ID, | |
| metadata: WC_METADATA, | |
| storage: new MemKVStorage(), | |
| }); | |
| } catch (e) { | |
| state.wcConnecting = false; | |
| setPickerButtonsDisabled(false); | |
| renderWcPane(null); | |
| setPickerStatus(`WalletConnect init failed: ${friendlyErr(e)}`, true); | |
| state.lastError = "wc init: " + friendlyErr(e); | |
| renderDiagnostics(); | |
| return; | |
| } | |
| state.wcSignClient = signClient; | |
| let connectResult; | |
| try { | |
| connectResult = await signClient.connect({ | |
| requiredNamespaces: { | |
| eip155: { | |
| methods: [ | |
| "eth_sendTransaction", | |
| "eth_signTransaction", | |
| "personal_sign", | |
| "eth_sign", | |
| "wallet_switchEthereumChain", | |
| ], | |
| chains: [BASE_CAIP], | |
| events: ["accountsChanged", "chainChanged"], | |
| }, | |
| }, | |
| }); | |
| } catch (e) { | |
| state.wcConnecting = false; | |
| setPickerButtonsDisabled(false); | |
| renderWcPane(null); | |
| setPickerStatus(`WalletConnect pairing failed: ${friendlyErr(e)}`, true); | |
| state.lastError = "wc connect: " + friendlyErr(e); | |
| renderDiagnostics(); | |
| return; | |
| } | |
| const { uri, approval } = connectResult; | |
| state.wcUri = uri || null; | |
| renderWcPane({ phase: "awaiting-scan" }); | |
| // Block on the user approving on their phone. No hard timeout here — | |
| // people take as long as they take to find their wallet and tap. | |
| try { | |
| const session = await approval(); | |
| state.wcSession = session; | |
| const caipAccount = session?.namespaces?.eip155?.accounts?.[0] || ""; | |
| const parts = caipAccount.split(":"); | |
| const addr = parts.length === 3 ? parts[2] : null; | |
| if (!addr) throw new Error("no eip155 account returned in session"); | |
| const provider = makeWcEip1193Provider(signClient, session); | |
| state.activeProvider = provider; | |
| state.activeInfo = { name: "WalletConnect", rdns: "org.walletconnect" }; | |
| state.address = addr; | |
| state.chainId = BASE_CHAIN_ID; // we required eip155:8453 in namespace | |
| state.lastError = null; | |
| state.wcConnecting = false; | |
| attachProviderListeners(provider); | |
| wireWcSessionEvents(signClient); | |
| hidePicker(); | |
| renderWcPane(null); | |
| updateUi(); | |
| if (isValidAddress(els.cswInput.value)) runPreview(); | |
| } catch (e) { | |
| state.wcConnecting = false; | |
| setPickerButtonsDisabled(false); | |
| renderWcPane(null); | |
| setPickerStatus(`connection cancelled or rejected: ${friendlyErr(e)}`, true); | |
| state.lastError = "wc approval: " + friendlyErr(e); | |
| renderDiagnostics(); | |
| } | |
| } | |
| function makeWcEip1193Provider(signClient, session) { | |
| const topic = session.topic; | |
| const handlers = new Map(); | |
| return { | |
| isWalletConnect: true, | |
| request: async ({ method, params }) => { | |
| return signClient.request({ | |
| topic, | |
| chainId: BASE_CAIP, | |
| request: { method, params: params || [] }, | |
| }); | |
| }, | |
| on: (event, cb) => { | |
| if (!handlers.has(event)) handlers.set(event, new Set()); | |
| handlers.get(event).add(cb); | |
| }, | |
| _emit: (event, payload) => { | |
| for (const cb of handlers.get(event) || []) { try { cb(payload); } catch {} } | |
| }, | |
| _topic: topic, | |
| }; | |
| } | |
| function wireWcSessionEvents(signClient) { | |
| signClient.on("session_event", ({ params }) => { | |
| const event = params?.event; | |
| if (!event || !state.activeProvider?._emit) return; | |
| if (event.name === "accountsChanged") { | |
| const next = Array.isArray(event.data) ? event.data[0] : null; | |
| state.activeProvider._emit("accountsChanged", next ? [next.split(":").pop()] : []); | |
| } else if (event.name === "chainChanged") { | |
| const hex = "0x" + Number(event.data).toString(16); | |
| state.activeProvider._emit("chainChanged", hex); | |
| } | |
| }); | |
| signClient.on("session_delete", () => { | |
| state.wcSession = null; | |
| state.activeProvider = null; | |
| state.activeInfo = null; | |
| state.address = null; | |
| state.chainId = null; | |
| updateUi(); | |
| }); | |
| } | |
| // ─── WC pairing pane (rendered inside the picker overlay) ──────────── | |
| function renderWcPane(cfg) { | |
| if (!cfg) { | |
| // Restore normal picker list | |
| renderPicker(); | |
| return; | |
| } | |
| els.pickerList.innerHTML = ""; | |
| els.pickerStatus.classList.add("hidden"); | |
| els.pickerTitle.textContent = "scan with your phone"; | |
| const pane = document.createElement("div"); | |
| pane.className = "wc-pane"; | |
| const sub = document.createElement("p"); sub.className = "wc-sub"; | |
| if (cfg.phase === "loading") { | |
| sub.innerHTML = '<span class="spin"></span> loading WalletConnect…'; | |
| pane.appendChild(sub); | |
| els.pickerList.appendChild(pane); | |
| return; | |
| } | |
| const uri = state.wcUri; | |
| if (!uri) { | |
| sub.textContent = "preparing pairing URI…"; | |
| pane.appendChild(sub); | |
| els.pickerList.appendChild(pane); | |
| return; | |
| } | |
| sub.textContent = "scan this code with any WalletConnect-compatible wallet — Zora mobile, Rainbow, MetaMask mobile, Trust, etc."; | |
| pane.appendChild(sub); | |
| const qrBox = document.createElement("div"); | |
| qrBox.className = "wc-qr"; | |
| const qrImg = document.createElement("img"); | |
| qrImg.alt = "WalletConnect QR"; | |
| qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=240x240&margin=0&data=${encodeURIComponent(uri)}`; | |
| qrImg.onerror = () => { | |
| qrBox.innerHTML = '<div class="wc-qr-fallback">QR image blocked by CSP. Copy the URI below and paste it into your wallet app.</div>'; | |
| }; | |
| qrBox.appendChild(qrImg); | |
| pane.appendChild(qrBox); | |
| const actions = document.createElement("div"); | |
| actions.className = "wc-actions"; | |
| const openBtn = document.createElement("button"); | |
| openBtn.type = "button"; | |
| openBtn.textContent = "Open on this device ↗"; | |
| openBtn.addEventListener("click", () => { | |
| try { window.open(uri, "_blank"); } catch { /* popup blocked */ } | |
| }); | |
| actions.appendChild(openBtn); | |
| const copyBtn = document.createElement("button"); | |
| copyBtn.type = "button"; | |
| copyBtn.textContent = "Copy URI"; | |
| copyBtn.addEventListener("click", async () => { | |
| try { | |
| await navigator.clipboard.writeText(uri); | |
| copyBtn.textContent = "✓ copied"; | |
| setTimeout(() => { copyBtn.textContent = "Copy URI"; }, 1800); | |
| } catch { | |
| copyBtn.textContent = "⚠ clipboard blocked"; | |
| } | |
| }); | |
| actions.appendChild(copyBtn); | |
| pane.appendChild(actions); | |
| const uriText = document.createElement("div"); | |
| uriText.className = "wc-uri-text"; | |
| uriText.textContent = uri; | |
| pane.appendChild(uriText); | |
| const waiting = document.createElement("div"); | |
| waiting.className = "wc-waiting"; | |
| waiting.innerHTML = '<span class="spin"></span> waiting for your wallet to approve…'; | |
| pane.appendChild(waiting); | |
| els.pickerList.appendChild(pane); | |
| } | |
| // ─── CSW input + preview ───────────────────────────────────────────── | |
| function isValidAddress(v) { return ADDRESS_RE.test((v || "").trim()); } | |
| els.cswInput.addEventListener("input", () => { | |
| const raw = els.cswInput.value.trim(); | |
| state.cswAddress = isValidAddress(raw) ? raw : null; | |
| state.preview = null; state.previewError = null; | |
| els.cswInput.classList.toggle("error", raw.length > 0 && !state.cswAddress); | |
| renderPreview(); renderError(); renderDiagnostics(); updateUi(); | |
| }); | |
| els.cswInput.addEventListener("blur", () => { | |
| if (state.cswAddress && state.address && !state.preview && !state.previewLoading) runPreview(); | |
| }); | |
| async function runPreview() { | |
| if (!state.address || !state.cswAddress) return; | |
| state.previewLoading = true; | |
| state.previewError = null; | |
| renderPreview(); renderError(); updateUi(); | |
| try { | |
| const resp = await fetch(PREVIEW_ENDPOINT, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ cswAddress: state.cswAddress, connectedEoa: state.address }), | |
| }); | |
| const text = await resp.text(); | |
| let body = null; | |
| try { body = JSON.parse(text); } catch {} | |
| if (!resp.ok || !body || body.success === false) { | |
| const msg = (body && body.error) || `HTTP ${resp.status}`; | |
| state.previewError = msg; state.preview = null; | |
| } else { | |
| state.preview = body.data; | |
| state.previewError = null; | |
| } | |
| } catch (err) { | |
| state.previewError = "Could not reach 4626 preview endpoint: " + friendlyErr(err); | |
| state.preview = null; | |
| } finally { | |
| state.previewLoading = false; | |
| renderPreview(); renderError(); updateUi(); | |
| } | |
| } | |
| function renderPreview() { | |
| if (!state.preview && !state.previewLoading) { | |
| els.previewPanel.classList.add("hidden"); return; | |
| } | |
| els.previewPanel.classList.remove("hidden"); | |
| if (state.previewLoading) { | |
| els.previewAgent.textContent = "loading…"; | |
| els.previewStatus.innerHTML = '<span class="spin"></span> verifying ownership on-chain'; | |
| return; | |
| } | |
| const p = state.preview; | |
| els.previewAgent.textContent = p.agentWalletAddress; | |
| els.previewStatus.textContent = p.alreadyOwner | |
| ? "already hired — no action needed" | |
| : "ready to sign — one tx on Base"; | |
| } | |
| function renderError() { | |
| if (!state.previewError) { els.errorPanel.classList.add("hidden"); return; } | |
| els.errorPanel.classList.remove("hidden"); | |
| els.errorPanel.textContent = state.previewError; | |
| } | |
| // ─── UI rendering ──────────────────────────────────────────────────── | |
| function renderDiagnostics() { | |
| els.diagEth.textContent = window.ethereum ? `present (${describeEthFlavor()})` : "undefined"; | |
| els.diag6963.textContent = state.providers.length | |
| ? state.providers.map((p) => p.info.name || p.info.rdns || "?").join(" · ") | |
| : "none announced yet"; | |
| els.diagSelected.textContent = state.activeInfo | |
| ? `${state.activeInfo.name || state.activeInfo.rdns || "unknown"}${state.address ? ` · ${shortAddr(state.address)}` : ""}` | |
| : "none"; | |
| els.diagCsw.textContent = state.cswAddress ? shortAddr(state.cswAddress) : "—"; | |
| els.diagOrigin.textContent = window.location.origin || "null"; | |
| els.diagErr.textContent = state.lastError || "—"; | |
| } | |
| function describeEthFlavor() { | |
| if (!window.ethereum) return "none"; | |
| const e = window.ethereum; const flags = []; | |
| if (e.isRabby) flags.push("rabby"); | |
| if (e.isMetaMask) flags.push("metamask"); | |
| if (e.isCoinbaseWallet) flags.push("coinbase"); | |
| if (e.isBraveWallet) flags.push("brave"); | |
| if (Array.isArray(e.providers)) flags.push(`providers[${e.providers.length}]`); | |
| return flags.length ? flags.join(",") : "unknown flavor"; | |
| } | |
| function updateChips() { | |
| els.chipWallet.textContent = state.address ? `wallet · ${shortAddr(state.address)}` : "wallet · not connected"; | |
| els.chipWallet.className = state.address ? "chip ok" : "chip"; | |
| if (state.chainId === BASE_CHAIN_ID) { | |
| els.chipChain.textContent = "chain · base"; els.chipChain.className = "chip ok"; | |
| } else if (state.chainId) { | |
| els.chipChain.textContent = `chain · ${state.chainId} · switch to base`; els.chipChain.className = "chip warn"; | |
| } else { | |
| els.chipChain.textContent = "chain · unknown"; els.chipChain.className = "chip"; | |
| } | |
| } | |
| function updateStepState() { | |
| els.step1.classList.toggle("done", !!state.address); | |
| els.step2.classList.toggle("done", !!(state.cswAddress && state.preview && state.preview.alreadyOwner === false)); | |
| } | |
| function updatePrimaryBtn() { | |
| const btn = els.primaryBtn; | |
| if (!state.address) { btn.textContent = "connect wallet"; btn.disabled = false; return; } | |
| if (!state.cswAddress) { btn.textContent = "enter your smart wallet"; btn.disabled = true; return; } | |
| if (state.previewLoading) { btn.textContent = "checking…"; btn.disabled = true; return; } | |
| if (state.previewError) { btn.textContent = "try again"; btn.disabled = false; return; } | |
| if (!state.preview) { btn.textContent = "continue"; btn.disabled = false; return; } | |
| if (state.preview.alreadyOwner) { btn.textContent = "agent already hired ✓"; btn.disabled = true; return; } | |
| if (state.chainId !== BASE_CHAIN_ID) { btn.textContent = "switch to base"; btn.disabled = false; return; } | |
| if (state.txStatus === "pending") { btn.textContent = "signing…"; btn.disabled = true; return; } | |
| if (state.txStatus === "confirmed") { btn.textContent = "open 4626.fun ↗"; btn.disabled = false; return; } | |
| btn.textContent = "delegate signing"; btn.disabled = false; | |
| } | |
| function updateUi() { updateChips(); updateStepState(); updatePrimaryBtn(); renderDiagnostics(); } | |
| // ─── Primary action handler ────────────────────────────────────────── | |
| els.primaryBtn.addEventListener("click", onPrimaryClick); | |
| async function onPrimaryClick() { | |
| if (!state.address) return showPicker(); | |
| if (!state.cswAddress) return; | |
| if (state.previewError) return runPreview(); | |
| if (!state.preview) return runPreview(); | |
| if (state.preview.alreadyOwner) return; | |
| if (state.chainId !== BASE_CHAIN_ID) return switchToBase(); | |
| if (state.txStatus === "confirmed") { window.open(API_BASE, "_blank"); return; } | |
| await sendInstallTx(); | |
| } | |
| async function switchToBase() { | |
| if (!state.activeProvider) return; | |
| try { | |
| await state.activeProvider.request({ | |
| method: "wallet_switchEthereumChain", | |
| params: [{ chainId: BASE_CHAIN_HEX }], | |
| }); | |
| state.chainId = BASE_CHAIN_ID; state.lastError = null; | |
| } catch (err) { state.lastError = friendlyErr(err); renderDiagnostics(); } | |
| updateUi(); | |
| } | |
| async function sendInstallTx() { | |
| const tx = state.preview && state.preview.txRequest; | |
| if (!tx) return; | |
| state.txStatus = "pending"; state.txHash = null; state.txError = null; | |
| renderTx(); updateUi(); | |
| try { | |
| const txHash = await state.activeProvider.request({ | |
| method: "eth_sendTransaction", | |
| params: [{ from: state.address, to: tx.to, data: tx.data, value: tx.value }], | |
| }); | |
| state.txHash = txHash; | |
| renderTx(); pollForReceipt(txHash); | |
| } catch (err) { | |
| state.txStatus = "failed"; state.txError = friendlyErr(err); | |
| state.lastError = state.txError; | |
| renderTx(); renderDiagnostics(); updateUi(); | |
| } | |
| } | |
| async function pollForReceipt(hash) { | |
| let attempts = 0; | |
| while (attempts < 40) { | |
| await new Promise((r) => setTimeout(r, 1500)); attempts += 1; | |
| try { | |
| const receipt = await state.activeProvider.request({ | |
| method: "eth_getTransactionReceipt", params: [hash], | |
| }); | |
| if (receipt) { | |
| state.txStatus = receipt.status === "0x1" ? "confirmed" : "failed"; | |
| if (receipt.status !== "0x1") state.txError = "reverted on-chain"; | |
| renderTx(); updateUi(); | |
| return; | |
| } | |
| } catch {} | |
| } | |
| state.txStatus = "pending (check basescan)"; renderTx(); | |
| } | |
| function renderTx() { | |
| if (!state.txStatus) { els.txPanel.classList.add("hidden"); return; } | |
| els.txPanel.classList.remove("hidden"); | |
| els.txStatus.textContent = state.txError ? `${state.txStatus} · ${state.txError}` : state.txStatus; | |
| if (state.txHash) { | |
| const short = `${state.txHash.slice(0, 10)}…${state.txHash.slice(-8)}`; | |
| els.txHash.innerHTML = `<a href="https://basescan.org/tx/${state.txHash}" target="_blank" rel="noopener">${short}</a>`; | |
| } else { els.txHash.textContent = "—"; } | |
| } | |
| function friendlyErr(e) { | |
| if (!e) return "unknown error"; | |
| const msg = String(e.message || e.toString()); | |
| if (/user (rejected|denied)/i.test(msg)) return "signature rejected"; | |
| if (/insufficient funds/i.test(msg)) return "insufficient eth on base"; | |
| if (/unrecognized chain|chain.*not.*added/i.test(msg)) return "base not added — add it and retry"; | |
| if (/timed out|timeout/i.test(msg)) return "timed out — popup may be hidden or blocked"; | |
| if (/failed to fetch|network/i.test(msg)) return "network error — check connection"; | |
| return msg.slice(0, 220); | |
| } | |
| (function init() { | |
| setupEip6963Discovery(); | |
| renderDiagnostics(); | |
| updateUi(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment