did:cow, a proposal for an ID resolution method with most of the convenience of did:plc/did:web and the robustness of a public blockchain
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 628 lines 22 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 7 <title>did:cow — Consensus Ownership Wrapper</title> 8 <meta name="description" content="A decentralized identifier that wraps did:plc or did:web with Ethereum-backed control, giving you censorship-resistant identity without requiring a blockchain transaction to get started."> 9 <meta property="og:title" content="did:cow — Consensus Ownership Wrapper"> 10 <meta property="og:description" content="A decentralized identifier that wraps did:plc or did:web with Ethereum-backed control, giving you censorship-resistant identity without requiring a blockchain transaction to get started."> 11 <meta property="og:url" content="https://cow.watch"> 12 <meta property="og:type" content="website"> 13 <style> 14 * { box-sizing: border-box; margin: 0; padding: 0; } 15 16 body { 17 font-family: system-ui, -apple-system, sans-serif; 18 background: #f5f5f5; 19 color: #1a1a1a; 20 padding: 2rem; 21 } 22 23 header { 24 display: flex; 25 justify-content: space-between; 26 align-items: center; 27 margin-bottom: 2rem; 28 } 29 30 .header-left { display: flex; align-items: center; gap: 1.5rem; } 31 32 .cow-ascii { 33 font-family: monospace; 34 font-size: 0.7rem; 35 line-height: 1.3; 36 color: #6b7280; 37 white-space: pre; 38 user-select: none; 39 } 40 41 h1 { font-size: 1.5rem; font-weight: 700; } 42 h1 span { color: #6b7280; font-weight: 400; } 43 44 .mobile-break { display: none; } 45 46 @media (max-width: 600px) { 47 h1 span { font-size: 1rem; } 48 .mobile-break { display: block; } 49 } 50 51 #connect-btn { 52 background: #1a1a1a; 53 color: #fff; 54 border: none; 55 padding: 0 1rem; 56 border-radius: 6px; 57 cursor: pointer; 58 font-size: 0.9rem; 59 height: 2.5rem; 60 } 61 #connect-btn:hover { background: #333; } 62 #connect-btn.connected { background: #166534; } 63 64 .card { 65 background: #fff; 66 border: 1px solid #e5e7eb; 67 border-radius: 8px; 68 padding: 1.5rem; 69 margin-bottom: 1.5rem; 70 } 71 72 .card h2 { 73 font-size: 1rem; 74 font-weight: 600; 75 margin-bottom: 1rem; 76 color: #374151; 77 } 78 79 .field { margin-bottom: 0.75rem; } 80 81 label { 82 display: block; 83 font-size: 0.8rem; 84 font-weight: 500; 85 color: #6b7280; 86 margin-bottom: 0.25rem; 87 text-transform: uppercase; 88 letter-spacing: 0.05em; 89 } 90 91 input[type="text"] { 92 width: 100%; 93 padding: 0.5rem 0.75rem; 94 border: 1px solid #d1d5db; 95 border-radius: 6px; 96 font-size: 0.95rem; 97 font-family: monospace; 98 height: 2.5rem; 99 } 100 input[type="text"]:focus { 101 outline: none; 102 border-color: #6b7280; 103 } 104 105 .row { display: flex; gap: 0.75rem; align-items: flex-end; } 106 .row .field { margin-bottom: 0; } 107 .row .field { flex: 1; } 108 109 button.action { 110 background: #1a1a1a; 111 color: #fff; 112 border: none; 113 padding: 0 1.25rem; 114 border-radius: 6px; 115 cursor: pointer; 116 font-size: 0.95rem; 117 height: 2.5rem; 118 white-space: nowrap; 119 } 120 button.action:hover { background: #333; } 121 button.action:disabled { background: #9ca3af; cursor: default; } 122 123 .result { 124 margin-top: 1rem; 125 padding: 1rem; 126 background: #f9fafb; 127 border: 1px solid #e5e7eb; 128 border-radius: 6px; 129 font-family: monospace; 130 font-size: 0.85rem; 131 white-space: pre-wrap; 132 word-break: break-all; 133 } 134 135 .result.error { background: #fef2f2; border-color: #fecaca; color: #dc2626; } 136 .result.success { background: #f0fdf4; border-color: #bbf7d0; } 137 138 .did-output { 139 margin-top: 1rem; 140 padding: 0.75rem 1rem; 141 background: #f0fdf4; 142 border: 1px solid #bbf7d0; 143 border-radius: 6px; 144 display: none; 145 } 146 147 .reveal-track { 148 position: relative; 149 display: flex; 150 align-items: center; 151 min-height: 5.5rem; 152 margin-bottom: 0.5rem; 153 } 154 155 #create-did-value { 156 font-family: monospace; 157 font-size: 1.2rem; 158 word-break: break-all; 159 width: 100%; 160 } 161 162 .running-cow { 163 position: absolute; 164 top: 0; 165 display: none; 166 pointer-events: none; 167 } 168 169 .running-cow pre { 170 transform: scaleX(-1); 171 font-family: monospace; 172 font-size: 0.85rem; 173 line-height: 1.3; 174 white-space: pre; 175 margin: 0; 176 } 177 178 .copy-btn { 179 background: none; 180 border: 1px solid #6b7280; 181 border-radius: 4px; 182 padding: 0.2rem 0.5rem; 183 font-size: 0.8rem; 184 cursor: pointer; 185 white-space: nowrap; 186 color: #374151; 187 } 188 .copy-btn:hover { background: #f3f4f6; } 189 190 .hint { 191 font-size: 0.8rem; 192 color: #6b7280; 193 margin-top: 0.5rem; 194 } 195 196 </style> 197</head> 198<body> 199 200<header> 201 <div class="header-left"> 202 <pre class="cow-ascii" 203> ^__^ 204 (oo)\_______ 205 (__)\ )\/\ 206 ||----w | 207 || ||</pre> 208 <h1>did:cow <span><br class="mobile-break">Consensus Ownership Wrapper</span></h1> 209 </div> 210 <button id="connect-btn" onclick="connectWallet()">Connect wallet</button> 211</header> 212 213<!-- About --> 214<div class="card"> 215 <p><b>did:cow</b> is a proposed decentralized identifier aimed at providing stronger guarantees for identities used in ATProto. 216 <br /> 217 <br /> 218 A did:cow ID consists of a pointer at a did:web or did:plc ID, and an address on the Ethereum network that can update it. 219 <br /> 220 <br /> 221 An ID can be created for free without a blockchain transaction. You only need to send a blockchain transaction if you want to point it at a different did:web or did:plc ID, or change the blockchain address that controls it. 222 <br /> 223 <br /> 224 <a style="float:right" href="/#!/did:cow:B6aaa1DAd9D09d689dc6111dcc6EA2A0d641b406:plc:pyzlzqt6b2nyrha7smfry6rv">Example</a> 225 <a href="https://tangled.org/goat.navy/did-cow">Specification</a> 226 | 227 <a href="https://api.cow.watch/did:cow:B6aaa1DAd9D09d689dc6111dcc6EA2A0d641b406:plc:pyzlzqt6b2nyrha7smfry6rv">API</a> 228 | 229 <a href="https://sepolia.etherscan.io/address/0x8560798CD78D09143D0194249503ebe25706ed96">Contract on testnet</a> 230 <span id="about-short"><a href="#" onclick="showMore(event)"></a></span><span id="about-more" style="display:none"></span></p> 231</div> 232 233<!-- Resolve --> 234<div class="card"> 235 <h2>Resolve</h2> 236 <div class="row"> 237 <div class="field"> 238 <label>DID</label> 239 <input type="text" id="resolve-did" placeholder="did:cow:8BC101ABF5BcF8b6209FaaAD4D761C1ED14999Be:plc:pyzlzqt6b2nyrha7smfry6rv"> 240 </div> 241 <button class="action" onclick="resolveDid()">Resolve</button> 242 </div> 243 <div id="resolve-status" class="result" style="display:none; margin-top: 1rem;"></div> 244 <div id="resolve-edit" style="display:none; margin-top: 1rem;"> 245 <div class="row" style="margin-bottom: 0.75rem;"> 246 <div class="field"> 247 <label>Controller</label> 248 <input type="text" id="edit-controller" disabled> 249 </div> 250 <button class="action" id="edit-controller-btn" onclick="sendUpdate('controller')" disabled>Update</button> 251 </div> 252 <div class="row"> 253 <div class="field"> 254 <label>Wrapped DID</label> 255 <input type="text" id="edit-wrapped" disabled> 256 </div> 257 <button class="action" id="edit-wrapped-btn" onclick="sendUpdate('wrapped')" disabled>Update</button> 258 </div> 259 <div id="edit-status" class="result" style="display:none; margin-top: 0.75rem;"></div> 260 <label style="margin-top: 1.25rem; display: block;">DID Document</label> 261 <div id="resolve-result" class="result" style="display:none; margin-top:0"></div> 262 </div> 263</div> 264 265<!-- Create --> 266<div class="card"> 267 <h2>Create</h2> 268 <p class="hint" style="margin-bottom:0.75rem">No transaction required — a did:cow identifier is formed from your controller address and wrapped DID.</p> 269 <div class="field"> 270 <label>Controller address <span id="controller-hint" style="color:#166534;display:none">(your connected address)</span></label> 271 <input type="text" id="create-controller" placeholder="8BC101ABF5BcF8b6209FaaAD4D761C1ED14999Be"> 272 </div> 273 <div class="field"> 274 <label>Wrapped DID</label> 275 <input type="text" id="create-wrapped" placeholder="did:plc:pyzlzqt6b2nyrha7smfry6rv or plc:pyzlzqt6b2nyrha7smfry6rv"> 276 </div> 277 <button class="action" onclick="createDid()">Construct DID</button> 278 <div id="create-output" class="did-output"> 279 <div class="reveal-track"> 280 <div class="running-cow" id="running-cow"><pre> ^__^ 281(oo)\_______ 282(__)\ )\/\ 283 ||----w | 284 || ||</pre></div> 285 <span id="create-did-value"></span> 286 </div> 287 <button class="copy-btn" id="copy-btn" onclick="copyDid()" style="display:none">Copy</button> 288 </div> 289 <div id="create-error" class="result error" style="display:none"></div> 290</div> 291 292 293<script type="module"> 294 const API_BASE = "https://api.cow.watch"; 295 296 const CONTRACT_ABI = [ 297 "function updateController(address _controller, string _wrappedDID, address _newController)", 298 "function updateWrappedDID(address _controller, string _wrappedDID, string _newWrappedDID)", 299 "error NotController()", 300 "error NotInitialized()", 301 "error AlreadyDeactivated()", 302 "error EmptyWrappedDID()", 303 ]; 304 305 const CONTRACT_ERROR_MESSAGES = { 306 "0x23019e67": "The connected wallet is not the current controller of this DID.", 307 "0x87138d5c": "This DID has not been registered on-chain yet.", 308 "0x5deafd90": "This DID has been permanently deactivated.", 309 "0x8a98f156": "Wrapped DID cannot be empty.", 310 }; 311 312 let contractConfig = null; 313 314 async function loadConfig() { 315 if (contractConfig) return contractConfig; 316 const resp = await fetch(`${API_BASE}/api/config`); 317 contractConfig = await resp.json(); 318 return contractConfig; 319 } 320 321 window.showMore = (e) => { 322 e.preventDefault(); 323 document.getElementById("about-short").style.display = "none"; 324 document.getElementById("about-more").style.display = "inline"; 325 }; 326 327 // ── Wallet ────────────────────────────────────────────────────────────────── 328 329 let connectedAddress = null; 330 331 window.connectWallet = async () => { 332 if (!window.ethereum) { 333 alert("No wallet detected. Install MetaMask or another browser wallet."); 334 return; 335 } 336 try { 337 const accounts = await window.ethereum.request({ method: "eth_requestAccounts" }); 338 connectedAddress = accounts[0]; 339 const btn = document.getElementById("connect-btn"); 340 btn.textContent = connectedAddress.slice(0, 6) + "…" + connectedAddress.slice(-4); 341 btn.classList.add("connected"); 342 343 // Pre-fill controller with checksummed address (strip 0x for did:cow format) 344 const controllerInput = document.getElementById("create-controller"); 345 if (!controllerInput.value) { 346 controllerInput.value = ethers.getAddress(connectedAddress).slice(2); 347 } 348 document.getElementById("controller-hint").style.display = "inline"; 349 // Enable edit fields if a DID has already been resolved 350 if (document.getElementById("resolve-edit").style.display !== "none") { 351 setEditEnabled(true); 352 } 353 } catch (e) { 354 console.error(e); 355 } 356 }; 357 358 // ── Resolve ───────────────────────────────────────────────────────────────── 359 360 let resolvedDid = null; // the DID last successfully resolved, for contract calls 361 362 function setEditEnabled(enabled) { 363 document.getElementById("edit-controller").disabled = !enabled; 364 document.getElementById("edit-wrapped").disabled = !enabled; 365 document.getElementById("edit-controller-btn").disabled = !enabled; 366 document.getElementById("edit-wrapped-btn").disabled = !enabled; 367 } 368 369 function extractDid(input) { 370 // Strip a full cow.watch URL if pasted, e.g. https://cow.watch/#!/did:cow:... 371 input = input.trim().replace(/^https?:\/\/[^/]+\/#!\//, ""); 372 // Also handle bare hash e.g. #!/did:cow:... 373 input = input.replace(/^#!\//, ""); 374 return input; 375 } 376 377 window.resolveDid = async () => { 378 const did = extractDid(document.getElementById("resolve-did").value); 379 if (!did) { 380 const statusEl = document.getElementById("resolve-status"); 381 statusEl.style.display = "block"; 382 statusEl.className = "result error"; 383 statusEl.textContent = "Please enter a did:cow DID."; 384 return; 385 } 386 document.getElementById("resolve-did").value = did; // normalise the field 387 window.location.hash = "!/" + did; 388 const statusEl = document.getElementById("resolve-status"); 389 const resultEl = document.getElementById("resolve-result"); 390 const editEl = document.getElementById("resolve-edit"); 391 392 statusEl.style.display = "block"; 393 statusEl.className = "result"; 394 statusEl.textContent = "Resolving…"; 395 editEl.style.display = "none"; 396 resolvedDid = null; 397 398 try { 399 const resp = await fetch(`${API_BASE}/${encodeURIComponent(did)}`); 400 const body = await resp.json(); 401 if (!resp.ok) { 402 statusEl.className = "result error"; 403 statusEl.textContent = body.detail ?? JSON.stringify(body, null, 2); 404 } else { 405 statusEl.style.display = "none"; 406 resultEl.className = "result"; 407 resultEl.textContent = JSON.stringify(body, null, 2); 408 409 const cowBlock = body["did:cow"]; 410 if (cowBlock) { 411 const controllerAddr = cowBlock.controller.split(":").pop(); 412 document.getElementById("edit-controller").value = controllerAddr; 413 document.getElementById("edit-wrapped").value = "did:" + cowBlock.wrappedDid.replace(/^did:/, ""); 414 resolvedDid = did; 415 editEl.style.display = "block"; 416 resultEl.style.display = "block"; 417 document.getElementById("edit-status").style.display = "none"; 418 setEditEnabled(!!connectedAddress); 419 } 420 } 421 } catch (e) { 422 statusEl.className = "result error"; 423 statusEl.textContent = `Network error: ${e.message}`; 424 } 425 }; 426 427 // ── Create ────────────────────────────────────────────────────────────────── 428 429 window.createDid = () => { 430 const errorEl = document.getElementById("create-error"); 431 const outputEl = document.getElementById("create-output"); 432 errorEl.style.display = "none"; 433 outputEl.style.display = "none"; 434 435 let controller = document.getElementById("create-controller").value.trim(); 436 let wrapped = document.getElementById("create-wrapped").value.trim(); 437 438 // Strip leading 0x if present 439 if (controller.startsWith("0x") || controller.startsWith("0X")) { 440 controller = controller.slice(2); 441 } 442 443 wrapped = stripDidPrefix(wrapped); 444 445 if (!controller) { 446 showCreateError("Controller address is required."); 447 return; 448 } 449 if (!/^[0-9a-fA-F]{40}$/.test(controller)) { 450 showCreateError("Controller must be a 40-character hex Ethereum address."); 451 return; 452 } 453 if (!wrapped) { 454 showCreateError("Wrapped DID is required."); 455 return; 456 } 457 if (!wrapped.includes(":")) { 458 showCreateError("Wrapped DID must include a method, e.g. plc:xxx or web:example.com"); 459 return; 460 } 461 462 // EIP-55 checksum via ethers 463 let checksummed; 464 try { 465 checksummed = ethers.getAddress("0x" + controller).slice(2); 466 } catch (e) { 467 showCreateError("Invalid Ethereum address."); 468 return; 469 } 470 471 const did = `did:cow:${checksummed}:${wrapped}`; 472 animateCowReveal(did); 473 }; 474 475 function animateCowReveal(did) { 476 const outputEl = document.getElementById("create-output"); 477 const didEl = document.getElementById("create-did-value"); 478 const cowEl = document.getElementById("running-cow"); 479 const copyBtn = document.getElementById("copy-btn"); 480 481 didEl.textContent = did; 482 didEl.style.clipPath = "inset(0 100% 0 0)"; 483 cowEl.style.display = "block"; 484 cowEl.style.left = "0px"; 485 copyBtn.style.display = "none"; 486 outputEl.style.display = "block"; 487 488 const cowWidth = cowEl.offsetWidth; 489 const trackWidth = cowEl.parentElement.offsetWidth; 490 const travel = trackWidth - cowWidth; // tail goes from 0 to right edge 491 492 const duration = 5400; 493 const start = performance.now(); 494 495 function frame(now) { 496 const t = Math.min((now - start) / duration, 1); 497 const eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; 498 const leftPx = eased * travel; 499 cowEl.style.left = leftPx + "px"; 500 // reveal tracks the tail (left edge of cow) so text comes out behind it 501 const revealPct = (leftPx / trackWidth) * 100; 502 didEl.style.clipPath = `inset(0 ${100 - revealPct}% 0 0)`; 503 if (t < 1) { 504 requestAnimationFrame(frame); 505 } else { 506 didEl.style.clipPath = ""; // reveal the last bit hidden under the cow 507 copyBtn.style.display = "inline-block"; 508 } 509 } 510 511 requestAnimationFrame(frame); 512 } 513 514 function stripDidPrefix(s) { 515 // Remove one or more leading "did:" repetitions 516 while (s.startsWith("did:")) s = s.slice(4); 517 return s; 518 } 519 520 // ── Edit ──────────────────────────────────────────────────────────────────── 521 522 function parseCowDid(did) { 523 if (!did.startsWith("did:cow:")) throw new Error("Not a did:cow DID"); 524 const rest = did.slice("did:cow:".length); 525 const colonIdx = rest.indexOf(":"); 526 if (colonIdx === -1) throw new Error("Invalid did:cow format"); 527 return { 528 controller: "0x" + rest.slice(0, colonIdx), 529 wrapped: rest.slice(colonIdx + 1), 530 }; 531 } 532 533 window.sendUpdate = async (updateType) => { 534 const statusEl = document.getElementById("edit-status"); 535 statusEl.style.display = "block"; 536 statusEl.className = "result"; 537 statusEl.textContent = "Preparing transaction…"; 538 539 try { 540 const { controller, wrapped } = parseCowDid(resolvedDid); 541 542 const config = await loadConfig(); 543 const provider = new ethers.BrowserProvider(window.ethereum); 544 545 const network = await provider.getNetwork(); 546 if (Number(network.chainId) !== config.chainId) { 547 statusEl.textContent = "Switching network…"; 548 await window.ethereum.request({ 549 method: "wallet_switchEthereumChain", 550 params: [{ chainId: "0x" + config.chainId.toString(16) }], 551 }); 552 } 553 554 const signer = await provider.getSigner(); 555 const contract = new ethers.Contract(config.contractAddress, CONTRACT_ABI, signer); 556 557 let tx; 558 if (updateType === "controller") { 559 const raw = document.getElementById("edit-controller").value.trim().replace(/^0x/i, ""); 560 if (!raw) throw new Error("Controller address is required."); 561 if (!/^[0-9a-fA-F]{40}$/.test(raw)) throw new Error("Invalid controller address — expected a 40-character hex Ethereum address."); 562 tx = await contract.updateController(controller, wrapped, "0x" + raw); 563 } else { 564 let newWrapped = stripDidPrefix(document.getElementById("edit-wrapped").value.trim()); 565 if (!newWrapped) throw new Error("Wrapped DID is required."); 566 if (!newWrapped.includes(":")) throw new Error("Wrapped DID must include a method, e.g. plc:xxx or web:example.com"); 567 tx = await contract.updateWrappedDID(controller, wrapped, newWrapped); 568 } 569 570 statusEl.textContent = `Transaction sent: ${tx.hash}\nWaiting for confirmation…`; 571 const receipt = await tx.wait(); 572 statusEl.className = "result success"; 573 statusEl.textContent = `Confirmed in block ${receipt.blockNumber}\nTx: ${tx.hash}`; 574 } catch (e) { 575 statusEl.className = "result error"; 576 if (e.code === "CALL_EXCEPTION") { 577 const selector = typeof e.data === "string" ? e.data.slice(0, 10) : null; 578 statusEl.textContent = CONTRACT_ERROR_MESSAGES[selector] ?? `Contract reverted${selector ? ` (${selector})` : ""}`; 579 } else if (e.code === "UNKNOWN_ERROR" || e.message?.includes("coalesce")) { 580 // MetaMask sometimes returns a malformed RPC response after broadcasting 581 // even though the tx went through. 582 statusEl.className = "result success"; 583 statusEl.textContent = "Transaction sent — check your wallet for confirmation status."; 584 } else { 585 statusEl.textContent = e.reason ?? e.message ?? String(e); 586 } 587 } 588 }; 589 590 window.copyDid = () => { 591 const val = document.getElementById("create-did-value").textContent; 592 navigator.clipboard.writeText(val).then(() => { 593 const btn = document.querySelector(".copy-btn"); 594 btn.textContent = "Copied!"; 595 setTimeout(() => { btn.textContent = "Copy"; }, 1500); 596 }); 597 }; 598 599 function showCreateError(msg) { 600 const el = document.getElementById("create-error"); 601 el.textContent = msg; 602 el.style.display = "block"; 603 } 604 605 // ── Enter on resolve input ─────────────────────────────────────────────────── 606 document.getElementById("resolve-did").addEventListener("keydown", e => { 607 if (e.key === "Enter") window.resolveDid(); 608 }); 609 610 // ── Hash-based deep linking ────────────────────────────────────────────────── 611 function resolveFromHash() { 612 const hash = window.location.hash; 613 if (hash.startsWith("#!/")) { 614 const did = decodeURIComponent(hash.slice(3)); 615 document.getElementById("resolve-did").value = did; 616 window.resolveDid(); 617 } 618 } 619 620 resolveFromHash(); 621 window.addEventListener("hashchange", resolveFromHash); 622</script> 623 624<!-- ethers.js for EIP-55 checksum and future MetaMask tx signing --> 625<script src="https://cdnjs.cloudflare.com/ajax/libs/ethers/6.13.4/ethers.umd.min.js"></script> 626 627</body> 628</html>