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
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>