fleetmesh-node 0.2.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.
Files changed (338) hide show
  1. fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
  2. fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
  3. fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
  4. fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
  5. fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
  6. fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
  7. node/RELEASE +1 -0
  8. node/__init__.py +35 -0
  9. node/app/__init__.py +49 -0
  10. node/app/commands.py +265 -0
  11. node/app/config.py +131 -0
  12. node/app/control.py +670 -0
  13. node/app/core.py +867 -0
  14. node/app/plugins.py +76 -0
  15. node/app/router.py +83 -0
  16. node/app/socket.py +431 -0
  17. node/app/systemd.py +36 -0
  18. node/app/views.py +204 -0
  19. node/channels/__init__.py +378 -0
  20. node/channels/bus.py +331 -0
  21. node/channels/journal.py +46 -0
  22. node/commands/__init__.py +140 -0
  23. node/commands/adapter.py +210 -0
  24. node/commands/base.py +278 -0
  25. node/commands/cluster_cmds.py +183 -0
  26. node/commands/compute_cmds.py +401 -0
  27. node/commands/core.py +474 -0
  28. node/commands/gossip_cmds.py +106 -0
  29. node/commands/nsite_cmds.py +141 -0
  30. node/commands/peering.py +402 -0
  31. node/commands/plugin_cmds.py +135 -0
  32. node/commands/sidecar_cmds.py +120 -0
  33. node/commands/sync.py +388 -0
  34. node/commands/transport_cmds.py +451 -0
  35. node/commands/video_cmds.py +99 -0
  36. node/compartment/__init__.py +124 -0
  37. node/compartment/base.py +868 -0
  38. node/compartment/command.py +458 -0
  39. node/compartment/context.py +163 -0
  40. node/compartment/every.py +108 -0
  41. node/compartment/handler.py +131 -0
  42. node/compartment/lifecycle.py +460 -0
  43. node/compartment/lint.py +388 -0
  44. node/compartment/need.py +158 -0
  45. node/compartment/problem.py +521 -0
  46. node/compartment/refusals.py +120 -0
  47. node/compartment/registry.py +790 -0
  48. node/compartment/schema.py +290 -0
  49. node/compartment/sidecar.py +499 -0
  50. node/compartment/stores.py +662 -0
  51. node/compartment/wire.py +225 -0
  52. node/compartment/worker.py +61 -0
  53. node/config/__init__.py +43 -0
  54. node/config/base.py +116 -0
  55. node/config/change.py +441 -0
  56. node/config/changestore.py +201 -0
  57. node/config/compartments.py +1144 -0
  58. node/config/node.py +318 -0
  59. node/config/parameter.py +563 -0
  60. node/config/protocols.py +274 -0
  61. node/config/records.py +171 -0
  62. node/examples/__init__.py +1 -0
  63. node/examples/demo.py +226 -0
  64. node/examples/follower.py +46 -0
  65. node/interfaces.py +235 -0
  66. node/keystore/__init__.py +27 -0
  67. node/keystore/core.py +103 -0
  68. node/keystore/enclave.py +688 -0
  69. node/meshnode.py +111 -0
  70. node/metrics/__init__.py +396 -0
  71. node/metrics/adapters.py +102 -0
  72. node/metrics/face.py +110 -0
  73. node/planes/__init__.py +21 -0
  74. node/planes/identity/__init__.py +20 -0
  75. node/planes/identity/announce/__init__.py +9 -0
  76. node/planes/identity/announce/compartment.py +447 -0
  77. node/planes/identity/announce/types.py +26 -0
  78. node/planes/identity/discovery/__init__.py +9 -0
  79. node/planes/identity/discovery/compartment.py +522 -0
  80. node/planes/identity/discovery/types.py +13 -0
  81. node/planes/identity/linked/__init__.py +1 -0
  82. node/planes/identity/linked/compartment.py +270 -0
  83. node/planes/identity/linked/types.py +25 -0
  84. node/planes/identity/presence/__init__.py +7 -0
  85. node/planes/identity/presence/compartment.py +409 -0
  86. node/planes/identity/presence/types.py +14 -0
  87. node/planes/operations/__init__.py +13 -0
  88. node/planes/operations/scheduler/__init__.py +9 -0
  89. node/planes/operations/scheduler/compartment.py +353 -0
  90. node/planes/operations/scheduler/types.py +21 -0
  91. node/planes/operations/updates/__init__.py +17 -0
  92. node/planes/operations/updates/core.py +310 -0
  93. node/planes/operations/updates/store.py +79 -0
  94. node/planes/operations/updates/types.py +20 -0
  95. node/planes/peering/__init__.py +28 -0
  96. node/planes/peering/budget/__init__.py +43 -0
  97. node/planes/peering/budget/chain.py +43 -0
  98. node/planes/peering/budget/core.py +537 -0
  99. node/planes/peering/budget/evaluation.py +80 -0
  100. node/planes/peering/budget/meter.py +100 -0
  101. node/planes/peering/budget/receipts.py +439 -0
  102. node/planes/peering/budget/shape.py +101 -0
  103. node/planes/peering/budget/tick.py +110 -0
  104. node/planes/peering/budget/types.py +67 -0
  105. node/planes/peering/budget/windows.py +346 -0
  106. node/planes/peering/capacity/__init__.py +7 -0
  107. node/planes/peering/capacity/compartment.py +342 -0
  108. node/planes/peering/capacity/types.py +24 -0
  109. node/planes/peering/peering/__init__.py +25 -0
  110. node/planes/peering/peering/core.py +464 -0
  111. node/planes/peering/peering/envelope.py +142 -0
  112. node/planes/peering/peering/handshake.py +198 -0
  113. node/planes/peering/peering/shape.py +80 -0
  114. node/planes/peering/peering/types.py +39 -0
  115. node/planes/peering/reporter/__init__.py +42 -0
  116. node/planes/peering/reporter/constants.py +29 -0
  117. node/planes/peering/reporter/core.py +299 -0
  118. node/planes/peering/reporter/finding.py +74 -0
  119. node/planes/peering/reporter/findings.py +171 -0
  120. node/planes/peering/reporter/publish.py +285 -0
  121. node/planes/peering/reporter/reading.py +193 -0
  122. node/planes/peering/reporter/shape.py +86 -0
  123. node/planes/peering/reporter/types.py +50 -0
  124. node/planes/peering/strain/__init__.py +9 -0
  125. node/planes/peering/strain/compartment.py +204 -0
  126. node/planes/peering/strain/types.py +21 -0
  127. node/planes/peering/trust/__init__.py +9 -0
  128. node/planes/peering/trust/compartment.py +239 -0
  129. node/planes/peering/trust/types.py +30 -0
  130. node/planes/services/__init__.py +33 -0
  131. node/planes/services/compute/__init__.py +160 -0
  132. node/planes/services/compute/common.py +134 -0
  133. node/planes/services/compute/compartment.py +681 -0
  134. node/planes/services/compute/executors/__init__.py +121 -0
  135. node/planes/services/compute/executors/agent.py +840 -0
  136. node/planes/services/compute/executors/bacalhau.py +605 -0
  137. node/planes/services/compute/executors/base.py +127 -0
  138. node/planes/services/compute/executors/harness.py +439 -0
  139. node/planes/services/compute/executors/mcp.py +454 -0
  140. node/planes/services/compute/executors/media.py +898 -0
  141. node/planes/services/compute/executors/nondet.py +283 -0
  142. node/planes/services/compute/executors/providers.py +499 -0
  143. node/planes/services/compute/executors/registry.py +140 -0
  144. node/planes/services/compute/executors/wasm.py +244 -0
  145. node/planes/services/compute/executors/zk.py +604 -0
  146. node/planes/services/compute/jobs.py +227 -0
  147. node/planes/services/compute/load.py +180 -0
  148. node/planes/services/compute/market.py +433 -0
  149. node/planes/services/compute/sandbox.py +282 -0
  150. node/planes/services/compute/types.py +84 -0
  151. node/planes/services/compute_wire/__init__.py +58 -0
  152. node/planes/services/compute_wire/compartment.py +256 -0
  153. node/planes/services/compute_wire/messages.py +320 -0
  154. node/planes/services/compute_wire/sealing.py +147 -0
  155. node/planes/services/compute_wire/session.py +572 -0
  156. node/planes/services/compute_wire/types.py +49 -0
  157. node/planes/services/compute_wire/verify.py +331 -0
  158. node/planes/services/dvm/__init__.py +24 -0
  159. node/planes/services/dvm/gateway.py +526 -0
  160. node/planes/services/dvm/protocol.py +35 -0
  161. node/planes/services/dvm/translate.py +15 -0
  162. node/planes/services/dvm/types.py +25 -0
  163. node/planes/services/gateway/__init__.py +40 -0
  164. node/planes/services/gateway/settle.py +279 -0
  165. node/planes/services/gateway/translate.py +644 -0
  166. node/planes/services/gossip/__init__.py +22 -0
  167. node/planes/services/gossip/core.py +929 -0
  168. node/planes/services/gossip/envelope.py +154 -0
  169. node/planes/services/gossip/mesh.py +127 -0
  170. node/planes/services/gossip/types.py +50 -0
  171. node/planes/services/nsite/__init__.py +7 -0
  172. node/planes/services/nsite/client.py +62 -0
  173. node/planes/services/nsite/compartment.py +752 -0
  174. node/planes/services/nsite/types.py +44 -0
  175. node/planes/services/paidfetch/__init__.py +7 -0
  176. node/planes/services/paidfetch/compartment.py +862 -0
  177. node/planes/services/paidfetch/types.py +99 -0
  178. node/planes/services/settle/__init__.py +27 -0
  179. node/planes/services/settle/compartment.py +384 -0
  180. node/planes/services/settle/engine.py +456 -0
  181. node/planes/services/settle/gateways.py +541 -0
  182. node/planes/services/settle/payment.py +512 -0
  183. node/planes/services/settle/store.py +74 -0
  184. node/planes/services/settle/types.py +58 -0
  185. node/planes/services/sidecars/__init__.py +8 -0
  186. node/planes/services/sidecars/compartment.py +461 -0
  187. node/planes/services/sidecars/manifest.py +304 -0
  188. node/planes/services/sidecars/manifests/10-grasp.json +37 -0
  189. node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
  190. node/planes/services/sidecars/manifests/30-magpie.json +28 -0
  191. node/planes/services/sidecars/types.py +43 -0
  192. node/planes/services/video/__init__.py +106 -0
  193. node/planes/services/video/barter.py +407 -0
  194. node/planes/services/video/cli.py +158 -0
  195. node/planes/services/video/compartment.py +932 -0
  196. node/planes/services/video/ingest.py +524 -0
  197. node/planes/services/video/ledger.py +705 -0
  198. node/planes/services/video/packager.py +847 -0
  199. node/planes/services/video/player.py +456 -0
  200. node/planes/services/video/timed_segments.py +124 -0
  201. node/planes/services/video/transit.py +478 -0
  202. node/planes/store/__init__.py +24 -0
  203. node/planes/store/blob_replication/__init__.py +7 -0
  204. node/planes/store/blob_replication/compartment.py +496 -0
  205. node/planes/store/blob_replication/types.py +35 -0
  206. node/planes/store/blob_wire/__init__.py +48 -0
  207. node/planes/store/blob_wire/client.py +57 -0
  208. node/planes/store/blob_wire/core.py +359 -0
  209. node/planes/store/blob_wire/intake.py +74 -0
  210. node/planes/store/blob_wire/messages.py +85 -0
  211. node/planes/store/projected/__init__.py +7 -0
  212. node/planes/store/projected/compartment.py +258 -0
  213. node/planes/store/projected/types.py +29 -0
  214. node/planes/store/retention/__init__.py +7 -0
  215. node/planes/store/retention/compartment.py +214 -0
  216. node/planes/store/retention/types.py +12 -0
  217. node/planes/store/sync/__init__.py +35 -0
  218. node/planes/store/sync/core.py +311 -0
  219. node/planes/store/sync/envelope.py +106 -0
  220. node/planes/store/sync/handover.py +206 -0
  221. node/planes/store/sync/initiator.py +128 -0
  222. node/planes/store/sync/responder.py +114 -0
  223. node/planes/store/sync/rules.py +179 -0
  224. node/planes/store/sync/shape.py +80 -0
  225. node/planes/store/sync/types.py +51 -0
  226. node/planes/transport/__init__.py +23 -0
  227. node/planes/transport/bridge/__init__.py +13 -0
  228. node/planes/transport/bridge/compartment.py +785 -0
  229. node/planes/transport/bridge/ratelimit.py +44 -0
  230. node/planes/transport/bridge/types.py +43 -0
  231. node/planes/transport/lan/__init__.py +9 -0
  232. node/planes/transport/lan/compartment.py +446 -0
  233. node/planes/transport/lan/types.py +25 -0
  234. node/planes/transport/link/__init__.py +7 -0
  235. node/planes/transport/link/compartment.py +257 -0
  236. node/planes/transport/link/types.py +27 -0
  237. node/planes/transport/mediation/__init__.py +45 -0
  238. node/planes/transport/mediation/compartment.py +238 -0
  239. node/planes/transport/mediation/leaf.py +107 -0
  240. node/planes/transport/mediation/messages.py +302 -0
  241. node/planes/transport/mediation/service.py +233 -0
  242. node/planes/transport/mediation/store.py +37 -0
  243. node/planes/transport/mediation/types.py +20 -0
  244. node/planes/transport/p2p/__init__.py +7 -0
  245. node/planes/transport/p2p/compartment.py +349 -0
  246. node/planes/transport/p2p/types.py +22 -0
  247. node/protocols/__init__.py +277 -0
  248. node/protocols/activitypub/__init__.py +56 -0
  249. node/protocols/activitypub/actor.py +230 -0
  250. node/protocols/activitypub/federation.py +490 -0
  251. node/protocols/activitypub/media.py +145 -0
  252. node/protocols/activitypub/models.py +286 -0
  253. node/protocols/activitypub/package.py +271 -0
  254. node/protocols/activitypub/store.py +564 -0
  255. node/protocols/arcade/__init__.py +26 -0
  256. node/protocols/atproto/__init__.py +13 -0
  257. node/protocols/atproto/firehose.py +126 -0
  258. node/protocols/atproto/metering.py +165 -0
  259. node/protocols/atproto/package.py +155 -0
  260. node/protocols/atproto/repo.py +250 -0
  261. node/protocols/atproto/xrpc.py +285 -0
  262. node/protocols/base.py +109 -0
  263. node/protocols/circles/__init__.py +29 -0
  264. node/protocols/dm/__init__.py +50 -0
  265. node/protocols/grasp/__init__.py +22 -0
  266. node/protocols/isotime.py +15 -0
  267. node/protocols/nostr/__init__.py +51 -0
  268. node/protocols/nostr/blobstore.py +383 -0
  269. node/protocols/nostr/blossom.py +627 -0
  270. node/protocols/nostr/client.py +377 -0
  271. node/protocols/nostr/descriptor.py +211 -0
  272. node/protocols/nostr/eventstore.py +304 -0
  273. node/protocols/nostr/package.py +182 -0
  274. node/protocols/nostr/peers.py +109 -0
  275. node/protocols/nostr/relayserver.py +656 -0
  276. node/protocols/p2panda/__init__.py +22 -0
  277. node/protocols/p2panda/identity.py +19 -0
  278. node/protocols/p2panda/package.py +236 -0
  279. node/protocols/p2panda/rung.py +152 -0
  280. node/protocols/p2panda/store.py +273 -0
  281. node/protocols/rhio/__init__.py +33 -0
  282. node/protocols/rhio/package.py +275 -0
  283. node/protocols/rhio/rung.py +136 -0
  284. node/protocols/rhio/store.py +1423 -0
  285. node/protocols/ssb/muxrpc.py +256 -0
  286. node/protocols/ssb/package.py +267 -0
  287. node/protocols/textile/__init__.py +111 -0
  288. node/protocols/textile/transducers.py +259 -0
  289. node/release.py +119 -0
  290. node/storage/__init__.py +88 -0
  291. node/storage/accounts.py +305 -0
  292. node/storage/adapter.py +161 -0
  293. node/storage/base.py +385 -0
  294. node/storage/dialect.py +209 -0
  295. node/storage/grants.py +317 -0
  296. node/storage/locator.py +150 -0
  297. node/storage/postgres.py +294 -0
  298. node/storage/projected.py +953 -0
  299. node/storage/sharding.py +448 -0
  300. node/storage/standing.py +568 -0
  301. node/testnet/__init__.py +9 -0
  302. node/testnet/cluster.py +731 -0
  303. node/testnet/fips_daemon.py +230 -0
  304. node/transport/__init__.py +94 -0
  305. node/transport/const.py +110 -0
  306. node/transport/didcomm_inbound.py +251 -0
  307. node/transport/dnstxt.py +169 -0
  308. node/transport/fips_rung/__init__.py +67 -0
  309. node/transport/fips_rung/core.py +666 -0
  310. node/transport/fips_rung/listener.py +96 -0
  311. node/transport/fips_rung/types.py +12 -0
  312. node/transport/host.py +334 -0
  313. node/transport/iroh_rung/__init__.py +29 -0
  314. node/transport/iroh_rung/core.py +477 -0
  315. node/transport/iroh_rung/quic.py +942 -0
  316. node/transport/iroh_rung/types.py +22 -0
  317. node/transport/ladder.py +145 -0
  318. node/transport/lineproto.py +49 -0
  319. node/transport/mdns.py +303 -0
  320. node/transport/netfetch.py +283 -0
  321. node/transport/relay_rung/__init__.py +23 -0
  322. node/transport/relay_rung/cooldown.py +87 -0
  323. node/transport/relay_rung/core.py +138 -0
  324. node/transport/relay_rung/inbound.py +161 -0
  325. node/transport/relay_rung/ledger.py +104 -0
  326. node/transport/relay_rung/listen.py +121 -0
  327. node/transport/relay_rung/outbound.py +181 -0
  328. node/transport/relay_rung/shape.py +71 -0
  329. node/transport/relay_rung/types.py +58 -0
  330. node/transport/relay_rung/wire.py +63 -0
  331. node/transport/router.py +761 -0
  332. node/transport/rung.py +276 -0
  333. node/transport/ssb_rung.py +166 -0
  334. node/transport/ws.py +502 -0
  335. sidecar/fleetmesh_announce.py +338 -0
  336. sidecar/fleetmesh_p2p.py +373 -0
  337. sidecar/fleetmesh_rhio.py +352 -0
  338. sidecar/fleetmesh_ssb.py +603 -0
