···11+# Ethereum RPC endpoint (e.g. Infura, Alchemy, or a local node)
22+RPC_URL=https://sepolia.infura.io/v3/YOUR_KEY_HERE
33+44+# Private key of the controller account (no 0x prefix)
55+PRIVATE_KEY=your_private_key_here
66+77+# Deployed CowRegistry contract address
88+CONTRACT_ADDRESS=0x0000000000000000000000000000000000000000
+234
cli/cow.py
···11+#!/usr/bin/env python3
22+"""did:cow registry CLI"""
33+44+import json
55+import os
66+import sys
77+from pathlib import Path
88+99+import click
1010+from dotenv import load_dotenv
1111+from web3 import Web3
1212+1313+load_dotenv()
1414+1515+# ABI is generated by `forge build` in the repo root
1616+_ABI_PATH = Path(__file__).parent.parent / "out" / "CowRegistry.sol" / "CowRegistry.json"
1717+1818+1919+# ---------------------------------------------------------------------------
2020+# Helpers
2121+# ---------------------------------------------------------------------------
2222+2323+def _w3():
2424+ url = os.getenv("RPC_URL")
2525+ if not url:
2626+ raise click.ClickException("RPC_URL not set in .env")
2727+ w3 = Web3(Web3.HTTPProvider(url))
2828+ if not w3.is_connected():
2929+ raise click.ClickException(f"Could not connect to {url}")
3030+ return w3
3131+3232+3333+def _contract(w3):
3434+ addr = os.getenv("CONTRACT_ADDRESS")
3535+ if not addr:
3636+ raise click.ClickException("CONTRACT_ADDRESS not set in .env")
3737+ if not _ABI_PATH.exists():
3838+ raise click.ClickException(
3939+ f"ABI not found at {_ABI_PATH} — run `forge build` first"
4040+ )
4141+ artifact = json.loads(_ABI_PATH.read_text())
4242+ return w3.eth.contract(
4343+ address=Web3.to_checksum_address(addr),
4444+ abi=artifact["abi"],
4545+ )
4646+4747+4848+def _account(w3):
4949+ key = os.getenv("PRIVATE_KEY")
5050+ if not key:
5151+ raise click.ClickException("PRIVATE_KEY not set in .env")
5252+ return w3.eth.account.from_key(key)
5353+5454+5555+def _parse_cow_did(did):
5656+ """
5757+ Parse did:cow:<controller>:<method>:<id...> into (controller_address, wrapped_did).
5858+5959+ The wrapped DID is returned without the leading 'did:' prefix, matching
6060+ what the contract stores — e.g. 'plc:7qqsrnkn4moc2jgdxvh6aa3t' or
6161+ 'web:example.com'.
6262+ """
6363+ if not did.startswith("did:cow:"):
6464+ raise click.ClickException(f"Not a did:cow DID: {did}")
6565+ # did:cow:<address>:<method>:<id...>
6666+ # splitting 'did:cow:ADDR:METHOD:ID' on ':' gives
6767+ # ['did', 'cow', 'ADDR', 'METHOD', 'ID...']
6868+ parts = did.split(":")
6969+ if len(parts) < 5:
7070+ raise click.ClickException(f"Invalid did:cow format: {did}")
7171+ controller_hex = parts[2]
7272+ wrapped_did = ":".join(parts[3:]) # e.g. 'web:example.com'
7373+ return controller_hex, wrapped_did
7474+7575+7676+def _controller_address(controller_hex):
7777+ """Convert bare hex controller (no 0x) to checksummed address."""
7878+ hex_str = controller_hex if controller_hex.startswith("0x") else f"0x{controller_hex}"
7979+ return Web3.to_checksum_address(hex_str)
8080+8181+8282+def _strip_did_prefix(wrapped_did):
8383+ """Accept 'did:web:example.com' or 'web:example.com', always return the latter."""
8484+ if wrapped_did.startswith("did:"):
8585+ return wrapped_did[4:]
8686+ return wrapped_did
8787+8888+8989+def _send(w3, account, tx):
9090+ tx["from"] = account.address
9191+ tx["nonce"] = w3.eth.get_transaction_count(account.address)
9292+9393+ latest = w3.eth.get_block("latest")
9494+ base_fee = latest["baseFeePerGas"]
9595+ priority_fee = w3.to_wei(1, "gwei")
9696+ tx["maxPriorityFeePerGas"] = priority_fee
9797+ tx["maxFeePerGas"] = base_fee * 2 + priority_fee
9898+9999+ tx["gas"] = w3.eth.estimate_gas(tx)
100100+101101+ signed = account.sign_transaction(tx)
102102+ txhash = w3.eth.send_raw_transaction(signed.raw_transaction)
103103+ click.echo(f"tx: {txhash.hex()}")
104104+ receipt = w3.eth.wait_for_transaction_receipt(txhash)
105105+ if receipt["status"] != 1:
106106+ raise click.ClickException("Transaction reverted")
107107+ click.echo(f"confirmed: block {receipt['blockNumber']}")
108108+ return receipt
109109+110110+111111+# ---------------------------------------------------------------------------
112112+# Commands
113113+# ---------------------------------------------------------------------------
114114+115115+@click.group()
116116+def cli():
117117+ """did:cow registry CLI\n
118118+ \b
119119+ Reads from the chain using RPC_URL.
120120+ Writes require PRIVATE_KEY (the current controller account).
121121+ See .env.example for configuration.
122122+ """
123123+124124+125125+@cli.command()
126126+@click.argument("did")
127127+def resolve(did):
128128+ """Resolve a did:cow DID to its current state.
129129+130130+ \b
131131+ DID format: did:cow:<controller_address>:<method>:<id>
132132+ Example: did:cow:8BC101ABF5BcF8b6209FaaAD4D761C1ED14999Be:plc:7qqsrnkn4moc2jgdxvh6aa3t
133133+ """
134134+ controller_hex, initial_wrapped = _parse_cow_did(did)
135135+ w3 = _w3()
136136+ contract = _contract(w3)
137137+138138+ cow_hash = contract.functions.calculateCowHash(
139139+ _controller_address(controller_hex),
140140+ initial_wrapped,
141141+ ).call()
142142+143143+ controller, wrapped_did = contract.functions.cows(cow_hash).call()
144144+145145+ if wrapped_did == "":
146146+ # No on-chain state — initial values from the DID string are authoritative
147147+ click.echo(f"status: not yet registered on-chain")
148148+ click.echo(f"wrapped: did:{initial_wrapped} (from DID)")
149149+ click.echo(f"controller: {_controller_address(controller_hex)} (initial)")
150150+ elif wrapped_did == ":":
151151+ click.echo(f"status: deactivated")
152152+ else:
153153+ click.echo(f"status: active")
154154+ click.echo(f"wrapped: did:{wrapped_did}")
155155+ click.echo(f"controller: {controller}")
156156+157157+158158+@cli.command("update-wrapped")
159159+@click.argument("did")
160160+@click.argument("new_wrapped_did")
161161+def update_wrapped(did, new_wrapped_did):
162162+ """Update the wrapped DID.
163163+164164+ \b
165165+ NEW_WRAPPED_DID: the new wrapped DID, with or without leading 'did:'
166166+ e.g. web:example.com or did:web:example.com
167167+ """
168168+ controller_hex, initial_wrapped = _parse_cow_did(did)
169169+ new_wrapped = _strip_did_prefix(new_wrapped_did)
170170+171171+ w3 = _w3()
172172+ contract = _contract(w3)
173173+ account = _account(w3)
174174+175175+ tx = contract.functions.updateWrappedDID(
176176+ _controller_address(controller_hex),
177177+ initial_wrapped,
178178+ new_wrapped,
179179+ ).build_transaction({})
180180+181181+ _send(w3, account, tx)
182182+ click.echo(f"wrapped: did:{new_wrapped}")
183183+184184+185185+@cli.command("update-controller")
186186+@click.argument("did")
187187+@click.argument("new_controller")
188188+def update_controller(did, new_controller):
189189+ """Transfer control to a new Ethereum address.
190190+191191+ \b
192192+ NEW_CONTROLLER: checksummed Ethereum address of the new controller.
193193+ Note: setting this to the zero address is permanent (cow becomes uncontrollable).
194194+ """
195195+ controller_hex, initial_wrapped = _parse_cow_did(did)
196196+197197+ w3 = _w3()
198198+ contract = _contract(w3)
199199+ account = _account(w3)
200200+201201+ tx = contract.functions.updateController(
202202+ _controller_address(controller_hex),
203203+ initial_wrapped,
204204+ Web3.to_checksum_address(new_controller),
205205+ ).build_transaction({})
206206+207207+ _send(w3, account, tx)
208208+ click.echo(f"controller: {Web3.to_checksum_address(new_controller)}")
209209+210210+211211+@cli.command()
212212+@click.argument("did")
213213+@click.confirmation_option(prompt="Deactivation is permanent and cannot be undone. Continue?")
214214+def deactivate(did):
215215+ """Permanently deactivate a did:cow."""
216216+ controller_hex, initial_wrapped = _parse_cow_did(did)
217217+218218+ w3 = _w3()
219219+ contract = _contract(w3)
220220+ account = _account(w3)
221221+222222+ cow_hash = contract.functions.calculateCowHash(
223223+ _controller_address(controller_hex),
224224+ initial_wrapped,
225225+ ).call()
226226+227227+ tx = contract.functions.deactivate(cow_hash).build_transaction({})
228228+229229+ _send(w3, account, tx)
230230+ click.echo("deactivated.")
231231+232232+233233+if __name__ == "__main__":
234234+ cli()