weed-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dht.py ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ dht.py — real Kademlia DHT discovery, no relay involved.
4
+
5
+ Uses the real `kademlia` library (pip install kademlia) rather than
6
+ reimplementing Kademlia's node-ID/k-bucket/RPC machinery — that's exactly
7
+ the kind of already-solved distributed-systems problem worth reusing, not
8
+ rebuilding, especially for a PoC.
9
+
10
+ Scoped deliberately: this answers "how do two nodes find each other
11
+ without a shared relay" — announce(content_hash, host_addr) /
12
+ lookup(content_hash) — not the richer signed-event system (publish/like/
13
+ subscribe/attestation) discovery_relay.py already handles. Merging that
14
+ into DHT value storage is a separate, bigger design question (Kademlia's
15
+ plain key→value store isn't naturally an append-only event log); kept out
16
+ of this file on purpose. Every DHT node is a full peer — there's no
17
+ separate "relay" role here, only who happened to join first.
18
+ """
19
+ import asyncio
20
+ import json
21
+ import threading
22
+
23
+ from kademlia.network import Server
24
+
25
+
26
+ async def _announce(server, content_hash, host_addr, title=None):
27
+ """Merge this announcer into the existing list under content_hash
28
+ instead of overwriting it — multiple peers can host the same content,
29
+ and a blind set() would silently drop everyone else's announcement.
30
+ Not race-free under concurrent announces to the same key (last write
31
+ still wins on a true conflict) — a real CRDT merge is out of scope."""
32
+ existing_raw = await server.get(content_hash)
33
+ entries = json.loads(existing_raw) if existing_raw else []
34
+ entries = [e for e in entries if e.get('host') != host_addr] # replace any stale self-entry
35
+ entries.append({'host': host_addr, 'title': title})
36
+ await server.set(content_hash, json.dumps(entries))
37
+
38
+
39
+ async def _lookup(server, content_hash):
40
+ raw = await server.get(content_hash)
41
+ return json.loads(raw) if raw else []
42
+
43
+
44
+ class DHTNode:
45
+ """Runs a real kademlia.network.Server in its own thread with its own
46
+ asyncio event loop, and exposes plain synchronous announce()/lookup()
47
+ methods any other thread (the shell, a CLI command) can call directly
48
+ — kademlia's Server is asyncio-only, but nothing else in this repo's
49
+ background-thread model (relay/host/web UI) is, so this is the
50
+ adapter, not a rewrite of everything else to asyncio."""
51
+
52
+ def __init__(self, port, bootstrap_nodes=None, quiet=False):
53
+ self.port = port
54
+ self.bootstrap_nodes = bootstrap_nodes or []
55
+ self.quiet = quiet
56
+ self.loop = None
57
+ self.server = None
58
+ self._ready = threading.Event()
59
+ self._error = None
60
+
61
+ def start(self):
62
+ t = threading.Thread(target=self._run, daemon=True)
63
+ t.start()
64
+ if not self._ready.wait(timeout=15):
65
+ raise RuntimeError('DHT node did not finish starting within 15s')
66
+ if self._error:
67
+ raise self._error
68
+ return t
69
+
70
+ def _run(self):
71
+ try:
72
+ self.loop = asyncio.new_event_loop()
73
+ asyncio.set_event_loop(self.loop)
74
+ self.server = Server()
75
+ self.loop.run_until_complete(self._setup())
76
+ except Exception as e:
77
+ self._error = e
78
+ self._ready.set()
79
+ return
80
+ self._ready.set()
81
+ self.loop.run_forever()
82
+
83
+ async def _setup(self):
84
+ await self.server.listen(self.port)
85
+ if self.bootstrap_nodes:
86
+ await self.server.bootstrap(self.bootstrap_nodes)
87
+ if not self.quiet:
88
+ joined = f', bootstrapped via {self.bootstrap_nodes}' if self.bootstrap_nodes else \
89
+ ' (first node in a new swarm)'
90
+ print(f'[dht:{self.port}] node listening{joined}', flush=True)
91
+
92
+ def announce(self, content_hash, host_addr, title=None, timeout=15):
93
+ fut = asyncio.run_coroutine_threadsafe(
94
+ _announce(self.server, content_hash, host_addr, title), self.loop)
95
+ return fut.result(timeout=timeout)
96
+
97
+ def lookup(self, content_hash, timeout=15):
98
+ fut = asyncio.run_coroutine_threadsafe(_lookup(self.server, content_hash), self.loop)
99
+ return fut.result(timeout=timeout)
100
+
101
+ def stop(self):
102
+ if self.loop and self.server:
103
+ self.loop.call_soon_threadsafe(self.server.stop)
104
+ self.loop.call_soon_threadsafe(self.loop.stop)
105
+
106
+
107
+ def run_dht_node(port, bootstrap_nodes=None, quiet=False):
108
+ """Blocking entry point, same contract as run_relay_server/
109
+ run_host_server — call this directly for a foreground CLI process, or
110
+ hand it to threading.Thread(target=...) to run in the background."""
111
+ node = DHTNode(port, bootstrap_nodes=bootstrap_nodes, quiet=quiet)
112
+ t = threading.Thread(target=node._run, daemon=True)
113
+ t.start()
114
+ t.join()
115
+
116
+
117
+ def _parse_bootstrap(spec):
118
+ """'host:port' or 'host:port,host:port' -> [(host, port), ...], or
119
+ None if spec is falsy."""
120
+ if not spec:
121
+ return None
122
+ out = []
123
+ for part in spec.split(','):
124
+ host, port_s = part.rsplit(':', 1)
125
+ out.append((host, int(port_s)))
126
+ return out
127
+
128
+
129
+ def main():
130
+ import sys
131
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 8468
132
+ bootstrap = _parse_bootstrap(sys.argv[2]) if len(sys.argv) > 2 else None
133
+ run_dht_node(port, bootstrap_nodes=bootstrap)
134
+
135
+
136
+ if __name__ == '__main__':
137
+ main()
discovery_relay.py ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Roadmap item 8: discovery. The design from the brainstorm was "no single
4
+ canonical index — gossiped signed events, any number of independent
5
+ replaceable relays, personalized ranking client-side," Nostr-style. This is
6
+ that, actually built and running, not just described.
7
+
8
+ A relay here is deliberately dumb: real stdlib HTTP server, verifies a
9
+ posted event's signature (cheap, uncontroversial — garbage in doesn't get
10
+ stored) but has NO opinion on content quality, no ranking, no single
11
+ "trending" list. It just stores what it's given and serves it back on
12
+ request. That's the whole point: any number of these can run independently,
13
+ none of them are load-bearing on their own, and a client is expected to
14
+ query several and merge — see poc_discovery.py for the client side.
15
+
16
+ Event shapes (all just signed JSON blobs, reusing poc_reputation.py's
17
+ Ed25519 signing):
18
+ publish {content_hash, title} — a creator announces content
19
+ like {content_hash} — a viewer signals approval
20
+ subscribe {target_pubkey} — a viewer follows a creator/signer
21
+ """
22
+ import json
23
+ import sys
24
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
25
+ from urllib.parse import urlparse, parse_qs
26
+
27
+ sys.path.insert(0, __import__('os').path.dirname(__import__('os').path.abspath(__file__)))
28
+ from poc_reputation import verify_attestation, attestation_id
29
+
30
+ _events = [] # in-memory store — a real relay would use a real DB; irrelevant to the design
31
+
32
+
33
+ class RelayHandler(BaseHTTPRequestHandler):
34
+ def log_message(self, fmt, *args):
35
+ pass # quiet — poc_discovery.py prints what matters
36
+
37
+ def do_POST(self):
38
+ if self.path != '/event':
39
+ self.send_response(404); self.end_headers(); return
40
+ length = int(self.headers.get('Content-Length', 0))
41
+ try:
42
+ event = json.loads(self.rfile.read(length))
43
+ ok, reason = verify_attestation(event) # generic: works on any {payload, signature} blob
44
+ except Exception as e:
45
+ ok, reason = False, f'malformed request: {e}'
46
+ if not ok:
47
+ self.send_response(400)
48
+ self.send_header('Content-Type', 'application/json')
49
+ self.end_headers()
50
+ self.wfile.write(json.dumps({'ok': False, 'reason': reason}).encode())
51
+ return
52
+ eid = attestation_id(event)
53
+ if not any(attestation_id(e) == eid for e in _events):
54
+ _events.append(event)
55
+ self.send_response(200)
56
+ self.send_header('Content-Type', 'application/json')
57
+ self.end_headers()
58
+ self.wfile.write(json.dumps({'ok': True, 'event_id': eid}).encode())
59
+
60
+ def do_GET(self):
61
+ if self.path.split('?')[0] != '/events':
62
+ self.send_response(404); self.end_headers(); return
63
+ qs = parse_qs(urlparse(self.path).query)
64
+ out = _events
65
+ if 'type' in qs:
66
+ out = [e for e in out if e['payload'].get('type') == qs['type'][0]]
67
+ self.send_response(200)
68
+ self.send_header('Content-Type', 'application/json')
69
+ self.end_headers()
70
+ self.wfile.write(json.dumps(out).encode())
71
+
72
+
73
+ def run_relay_server(port, quiet=False):
74
+ """Split out from main() so shell.py can run a relay in a background
75
+ thread — same pattern as node.run_host_server. quiet=True for the shell:
76
+ a background thread's print() races with cmd.Cmd's input()-driven
77
+ prompt on the same stdout with no coordination between them — readline
78
+ doesn't know to redraw the prompt when unrelated output shows up mid-
79
+ read, so the two interleave and the prompt looks like it "disappeared."
80
+ The shell already prints its own equivalent confirmation line, so this
81
+ fixes it at the source instead of patching the visual symptom."""
82
+ srv = ThreadingHTTPServer(('0.0.0.0', port), RelayHandler)
83
+ if not quiet:
84
+ print(f"[relay:{port}] up, no opinion on content, just store-and-forward", flush=True)
85
+ srv.serve_forever()
86
+
87
+
88
+ def main():
89
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 9101
90
+ run_relay_server(port)
91
+
92
+
93
+ if __name__ == '__main__':
94
+ main()
lightning_settle.py ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Real Lightning HTLC settlement, replacing poc_challenge_auction.py's mock
4
+ "settlement: mock Lightning HTLC held until delivery confirms" print.
5
+
6
+ Talks to two real LND nodes (lnd-alice, lnd-bob — see lightning/docker-compose.yml)
7
+ over a real channel on regtest via `docker exec ... lncli`. Not simulated:
8
+ real BOLT11 invoices, real onion-routed HTLCs, real preimage reveal on
9
+ settlement — same protocol code LND runs on mainnet, just against a private
10
+ regtest chain instead of waiting on public testnet sync/faucets (same
11
+ reasoning as using a real remote box over SSH for the WAN latency test
12
+ rather than a fabricated one).
13
+
14
+ Requires the compose stack in lightning/ to be up with a funded, active
15
+ channel between alice and bob (see lightning/README.md for the one-time
16
+ setup: fund alice on-chain, open channel, confirm).
17
+ """
18
+ import hashlib
19
+ import json
20
+ import subprocess
21
+
22
+ ALICE_CONTAINER = 'lightning-lnd-alice-1' # payer — stands in for the requester/viewer
23
+ BOB_CONTAINER = 'lightning-lnd-bob-1' # payee — stands in for the auction winner
24
+ LNDDIR = '/home/lnd/.lnd'
25
+
26
+
27
+ class SettlementError(RuntimeError):
28
+ pass
29
+
30
+
31
+ def _lncli(container, *args):
32
+ cmd = ['docker', 'exec', container, 'lncli', '--network=regtest', f'--lnddir={LNDDIR}', *args]
33
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
34
+ if result.returncode != 0:
35
+ raise SettlementError(f'lncli {args[0]} failed: {result.stderr.strip()}')
36
+ return json.loads(result.stdout)
37
+
38
+
39
+ def channel_active():
40
+ try:
41
+ chans = _lncli(ALICE_CONTAINER, 'listchannels')
42
+ except (SettlementError, FileNotFoundError):
43
+ return False
44
+ return any(c.get('active') for c in chans.get('channels', []))
45
+
46
+
47
+ def settle(amount_sat, memo):
48
+ """Real HTLC settlement for one auction round's winning bid.
49
+
50
+ Bob (payee/winner) creates a real BOLT11 invoice; Alice (payer/
51
+ requester) pays it over the real channel. Returns a dict with the real
52
+ payment_hash, the real revealed preimage, and independently re-verifies
53
+ sha256(preimage) == payment_hash locally rather than trusting LND's own
54
+ claim of success — same "verify, don't just trust the tool's own report"
55
+ standard the rest of this project has used throughout.
56
+ """
57
+ amount_sat = max(1, int(round(amount_sat)))
58
+ invoice = _lncli(BOB_CONTAINER, 'addinvoice', f'--amt={amount_sat}', f'--memo={memo}')
59
+ payment_request = invoice['payment_request']
60
+ expected_hash = invoice['r_hash']
61
+
62
+ payment = _lncli(ALICE_CONTAINER, 'payinvoice', '--force', '--json', payment_request)
63
+ if payment.get('status') != 'SUCCEEDED':
64
+ raise SettlementError(f'payment did not succeed: {payment.get("status")}')
65
+
66
+ preimage = payment['payment_preimage']
67
+ recomputed_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
68
+ if recomputed_hash != expected_hash:
69
+ raise SettlementError(
70
+ f'preimage does not match invoice payment_hash — '
71
+ f'got {recomputed_hash}, expected {expected_hash}')
72
+
73
+ return {
74
+ 'amount_sat': amount_sat,
75
+ 'payment_hash': expected_hash,
76
+ 'preimage': preimage,
77
+ 'fee_sat': int(payment.get('fee_sat', 0)),
78
+ 'verified_locally': True,
79
+ }
80
+
81
+
82
+ if __name__ == '__main__':
83
+ # smoke test — run directly to confirm the channel is up before wiring
84
+ # it into the auction
85
+ if not channel_active():
86
+ print('no active channel found — bring up lightning/docker-compose.yml first')
87
+ raise SystemExit(1)
88
+ result = settle(1234, 'lightning_settle.py smoke test')
89
+ print(json.dumps(result, indent=2))
90
+ print('\npreimage independently re-hashed and matched the invoice payment_hash — real HTLC, verified.')