@@ -0,0 +1,862 @@
1
+ """Paid fetch: what a blob costs, who pays nothing, and how a Cashu payment
2
+ is turned into finality before a byte is served.
3
+
4
+ Three pieces, each replaceable on its own:
5
+
6
+ * `Pricing` (below) answers "what does this fetch cost?" from operator
7
+ config, per-item prices in SQLite and the free allowance. It knows
8
+ nothing about money moving.
9
+ * `MintBackend` is the trust and settlement seam. `AllowlistBackend` is
10
+ the one shipped: a list of mints the operator chose, tokens accepted
11
+ only from those, finality by NUT-03 swap at that mint, earnings kept as
12
+ fresh proofs in SQLite and swept to Lightning by NUT-05 melt above a
13
+ threshold. A self-hosted nutshell, cdk-mintd or a Fedimint gateway is
14
+ another `MintBackend`; the 402 and the verify path do not change.
15
+ * `PaidFetch` is the subsystem: it owns both, builds the 402, checks a
16
+ payment and records every refusal with its reason.
17
+
18
+ Every amount is msat inside the node. Cashu quotes in sat, so the edge
19
+ rounds up: a 1 msat price costs 1 sat. That is the price of not carrying
20
+ fractions of a sat as debt.
21
+
22
+ Pricing rules (plan §3.4), first match wins:
23
+ 1. fetcher is a partner or grantee and free_for_partners → 0
24
+ 2. per-item operator price on this blob
25
+ 3. first matching rule: min_bytes / content_type glob / uploader
26
+ 4. default_msat (default 0)
27
+ A free allowance free_bytes_per_day, keyed by the fetcher's key alone
28
+ (anonymous fetches share one pool), is spent before any of these. Unset
29
+ (None) means no free tier.
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import fnmatch
34
+ import json
35
+ import math
36
+ import os
37
+ import threading
38
+ import time
39
+ from typing import Any, Callable, Mapping, MutableMapping, Optional, Sequence, cast
40
+
41
+ from fleetmesh_core.accounting import cashu, paidfetch
42
+ from fleetmesh_core.accounting.cashu import CashuProof
43
+ from node import compartment as compartment_mod, storage
44
+ from node.compartment import stores as st
45
+ from node.channels import Channel, INFO, NOTE
46
+ from node.metrics import COUNTER, Metric
47
+ from node.compartment import Column, Command, Index, Need, Table
48
+ from node.config import FetchPricingConfig
49
+ from node.config.parameter import Parameter
50
+
51
+
52
+ from .types import (
53
+ BalanceReport,
54
+ MintBackend,
55
+ Paid,
56
+ PaidFetchOffer,
57
+ PriceEntry,
58
+ PriceGetResult,
59
+ PriceSetResult,
60
+ RailDescriptor,
61
+ StoredProof,
62
+ SweepReport,
63
+ Unpaid,
64
+ )
65
+
66
+
67
+ DEFAULTS: Mapping[str, Any] = {
68
+ 'default_msat': 0,
69
+ 'free_for_partners': True,
70
+ 'free_bytes_per_day': None, # None: no free tier
71
+ 'max_account_fee_msat': 0,
72
+ 'rules': [], # [{'min_bytes'|'content_type'|'uploader': ..., 'msat'|'msat_per_mb': int}]
73
+ 'rails': {'cashu': {'mints': [], 'unit': 'sat',
74
+ 'sweep_above_sat': 0, # 0: never sweep automatically
75
+ 'sweep_to': None}}, # a Lightning address or bolt11 source
76
+ 'offer_seconds': 600,
77
+ # GRASP (git smart-http, kinds 30617/30618/1617-1633) cannot be metered per
78
+ # object by a proxy the way a Blossom GET is: one 402 in front of the
79
+ # whole clone/fetch session is what is actually enforceable, so it has its
80
+ # own fixed price rather than reusing the per-blob `rules`/`default_msat`.
81
+ # ncontainer needs no entry here: it resolves a tag to a sha256 digest and
82
+ # every blob it serves is already priced (or free) exactly as any other
83
+ # Blossom fetch, through the same `rules`/`default_msat`/per-item path.
84
+ 'grasp_session_msat': 0,
85
+ # Any other sidecar whose manifest says pricing.mode == session, by name
86
+ # (grasp may be given here too; grasp_session_msat is the older spelling
87
+ # and wins when both are set).
88
+ 'sidecar_session_msat': {},
89
+ }
90
+
91
+ ANON = '' # the pooled anonymous fetcher
92
+
93
+
94
+ # The 402 shape - Quote, Item, msat_to_sat_ceil - is fleetmesh_core/paidfetch.py's,
95
+ # pinned by vectors/paid-fetch.json.
96
+ msat_to_sat_ceil = paidfetch.msat_to_sat_ceil
97
+ Item = paidfetch.Item
98
+ Quote = paidfetch.Quote
99
+
100
+
101
+
102
+
103
+ class ProofStore(storage.SqliteStore):
104
+ """The node's own Cashu proofs (its earnings) and every proof it has taken
105
+ in payment, by Y, so a token replayed after the swap is refused without a
106
+ round trip to the mint."""
107
+
108
+ SCHEMA = (
109
+ Table('cashu_proofs', (
110
+ Column('y', 'TEXT', primary_key=True),
111
+ Column('mint', 'TEXT', not_null=True),
112
+ Column('unit', 'TEXT', not_null=True),
113
+ Column('keyset', 'TEXT', not_null=True),
114
+ Column('amount', 'INTEGER', not_null=True),
115
+ Column('secret', 'TEXT', not_null=True),
116
+ Column('c', 'TEXT', not_null=True),
117
+ Column('state', 'TEXT', not_null=True),
118
+ Column('received_at', 'INTEGER', not_null=True),
119
+ Column('spent_at', 'INTEGER'),
120
+ ), indices=(
121
+ Index('idx_cashu_proofs_state', ('mint', 'state')),
122
+ )),
123
+ Table('cashu_taken', (
124
+ Column('y', 'TEXT', primary_key=True),
125
+ Column('mint', 'TEXT', not_null=True),
126
+ Column('sha256', 'TEXT'),
127
+ Column('taken_at', 'INTEGER', not_null=True),
128
+ )),
129
+ Table('account_proofs', (
130
+ Column('y', 'TEXT', primary_key=True),
131
+ Column('account', 'TEXT', not_null=True),
132
+ Column('mint', 'TEXT', not_null=True),
133
+ Column('proof_json', 'TEXT', not_null=True),
134
+ Column('at', 'INTEGER', not_null=True),
135
+ ), indices=(
136
+ Index('idx_account_proofs_account', ('account',)),
137
+ )),
138
+ )
139
+
140
+ def seen(self, ys: Sequence[str]) -> set[str]:
141
+ if not ys:
142
+ return set()
143
+ q = 'SELECT y FROM cashu_taken WHERE y IN (%s)' % ','.join('?' * len(ys))
144
+ return {r[0] for r in self._all(q, list(ys))}
145
+
146
+ def take(self, ys: Sequence[str], mint: str, sha256: Optional[str], now: int) -> None:
147
+ with self.transaction() as cur:
148
+ cur.executemany('INSERT OR IGNORE INTO cashu_taken(y, mint, sha256, taken_at) VALUES (?,?,?,?)',
149
+ [(y, mint, sha256, now) for y in ys])
150
+
151
+ def add_proofs(self, mint: str, unit: str, proofs: Sequence[CashuProof], now: int) -> None:
152
+ with self.transaction() as cur:
153
+ cur.executemany(
154
+ 'INSERT OR REPLACE INTO cashu_proofs(y, mint, unit, keyset, amount, secret, c, state, received_at) '
155
+ 'VALUES (?,?,?,?,?,?,?,?,?)',
156
+ [(cashu.point_to_hex(cashu.hash_to_curve(p['secret'])), mint, unit, p['id'], int(p['amount']),
157
+ p['secret'], p['C'], 'unspent', now) for p in proofs])
158
+
159
+ def unspent(self, mint: Optional[str] = None) -> list[StoredProof]:
160
+ q = 'SELECT y, mint, unit, keyset, amount, secret, c FROM cashu_proofs WHERE state=\'unspent\''
161
+ args: list[Any] = []
162
+ if mint:
163
+ q += ' AND mint=?'
164
+ args.append(mint)
165
+ return [{'y': r[0], 'mint': r[1], 'unit': r[2], 'id': r[3], 'amount': r[4], 'secret': r[5], 'C': r[6]}
166
+ for r in self._all(q + ' ORDER BY received_at', args)]
167
+
168
+ def mark_spent(self, ys: Sequence[str], now: int) -> None:
169
+ with self.transaction() as cur:
170
+ cur.executemany('UPDATE cashu_proofs SET state=\'spent\', spent_at=? WHERE y=?', [(now, y) for y in ys])
171
+
172
+ def balances(self) -> dict[str, int]:
173
+ return {r[0]: int(r[1]) for r in
174
+ self._all('SELECT mint, COALESCE(SUM(amount),0) FROM cashu_proofs WHERE state=\'unspent\' GROUP BY mint')}
175
+
176
+ # -- proofs paid direct to an account (P2PK-locked; this node cannot spend them) --
177
+ def add_account_proofs(self, account: str, mint: str, proofs: Sequence[CashuProof], now: int) -> None:
178
+ """Keep the account's locked proofs so it can collect them later; the
179
+ node is only the custodian of the ciphertext, never of the value."""
180
+ with self.transaction() as cur:
181
+ cur.executemany(
182
+ 'INSERT OR IGNORE INTO account_proofs(y, account, mint, proof_json, at) VALUES (?,?,?,?,?)',
183
+ [(cashu.point_to_hex(cashu.hash_to_curve(p['secret'])), account.lower(), mint,
184
+ json.dumps(dict(p), sort_keys=True, separators=(',', ':')), now) for p in proofs])
185
+
186
+ def account_proofs(self, account: str) -> list[StoredProof]:
187
+ return [cast(StoredProof, dict(json.loads(r[0]), mint=r[1])) for r in
188
+ self._all('SELECT proof_json, mint FROM account_proofs WHERE account=? ORDER BY at', (account.lower(),))]
189
+
190
+ def account_proof_count(self) -> int:
191
+ r = self._one('SELECT COUNT(*) FROM account_proofs')
192
+ return int(r[0]) if r else 0
193
+
194
+
195
+ def powers_of_two(total: int) -> list[int]:
196
+ if total < 0:
197
+ raise ValueError('negative amount: %d' % total)
198
+ out, bit = [], 1
199
+ while total:
200
+ if total & bit:
201
+ out.append(bit)
202
+ total &= ~bit
203
+ bit <<= 1
204
+ return out
205
+
206
+
207
+ class AllowlistBackend:
208
+ """Plan §3.5 option (a): mints the operator chose, and nobody else's IOUs.
209
+
210
+ verify() is the security boundary of the whole feature. It accepts a
211
+ token only when: the mint is on the list, the unit matches, the amount
212
+ covers the item, no proof was taken before, and the mint swaps the
213
+ proofs for fresh ones this node blinded. After the swap the payer's
214
+ proofs are dead at the mint and the node's are live: that is finality.
215
+ A mint that is down is an Unpaid('mint unreachable'), never a pass.
216
+ """
217
+
218
+ rail = 'cashu'
219
+
220
+ def __init__(self, rail_cfg: Callable[[], Mapping[str, Any]], store: ProofStore, node_pk: Optional[str] = None,
221
+ client_factory: Optional[Callable[[str], Any]] = None,
222
+ clock: Optional[Callable[[], int]] = None) -> None:
223
+ # rail_cfg is read on every use: fetch_pricing is a live key, so an
224
+ # operator can add a mint without restarting the node.
225
+ self._rail_cfg = rail_cfg
226
+ self.store = store
227
+ self.node_pk = node_pk
228
+ self.clock = clock or (lambda: int(time.time()))
229
+ if client_factory is None:
230
+ from node.planes.services.settle.gateways import CashuHttpClient
231
+ client_factory = lambda url: CashuHttpClient(mint_url=url) # noqa: E731
232
+ self._factory = client_factory
233
+ self._clients: MutableMapping[str, Any] = {}
234
+ self._keysets: dict[str, tuple[int, Mapping[str, Any]]] = {} # mint -> (fetched_at, keyset)
235
+ self._lock = threading.Lock()
236
+
237
+ # -- wiring -------------------------------------------------------------
238
+ @property
239
+ def mints(self) -> list[str]:
240
+ raw = self._rail_cfg().get('mints') or []
241
+ return [cashu.normalize_mint_url(m) for m in raw if isinstance(m, str) and m.strip()]
242
+
243
+ @property
244
+ def unit(self) -> str:
245
+ return str(self._rail_cfg().get('unit') or 'sat')
246
+
247
+ @property
248
+ def sweep_above_sat(self) -> int:
249
+ return int(self._rail_cfg().get('sweep_above_sat') or 0)
250
+
251
+ @property
252
+ def sweep_to(self) -> Optional[str]:
253
+ v = self._rail_cfg().get('sweep_to')
254
+ return str(v) if v else None
255
+
256
+ def client(self, mint: str) -> Any:
257
+ with self._lock:
258
+ c = self._clients.get(mint)
259
+ if c is None:
260
+ c = self._clients[mint] = self._factory(mint)
261
+ return c
262
+
263
+ def active_keyset(self, mint: str) -> Mapping[str, Any]:
264
+ now = self.clock()
265
+ with self._lock:
266
+ cached = self._keysets.get(mint)
267
+ if cached and now - cached[0] < 3600:
268
+ return cached[1]
269
+ keysets = self.client(mint).get_keys().get('keysets', [])
270
+ ks = [k for k in keysets if k.get('unit') == self.unit and k.get('keys')]
271
+ if not ks:
272
+ raise ValueError('mint %s has no %s keyset' % (mint, self.unit))
273
+ with self._lock:
274
+ self._keysets[mint] = (now, ks[0])
275
+ return ks[0]
276
+
277
+ # -- MintBackend --------------------------------------------------------
278
+ def offer(self, item: Item, quote_id: str, expires: int) -> Optional[PaidFetchOffer]:
279
+ if not self.mints or item.msat <= 0:
280
+ return None
281
+ lock = cashu.x_only_to_compressed(item.payee) if item.to == 'account' else None
282
+ req = cashu.encode_payment_request(msat_to_sat_ceil(item.msat), self.unit, self.mints,
283
+ payment_id=quote_id, description='fleetmesh fetch', p2pk_pubkey=lock)
284
+ return {'rail': self.rail, 'request': req}
285
+
286
+ def verify(self, item: Item, token: str) -> Paid | Unpaid:
287
+ try:
288
+ tok = cashu.decode_token_v4(token)
289
+ except ValueError as e:
290
+ return Unpaid('bad token', str(e))
291
+ if tok['mint'] not in self.mints:
292
+ return Unpaid('mint not accepted', tok['mint'])
293
+ if tok['unit'] != self.unit:
294
+ return Unpaid('wrong unit', tok['unit'])
295
+ need = msat_to_sat_ceil(item.msat)
296
+ got = cashu.token_amount(tok)
297
+ if got < need:
298
+ return Unpaid('short payment', '%d < %d %s' % (got, need, self.unit))
299
+ proofs = tok['proofs']
300
+ ys = [cashu.point_to_hex(cashu.hash_to_curve(p['secret'])) for p in proofs]
301
+ if item.to == 'account':
302
+ # Account fees are paid direct: the proofs must be locked to the
303
+ # account key, so this node could not spend them even if it
304
+ # wanted to. Nothing to swap; the account collects. There is no
305
+ # mint round trip to kill a replay, so the Y record is the only
306
+ # thing standing between one token and unlimited fetches.
307
+ want = item.payee.lower()
308
+ if any(cashu.p2pk_locked_to(p['secret']) != want for p in proofs):
309
+ return Unpaid('not locked to account')
310
+ if self.store.seen(ys):
311
+ return Unpaid('proof replayed')
312
+ now = self.clock()
313
+ self.store.take(ys, tok['mint'], None, now)
314
+ self.store.add_account_proofs(want, tok['mint'], proofs, now)
315
+ return Paid(self.rail, got * 1000, {'mint': tok['mint'], 'locked_to': want, 'proofs': proofs})
316
+ if any(cashu.p2pk_locked_to(p['secret']) for p in proofs):
317
+ return Unpaid('locked proofs offered to operator')
318
+ if self.store.seen(ys):
319
+ return Unpaid('proof replayed')
320
+ try:
321
+ ks = self.active_keyset(tok['mint'])
322
+ # NUT-02 input fees: the mint keeps ceil(n * fee_ppk / 1000) sat of
323
+ # the inputs, so the outputs must ask for that much less or the
324
+ # swap is refused. The fetcher paid the price; the fee is the
325
+ # operator's cost of using that mint.
326
+ fee = math.ceil(len(proofs) * int(ks.get('input_fee_ppk') or 0) / 1000)
327
+ except Exception as e:
328
+ return Unpaid('swap refused', str(e)[:200])
329
+ if fee >= got:
330
+ return Unpaid('fee exceeds payment', '%d sat over %d input(s) costs %d sat at %s' % (got, len(proofs), fee, tok['mint']))
331
+ try:
332
+ outs, blinds = [], []
333
+ for amt in powers_of_two(got - fee):
334
+ secret = os.urandom(32).hex()
335
+ b_prime, r = cashu.blind_message(secret)
336
+ outs.append({'amount': amt, 'id': ks['id'], 'B_': b_prime})
337
+ blinds.append((secret, r, amt))
338
+ swapped = self.client(tok['mint']).swap_tokens(proofs, outs)
339
+ except Exception as e: # CashuMintError, ValueError, socket errors: all "no finality"
340
+ return Unpaid('swap refused', str(e)[:200])
341
+ sigs = swapped.get('signatures') or []
342
+ if len(sigs) != len(outs):
343
+ return Unpaid('swap refused', 'mint returned %d signatures for %d outputs' % (len(sigs), len(outs)))
344
+ now = self.clock()
345
+ fresh: list[CashuProof] = []
346
+ for sig, (secret, r, amt) in zip(sigs, blinds):
347
+ c_hex = cashu.unblind_signature(sig['C_'], r, ks['keys'][str(amt)])
348
+ fresh.append({'amount': amt, 'id': ks['id'], 'secret': secret, 'C': c_hex})
349
+ self.store.take(ys, tok['mint'], None, now)
350
+ self.store.add_proofs(tok['mint'], self.unit, fresh, now)
351
+ # amount_msat is what the node keeps: the mint's fee is gone.
352
+ return Paid(self.rail, (got - fee) * 1000, {'mint': tok['mint'], 'swapped': len(fresh), 'fee_sat': fee})
353
+
354
+ def balance(self) -> BalanceReport:
355
+ return {'unit': self.unit, 'by_mint': self.store.balances(),
356
+ 'total': sum(self.store.balances().values())}
357
+
358
+ def sweep(self, now: int, force: bool = False) -> Optional[SweepReport]:
359
+ """NUT-05 melt of everything unspent at a mint into a Lightning
360
+ payment to sweep_to. Needs a bolt11: sweep_to may be an invoice or a
361
+ callable/URL that produces one, which is the next piece to build; for
362
+ now a sweep with no bolt11 source reports that and does nothing."""
363
+ total = sum(self.store.balances().values())
364
+ if not force and (self.sweep_above_sat <= 0 or total < self.sweep_above_sat):
365
+ return None
366
+ if not self.sweep_to:
367
+ return {'swept': 0, 'why': 'no sweep_to configured', 'balance': total}
368
+ return {'swept': 0, 'why': 'lightning melt not wired yet', 'balance': total}
369
+
370
+ def describe(self) -> RailDescriptor:
371
+ return {'rail': self.rail, 'mints': list(self.mints), 'unit': self.unit,
372
+ 'sweep_above_sat': self.sweep_above_sat, 'trust': 'allowlist'}
373
+
374
+
375
+ # ---------------------------------------------------------------------------
376
+ # Pricing
377
+ # ---------------------------------------------------------------------------
378
+
379
+ class PriceStore(storage.SqliteStore):
380
+ """blob_prices(sha256, account, msat, set_by, at) and the daily allowance counters."""
381
+
382
+ SCHEMA = (
383
+ Table('blob_prices', (
384
+ Column('sha256', 'TEXT', not_null=True),
385
+ Column('account', 'TEXT', not_null=True, default="''"),
386
+ Column('msat', 'INTEGER', not_null=True),
387
+ Column('set_by', 'TEXT'),
388
+ Column('at', 'INTEGER', not_null=True),
389
+ ), primary_key=('sha256', 'account')),
390
+ Table('fetch_allowance', (
391
+ Column('fetcher', 'TEXT', not_null=True),
392
+ Column('day', 'INTEGER', not_null=True),
393
+ Column('bytes', 'INTEGER', not_null=True),
394
+ ), primary_key=('fetcher', 'day')),
395
+ )
396
+
397
+ def price(self, sha256: str, account: str = '') -> Optional[int]:
398
+ r = self._one('SELECT msat FROM blob_prices WHERE sha256=? AND account=?', (sha256.lower(), account))
399
+ return int(r[0]) if r else None
400
+
401
+ def set_price(self, sha256: str, msat: int, set_by: Optional[str], now: int, account: str = '') -> None:
402
+ with self.transaction() as cur:
403
+ cur.execute('INSERT OR REPLACE INTO blob_prices(sha256, account, msat, set_by, at) VALUES (?,?,?,?,?)',
404
+ (sha256.lower(), account, int(msat), set_by, now))
405
+
406
+ def clear_price(self, sha256: str, account: str = '') -> bool:
407
+ with self.transaction() as cur:
408
+ cur.execute('DELETE FROM blob_prices WHERE sha256=? AND account=?', (sha256.lower(), account))
409
+ return cur.rowcount > 0
410
+
411
+ def list_prices(self, limit: int = 100) -> list[PriceEntry]:
412
+ return [{'sha256': r[0], 'account': r[1], 'msat': r[2], 'set_by': r[3], 'at': r[4]} for r in
413
+ self._all('SELECT sha256, account, msat, set_by, at FROM blob_prices ORDER BY at DESC LIMIT ?', (limit,))]
414
+
415
+ def used_today(self, fetcher: str, day: int) -> int:
416
+ r = self._one('SELECT bytes FROM fetch_allowance WHERE fetcher=? AND day=?', (fetcher, day))
417
+ return int(r[0]) if r else 0
418
+
419
+ def spend_allowance(self, fetcher: str, day: int, size: int) -> None:
420
+ with self.transaction() as cur:
421
+ cur.execute('INSERT INTO fetch_allowance(fetcher, day, bytes) VALUES (?,?,?) '
422
+ 'ON CONFLICT(fetcher, day) DO UPDATE SET bytes = bytes + excluded.bytes', (fetcher, day, size))
423
+ cur.execute('DELETE FROM fetch_allowance WHERE day < ?', (day - 2,))
424
+
425
+
426
+ def _rule_matches(rule: Mapping[str, Any], size: int, ctype: str, uploader: Optional[str]) -> bool:
427
+ if 'min_bytes' in rule and size < int(rule['min_bytes']):
428
+ return False
429
+ if 'max_bytes' in rule and size > int(rule['max_bytes']):
430
+ return False
431
+ if 'content_type' in rule and not fnmatch.fnmatch(ctype or '', str(rule['content_type'])):
432
+ return False
433
+ if 'uploader' in rule and (uploader or '').lower() != str(rule['uploader']).lower():
434
+ return False
435
+ return True
436
+
437
+
438
+ def rule_price(rule: Mapping[str, Any], size: int) -> int:
439
+ if 'msat_per_mb' in rule:
440
+ return int(math.ceil(size / (1024 * 1024)) * int(rule['msat_per_mb']))
441
+ return int(rule.get('msat', 0))
442
+
443
+
444
+ class Pricing:
445
+ """§3.4 as a function of (blob stat, fetcher, account)."""
446
+
447
+ def __init__(self, cfg: Callable[[], Mapping[str, Any]], prices: PriceStore,
448
+ partners: Callable[[], Sequence[str]], clock: Callable[[], int]) -> None:
449
+ self._cfg = cfg
450
+ self.prices = prices
451
+ self.partners = partners
452
+ self.clock = clock
453
+
454
+ def cfg(self) -> Mapping[str, Any]:
455
+ out = json.loads(json.dumps(DEFAULTS))
456
+ raw = self._cfg().get('fetch_pricing') or {}
457
+ for k, v in raw.items():
458
+ if k == 'rails' and isinstance(v, dict):
459
+ for rail, rcfg in v.items():
460
+ out['rails'].setdefault(rail, {}).update(rcfg or {})
461
+ else:
462
+ out[k] = v
463
+ return out
464
+
465
+ def operator_msat(self, stat: Mapping[str, Any], fetcher: Optional[str]) -> int:
466
+ cfg = self.cfg()
467
+ if fetcher and cfg.get('free_for_partners', True) and fetcher in set(self.partners()):
468
+ return 0
469
+ sha = stat['sha256']
470
+ per_item = self.prices.price(sha)
471
+ if per_item is not None:
472
+ return per_item
473
+ size, ctype, uploader = int(stat.get('size', 0)), str(stat.get('type') or ''), stat.get('uploaded_by')
474
+ for rule in cfg.get('rules') or []:
475
+ if isinstance(rule, Mapping) and _rule_matches(rule, size, ctype, uploader):
476
+ return rule_price(rule, size)
477
+ return int(cfg.get('default_msat') or 0)
478
+
479
+ def account_msat(self, stat: Mapping[str, Any], account: Optional[str]) -> int:
480
+ """The fee an account put on its own upload, capped by the operator.
481
+ Bound to (account, sha256), and only when the fetch names the account."""
482
+ if not account:
483
+ return 0
484
+ cap = int(self.cfg().get('max_account_fee_msat') or 0)
485
+ if cap <= 0:
486
+ return 0
487
+ if (stat.get('uploaded_by') or '').lower() != account.lower():
488
+ return 0
489
+ fee = self.prices.price(stat['sha256'], account.lower())
490
+ return min(int(fee or 0), cap)
491
+
492
+ def allowance_covers(self, fetcher: Optional[str], size: int) -> bool:
493
+ """True when this fetch fits inside the fetcher's free bytes for today.
494
+ None (the default) means there is no free tier, not an unlimited one:
495
+ the operator's prices then apply from the first byte."""
496
+ limit = self.cfg().get('free_bytes_per_day')
497
+ if limit is None:
498
+ return False
499
+ day = self.clock() // 86400
500
+ return self.prices.used_today(fetcher or ANON, day) + size <= int(limit)
501
+
502
+ def spend_allowance(self, fetcher: Optional[str], size: int) -> None:
503
+ if self.cfg().get('free_bytes_per_day') is None:
504
+ return
505
+ self.prices.spend_allowance(fetcher or ANON, self.clock() // 86400, size)
506
+
507
+
508
+ # ---------------------------------------------------------------------------
509
+ # The subsystem
510
+ # ---------------------------------------------------------------------------
511
+
512
+ class PaidFetch(compartment_mod.Compartment):
513
+ """Prices fetches, issues 402s, verifies payment. Consulted by BlossomServer."""
514
+
515
+ name = 'paidfetch'
516
+ attr = 'paid_fetch'
517
+ PRICES_DB: str = 'fetch_prices.sqlite'
518
+ reads = ('fetch_pricing',)
519
+ live = ('fetch_pricing',)
520
+ config_cls = FetchPricingConfig
521
+ SCHEMA = PriceStore.SCHEMA
522
+ STORE_CLS = PriceStore # builds its own; the base opens nothing for it (B5)
523
+ STORES = (
524
+ st.Store(key='receipts', description='recent fetch payment receipts',
525
+ kind=st.RING, shape=dict, bound=1024),
526
+ )
527
+ CHANNELS = (
528
+ Channel('fetch_paid', 'Blob fetch payment verified and settled', level=INFO, say='fetch payment settled for {sha256} ({amount_msat} msat)', tags=('paidfetch', 'settle')),
529
+ Channel('payment_refused', 'Blob fetch payment refused', level=NOTE, say='fetch payment refused: {reason}', tags=('paidfetch', 'payment')),
530
+ )
531
+ METRICS = (
532
+ Metric(name='paidfetch_fetches_paid', description='Times fetches were paid.', kind=COUNTER, counts='fetch_paid', tags=('paidfetch', 'settle')),
533
+ Metric(name='paidfetch_payments_refused', description='Times fetch payments were refused.', kind=COUNTER, counts='payment_refused', tags=('paidfetch', 'payment')),
534
+ )
535
+ EVERY = (
536
+ compartment_mod.Every(seconds=60, run='sweep_tick', name='sweep'),
537
+ )
538
+ NEEDS = (
539
+ Need('cfg', 'cfg', getter=True),
540
+ Need('db_path', path='PRICES_DB'),
541
+ Need('grant_store', 'stores.grants'),
542
+ Need('node_pk', 'pubkey'),
543
+ Need('persist_cfg'),
544
+ Need('face'),
545
+ )
546
+ COMMANDS = (
547
+ Command(
548
+ key='price_set',
549
+ name='Set Price',
550
+ description='price one blob for fetching (account: a fee that account adds to its own upload)',
551
+ tags=('paidfetch', 'price'),
552
+ parameters=(
553
+ Parameter(key='sha256', name='sha256', description='hex digest of blob to price', type=str, default='', required=True),
554
+ Parameter(key='msat', name='msat', description='price in millisatoshis', type=int, default=0, required=True),
555
+ Parameter(key='account', name='account', description='a fee that account adds to its own upload', type=str, default=None, required=False),
556
+ ),
557
+ permission='paidfetch/price_set',
558
+ run='price_set_cmd',
559
+ reply='dict',
560
+ ),
561
+ Command(
562
+ key='price_get',
563
+ name='Get Price',
564
+ description='what one blob costs by rule and by item',
565
+ tags=('paidfetch', 'price'),
566
+ parameters=(
567
+ Parameter(key='sha256', name='sha256', description='hex digest of blob', type=str, default='', required=True),
568
+ ),
569
+ permission='paidfetch/price_get',
570
+ run='price_get_cmd',
571
+ reply='dict',
572
+ ),
573
+ Command(
574
+ key='price_clear',
575
+ name='Clear Price',
576
+ description='remove a per-item price',
577
+ tags=('paidfetch', 'price'),
578
+ parameters=(
579
+ Parameter(key='sha256', name='sha256', description='hex digest of blob', type=str, default='', required=True),
580
+ Parameter(key='account', name='account', description='account override to clear', type=str, default=None, required=False),
581
+ ),
582
+ permission='paidfetch/price_clear',
583
+ run='price_clear_cmd',
584
+ reply='bool',
585
+ ),
586
+ Command(
587
+ key='prices',
588
+ name='List Prices',
589
+ description='every per-item price set on this node',
590
+ tags=('paidfetch', 'prices'),
591
+ permission='paidfetch/prices',
592
+ run='prices_cmd',
593
+ reply='list',
594
+ ),
595
+ Command(
596
+ key='rails',
597
+ name='Payment Rails',
598
+ description='the payment rails this node offers in a 402',
599
+ tags=('paidfetch', 'rails'),
600
+ permission='paidfetch/rails',
601
+ run='rails_cmd',
602
+ reply='dict',
603
+ ),
604
+ Command(
605
+ key='wallet',
606
+ name='Wallet Balance',
607
+ description="this node's earnings held as ecash, by mint",
608
+ tags=('paidfetch', 'wallet'),
609
+ permission='paidfetch/wallet',
610
+ run='wallet_cmd',
611
+ reply='dict',
612
+ ),
613
+ Command(
614
+ key='sweep',
615
+ name='Sweep Wallet',
616
+ description='move earnings above the threshold out to Lightning now',
617
+ tags=('paidfetch', 'sweep'),
618
+ permission='paidfetch/sweep',
619
+ run='sweep_cmd',
620
+ reply='dict',
621
+ ),
622
+ Command(
623
+ key='grasp_price_set',
624
+ name='Set GRASP Price',
625
+ description='flat price for one GRASP git clone/fetch session (0: free)',
626
+ tags=('paidfetch', 'grasp'),
627
+ parameters=(
628
+ Parameter(key='msat', name='msat', description='session price in millisatoshis', type=int, default=0, required=True),
629
+ ),
630
+ permission='paidfetch/grasp_price_set',
631
+ run='grasp_price_set_cmd',
632
+ reply='dict',
633
+ ),
634
+ Command(
635
+ key='grasp_price_get',
636
+ name='Get GRASP Price',
637
+ description='the current GRASP session price',
638
+ tags=('paidfetch', 'grasp'),
639
+ permission='paidfetch/grasp_price_get',
640
+ run='grasp_price_get_cmd',
641
+ reply='dict',
642
+ ),
643
+ )
644
+
645
+ def start(self) -> None:
646
+ blossom = getattr(self.face, 'blossom', None)
647
+ if blossom is not None:
648
+ blossom.pricing = self
649
+
650
+ def __init__(self, cfg: Callable[[], Mapping[str, Any]], db_path: str = ':memory:',
651
+ partners: Optional[Callable[[], Sequence[str]]] = None, node_pk: Optional[str] = None,
652
+ backend: Optional[MintBackend] = None, clock: Optional[Callable[[], int]] = None,
653
+ node: Any = None, client_factory: Optional[Callable[[str], Any]] = None,
654
+ persist_cfg: Optional[Callable[[], None]] = None,
655
+ grant_store: Optional[Any] = None,
656
+ face: Optional[Any] = None) -> None:
657
+ super().__init__(node, clock)
658
+ self.node_pk = node_pk
659
+ self.face = face
660
+ self.prices = PriceStore(db_path)
661
+ self.db = self.prices
662
+ if partners is not None:
663
+ partners_fn = partners
664
+ elif grant_store is not None:
665
+ partners_fn = lambda: grant_store.peers()
666
+ else:
667
+ partners_fn = lambda: []
668
+ self.pricing = Pricing(cfg, self.prices, partners_fn, self.clock)
669
+ if backend is None:
670
+ proofs_path = ':memory:' if db_path == ':memory:' else os.path.join(os.path.dirname(db_path), 'cashu_wallet.sqlite')
671
+ backend = AllowlistBackend(lambda: self.pricing.cfg()['rails'].get('cashu') or {}, ProofStore(proofs_path),
672
+ node_pk=node_pk, client_factory=client_factory, clock=self.clock)
673
+ self.backend: MintBackend = backend
674
+ self._quotes = 0
675
+ self._closed = False
676
+ # Writes a changed price back to the node's config file; a no-op for
677
+ # a compartment built without a node directory (tests, offline tools).
678
+ self.persist_cfg: Callable[[], None] = persist_cfg or (lambda: None)
679
+
680
+ # -- what Blossom asks ---------------------------------------------------
681
+ def quote(self, stat: Mapping[str, Any], fetcher: Optional[str], account: Optional[str]) -> Optional[Quote]:
682
+ """None when the fetch is free (no 402, no headers). Otherwise the
683
+ itemised price with one offer per item from the backend."""
684
+ size = int(stat.get('size', 0))
685
+ op = self.pricing.operator_msat(stat, fetcher)
686
+ acct = self.pricing.account_msat(stat, account)
687
+ if op + acct == 0:
688
+ return None
689
+ if self.pricing.allowance_covers(fetcher, size):
690
+ return None
691
+ items: list[Item] = []
692
+ if op > 0:
693
+ items.append(Item('operator', self.node_pk or '', op))
694
+ if acct > 0 and account:
695
+ items.append(Item('account', account.lower(), acct))
696
+ now = self.clock()
697
+ expires = now + int(self.pricing.cfg().get('offer_seconds') or 600)
698
+ self._quotes += 1
699
+ q = Quote(stat['sha256'], size, items, expires)
700
+ for idx, item in enumerate(items):
701
+ off = self.backend.offer(item, '%s:%d' % (stat['sha256'][:16], idx), expires)
702
+ if off:
703
+ q.offers.append(dict(off, item=idx))
704
+ return q
705
+
706
+ def session_msat(self, name: str) -> int:
707
+ """The flat per-session price for sidecar `name` (0: free).
708
+ `grasp_session_msat` is the older spelling for grasp and wins."""
709
+ cfg = self.pricing.cfg()
710
+ if name == 'grasp' and cfg.get('grasp_session_msat'):
711
+ return int(cfg['grasp_session_msat'])
712
+ table = cfg.get('sidecar_session_msat') or {}
713
+ return int(table.get(name) or 0) if isinstance(table, Mapping) else 0
714
+
715
+ def quote_session(self, subject: str, fetcher: Optional[str]) -> Optional[Quote]:
716
+ """A sidecar session (a GRASP git clone/fetch is the model), priced
717
+ as one flat operator item - the proxy in front of a many-request
718
+ protocol cannot meter individual objects, so the whole session is
719
+ the billable unit. `subject` is `<sidecar name>:<stable string>`
720
+ (repo pubkey and identifier is typical for git); the name picks the
721
+ price (session_msat), the whole string stands in for a blob's
722
+ sha256 in the 402 and in settle(). It is never hashed or validated
723
+ here, only quoted against and carried through to the backend.
724
+ """
725
+ name = subject.split(':', 1)[0]
726
+ op = 0 if (fetcher and self.pricing.cfg().get('free_for_partners', True)
727
+ and fetcher in set(self.pricing.partners())) else self.session_msat(name)
728
+ if op == 0:
729
+ return None
730
+ now = self.clock()
731
+ expires = now + int(self.pricing.cfg().get('offer_seconds') or 600)
732
+ self._quotes += 1
733
+ item = Item('operator', self.node_pk or '', op)
734
+ q = Quote(subject, 0, [item], expires)
735
+ off = self.backend.offer(item, '%s:0' % subject[:16], expires)
736
+ if off:
737
+ q.offers.append(dict(off, item=0))
738
+ return q
739
+
740
+ def settle(self, quote: Quote, tokens: Sequence[str]) -> Paid | Unpaid:
741
+ """One token per item, in item order. Every item must verify; the
742
+ first failure is the answer and nothing later is touched. An operator
743
+ item that swapped before a later item fails stays swapped: the node
744
+ was paid for it, and a refusal is recorded so the operator can see it."""
745
+ if len(tokens) < len(quote.items):
746
+ short = Unpaid('missing payment', '%d token(s) for %d item(s)' % (len(tokens), len(quote.items)))
747
+ self.refusals.refuse('payment', short.reason, subject=quote.sha256, detail={'why': short.detail})
748
+ return short
749
+ if not quote.offers:
750
+ norail = Unpaid('no rail', 'this node lists no mint to pay')
751
+ self.refusals.refuse('payment', norail.reason, subject=quote.sha256, detail={'why': norail.detail})
752
+ return norail
753
+ total = 0
754
+ for item, tok in zip(quote.items, tokens):
755
+ res = self.backend.verify(item, tok)
756
+ if isinstance(res, Unpaid):
757
+ self.refusals.refuse('payment', res.reason, subject=quote.sha256, detail={'why': res.detail, 'to': item.to})
758
+ return res
759
+ total += res.amount_msat
760
+ return Paid('cashu', total, {'items': len(quote.items)})
761
+
762
+ def note_served(self, fetcher: Optional[str], size: int) -> None:
763
+ self.pricing.spend_allowance(fetcher, size)
764
+
765
+ # -- Compartment ------------------------------------------------------------
766
+ def sweep_tick(self, now: int) -> list[tuple[Any, ...]]:
767
+ try:
768
+ r = self.backend.sweep(now)
769
+ except Exception as e:
770
+ self.refusals.refuse('sweep', 'failed', detail={'why': str(e)[:200]})
771
+ return []
772
+ return [('sweep', r)] if r else []
773
+
774
+ _tick_runs_every = True
775
+
776
+ def tick(self, now: int) -> list[tuple[Any, ...]]:
777
+ return self.sweep_tick(now)
778
+
779
+ def state(self) -> Mapping[str, Any]:
780
+ cfg = self.pricing.cfg()
781
+ if self._closed:
782
+ # health() is read after stop(); report the config and counters, not the stores.
783
+ return {'stopped': True, 'default_msat': cfg['default_msat'], 'rules': len(cfg.get('rules') or []),
784
+ 'quotes': self._quotes}
785
+ store = getattr(self.backend, 'store', None)
786
+ return {'default_msat': cfg['default_msat'], 'rules': len(cfg.get('rules') or []),
787
+ 'per_item_prices': len(self.prices.list_prices(10000)), 'quotes': self._quotes,
788
+ 'rail': self.backend.describe(), 'wallet': self.backend.balance(),
789
+ 'account_proofs': store.account_proof_count() if isinstance(store, ProofStore) else 0}
790
+
791
+ def trouble(self) -> list[str]:
792
+ cfg = self.pricing.cfg()
793
+ priced = cfg['default_msat'] or cfg.get('rules') or self.prices.list_prices(1)
794
+ if priced and not (cfg['rails'].get('cashu') or {}).get('mints'):
795
+ return [self.trouble_with(
796
+ 'fetches are priced but fetch_pricing.rails.cashu.mints is empty: every priced fetch is refused with nothing to pay',
797
+ code='no_mints',
798
+ severity='trouble',
799
+ hint='configure mints under fetch_pricing.rails.cashu.mints or set default_msat to 0',
800
+ )]
801
+ return []
802
+
803
+
804
+ def descriptor_tags(self) -> list[list[str]]:
805
+ """What the kind 11801 says: the mints this node takes and that it prices fetches."""
806
+ d = self.backend.describe()
807
+ tags = [['mint', m] for m in d.get('mints', [])]
808
+ cfg = self.pricing.cfg()
809
+ if cfg['default_msat'] or cfg.get('rules') or self.prices.list_prices(1):
810
+ tags.append(['price', 'fetch', str(int(cfg['default_msat'] or 0)), 'msat'])
811
+ names = ['grasp'] + sorted(n for n in (cfg.get('sidecar_session_msat') or {}) if n != 'grasp')
812
+ for n in names:
813
+ msat = self.session_msat(n)
814
+ if msat:
815
+ tags.append(['price', '%s-session' % n, str(msat), 'msat'])
816
+ return tags
817
+
818
+ def price_set_cmd(self, args: Mapping[str, Any]) -> PriceSetResult:
819
+ now = self.clock()
820
+ acct = str(args.get('account') or '').lower()
821
+ self.prices.set_price(str(args['sha256']), int(args['msat']), self.node_pk, now, account=acct)
822
+ return {'sha256': str(args['sha256']).lower(), 'account': acct, 'msat': int(args['msat'])}
823
+
824
+ def price_get_cmd(self, args: Mapping[str, Any]) -> PriceGetResult:
825
+ sha = str(args['sha256']).lower()
826
+ return {'sha256': sha, 'per_item_msat': self.prices.price(sha),
827
+ 'default_msat': self.pricing.cfg()['default_msat']}
828
+
829
+ def price_clear_cmd(self, args: Mapping[str, Any]) -> bool:
830
+ return self.prices.clear_price(str(args['sha256']), str(args.get('account') or '').lower())
831
+
832
+ def prices_cmd(self) -> list[PriceEntry]:
833
+ return self.prices.list_prices()
834
+
835
+ def rails_cmd(self) -> Mapping[str, Any]:
836
+ return {'cashu': self.backend.describe()}
837
+
838
+ def wallet_cmd(self) -> Mapping[str, Any]:
839
+ return self.backend.balance()
840
+
841
+ def sweep_cmd(self) -> Mapping[str, Any]:
842
+ now = self.clock()
843
+ return self.backend.sweep(now, force=True) or {'swept': 0, 'why': 'nothing to sweep'}
844
+
845
+ def grasp_price_set_cmd(self, args: Mapping[str, Any]) -> dict[str, int]:
846
+ msat = int(args['msat'])
847
+ raw = self.pricing._cfg()
848
+ fp = raw.get('fetch_pricing') if isinstance(raw, dict) and 'fetch_pricing' in raw else raw
849
+ if isinstance(fp, dict):
850
+ fp['grasp_session_msat'] = msat
851
+ self.persist_cfg()
852
+ return {'grasp_session_msat': msat}
853
+
854
+ def grasp_price_get_cmd(self) -> dict[str, int]:
855
+ return {'grasp_session_msat': int(self.pricing.cfg().get('grasp_session_msat') or 0)}
856
+
857
+ def stop(self) -> Any:
858
+ self._closed = True
859
+ super().stop()
860
+ store = getattr(self.backend, 'store', None)
861
+ if store is not None:
862
+ store.close()