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,456 @@
1
+ """SettlementEngine: multi-rail escrow lifecycle (create / hold / settle /
2
+ reconcile / cancel / stream), and the one asymmetric-settlement write -
3
+ expanding a peer's grant with a verified nutzap, bounded by the paid
4
+ ceiling for its tier."""
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import threading
9
+ import time
10
+ from typing import Any, Mapping, Optional
11
+
12
+ from fleetmesh_core.accounting import cashu, window
13
+ from . import gateways, payment
14
+ from .types import EscrowRowDict
15
+ from node.storage import grants as grants_mod
16
+ from .store import PersistentSpentProofs, SettlementStore
17
+
18
+
19
+ # How far a paid expansion may take a limit past the tier ceiling.
20
+ #
21
+ # A nutzap added to the grant's limits without bound: 10 bytes_in per msat,
22
+ # so a million sats bought ten gigabytes of bytes_in on a probation grant whose
23
+ # ceiling is 3.2 MB. `_write_grant` clamps every other writer to
24
+ # `window.within(limits, tier)` and this one was not clamped at all. Payment
25
+ # is allowed to buy headroom above the tier - that is what asymmetric
26
+ # settlement is for - but a bounded amount of it: each limit is held to
27
+ # ceiling * PAID_CEILING_MULTIPLIER, so a paid peer cannot outgrow what the
28
+ # node's capacity planning assumed for the tier by more than this factor.
29
+ # The clamp mirrors `_write_grant`'s: the limit is held at the cap and the
30
+ # result says which limits were cut and by how much, so the payer can see
31
+ # where its top-up stopped buying anything. A nutzap that would move nothing
32
+ # at all is refused before its proofs are spent, so it cannot be burned for
33
+ # no return.
34
+ PAID_CEILING_MULTIPLIER: int = 4
35
+
36
+
37
+ def paid_ceilings(tier: str) -> Mapping[str, Any]:
38
+ """The most a paid grant at `tier` may hold on each limit."""
39
+ return {k: v * PAID_CEILING_MULTIPLIER for k, v in window.ceilings(tier).items() if k != 'dup_ratio'}
40
+
41
+
42
+ class _Rollback(Exception):
43
+ """Raised inside a `db.transaction()` block to undo the row write when
44
+ the rail refused; caught by the method that raised it."""
45
+
46
+
47
+ class SettlementEngine:
48
+ """Manages multi-rail escrow lifecycle and proof persistence."""
49
+
50
+ def __init__(
51
+ self,
52
+ db_path: str = ':memory:',
53
+ clock: Optional[Any] = None,
54
+ mint_url: str = payment.DEFAULT_MINT_URL,
55
+ mint_privkey_hex: Optional[str] = None,
56
+ nwc_uri: Optional[str] = None,
57
+ use_live_cashu: bool = False,
58
+ node_pk: Optional[str] = None,
59
+ allow_unverified_proofs: bool = False,
60
+ db: Optional[Any] = None,
61
+ ) -> None:
62
+ self.db_path: str = db_path
63
+ self.clock = clock or (lambda: int(time.time()))
64
+ self._lock: threading.RLock = threading.RLock()
65
+ self.db: Any = db if db is not None else SettlementStore(self.db_path)
66
+ # The pubkey a nutzap must name in its `p` tag to pay THIS node. None
67
+ # means the engine has no identity and refuses every expansion: a
68
+ # nutzap addressed to anyone was accepted as payment to us.
69
+ self.node_pk: Optional[str] = node_pk.lower() if node_pk else None
70
+ # One persistent spent-secret ledger for every cashu path.
71
+ self.spent: PersistentSpentProofs = PersistentSpentProofs(self.db, self.clock)
72
+
73
+ cashu_rail: payment.CashuPaymentRail
74
+ if use_live_cashu:
75
+ # A live rail asks the real mint whether the proofs are good. That
76
+ # is the proof verifier when this node is not itself the mint.
77
+ cashu_rail = gateways.LiveCashuPaymentRail(mint_url=mint_url, mint_privkey_hex=mint_privkey_hex,
78
+ spent=self.spent)
79
+ else:
80
+ cashu_rail = payment.CashuPaymentRail(mint_url=mint_url, mint_privkey_hex=mint_privkey_hex,
81
+ spent=self.spent,
82
+ allow_unverified_proofs=allow_unverified_proofs)
83
+
84
+ # Initialize Payment Rails
85
+ self.rails: dict[str, payment.PaymentRail] = {
86
+ 'lightning': payment.LightningHoldPaymentRail(),
87
+ 'cashu': cashu_rail,
88
+ 'mock': payment.MockPaymentRail(),
89
+ }
90
+
91
+ if nwc_uri:
92
+ self.rails['nwc'] = gateways.NWCLightningPaymentRail(nwc_client=gateways.NWCClient(nwc_uri))
93
+
94
+ def register_rail(self, rail_id: str, rail: payment.PaymentRail) -> None:
95
+ with self._lock:
96
+ self.rails[rail_id.lower()] = rail
97
+
98
+ def get_rail(self, rail_id: str) -> payment.PaymentRail:
99
+ rail = self.rails.get(rail_id.lower())
100
+ if not rail:
101
+ raise ValueError(f"unsupported settlement rail: {rail_id}")
102
+ return rail
103
+
104
+ def cashu_rail(self) -> payment.CashuPaymentRail:
105
+ """The cashu rail, or a clear error when an operator registered
106
+ something under that name that is not one."""
107
+ rail = self.get_rail('cashu')
108
+ if not isinstance(rail, payment.CashuPaymentRail):
109
+ raise ValueError("the 'cashu' rail is a %s, not a CashuPaymentRail" % type(rail).__name__)
110
+ return rail
111
+
112
+ def create_escrow(
113
+ self,
114
+ ask_id: str,
115
+ buyer_pk: str,
116
+ seller_pk: str,
117
+ amount_msat: int,
118
+ payment_hash: str,
119
+ rail_id: str = 'lightning',
120
+ ) -> Mapping[str, Any]:
121
+ """Create a new escrow entry across chosen settlement rail."""
122
+ r = self.get_rail(rail_id)
123
+ h = payment_hash.lower()
124
+ now = self.clock()
125
+
126
+ inv = r.create_hold_invoice(amount_msat, h)
127
+
128
+ self.db.execute('''
129
+ INSERT OR REPLACE INTO escrows
130
+ (ask_id, buyer, seller, amount_msat, payment_hash, status, rail, created_at)
131
+ VALUES (?, ?, ?, ?, ?, 'CREATED', ?, ?)
132
+ ''', (ask_id.lower(), buyer_pk.lower(), seller_pk.lower(), amount_msat, h, rail_id.lower(), now))
133
+ self.db.commit()
134
+
135
+ return {
136
+ 'ask_id': ask_id.lower(),
137
+ 'buyer': buyer_pk.lower(),
138
+ 'seller': seller_pk.lower(),
139
+ 'amount_msat': amount_msat,
140
+ 'payment_hash': h,
141
+ 'status': 'CREATED',
142
+ 'rail': rail_id.lower(),
143
+ 'invoice': inv.to_dict(),
144
+ }
145
+
146
+ def hold_escrow(self, ask_id: str, token_or_proof: Any = None) -> bool:
147
+ """Lock funds into escrow."""
148
+ row = self.db._one('''
149
+ SELECT payment_hash, rail, status FROM escrows WHERE ask_id = ?
150
+ ''', (ask_id.lower(),))
151
+
152
+ if not row or row[2] != 'CREATED':
153
+ return False
154
+
155
+ pay_hash, rail_id, _ = row
156
+ r = self.get_rail(rail_id)
157
+ if not r.hold_payment(pay_hash, token_or_proof):
158
+ return False
159
+
160
+ self.db.execute("UPDATE escrows SET status = 'HELD' WHERE ask_id = ?", (ask_id.lower(),))
161
+ self.db.commit()
162
+ return True
163
+
164
+ def settle_escrow(self, ask_id: str, preimage: str) -> bool:
165
+ """Settle escrow upon disclosure of verified preimage.
166
+
167
+ Under the engine lock and one sqlite transaction: the row is moved
168
+ to SETTLED first, then the rail; a rail refusal raises inside the
169
+ transaction so the row rolls back. The rail only changes state after
170
+ the DB write has succeeded, so a crash between the two leaves at
171
+ worst a SETTLED row for an escrow the (in-memory) rail will rebuild
172
+ from it, never a settled rail with a HELD row.
173
+ """
174
+ with self._lock:
175
+ try:
176
+ with self.db.transaction() as cur:
177
+ row = cur.execute('SELECT payment_hash, rail, status FROM escrows WHERE ask_id = ?',
178
+ (ask_id.lower(),)).fetchone()
179
+ if not row or row[2] != 'HELD':
180
+ return False
181
+ pay_hash, rail_id, _ = row
182
+ r = self.get_rail(rail_id)
183
+ now = self.clock()
184
+ cur.execute('''
185
+ UPDATE escrows SET status = 'SETTLED', preimage = ?, settled_at = ?
186
+ WHERE ask_id = ?
187
+ ''', (preimage, now, ask_id.lower()))
188
+ if not r.settle_payment(pay_hash, preimage):
189
+ raise _Rollback()
190
+ return True
191
+ except _Rollback:
192
+ return False
193
+
194
+ def cancel_escrow(self, ask_id: str, reason: str = '') -> bool:
195
+ """Cancel escrow and refund locked funds. Same lock + transaction
196
+ discipline as settle_escrow; a rail that refuses the cancel (already
197
+ settled under us) rolls the row back."""
198
+ with self._lock:
199
+ try:
200
+ with self.db.transaction() as cur:
201
+ row = cur.execute('SELECT payment_hash, rail, status FROM escrows WHERE ask_id = ?',
202
+ (ask_id.lower(),)).fetchone()
203
+ if not row or row[2] in ('SETTLED', 'CANCELLED'):
204
+ return False
205
+ pay_hash, rail_id, _ = row
206
+ r = self.get_rail(rail_id)
207
+ cur.execute("UPDATE escrows SET status = 'CANCELLED' WHERE ask_id = ?", (ask_id.lower(),))
208
+ if r.get_status(pay_hash).status in ('CREATED', 'HELD') and not r.cancel_payment(pay_hash, reason=reason):
209
+ raise _Rollback()
210
+ return True
211
+ except _Rollback:
212
+ return False
213
+
214
+ def reconcile_escrow(
215
+ self,
216
+ ask_id: str,
217
+ actual_cost_msat: int,
218
+ preimage: str,
219
+ ) -> Mapping[str, Any]:
220
+ """Reconcile a variable-length token escrow (Pre-Funded Escrow with Delta Refund).
221
+
222
+ The seller claims actual metered cost (N_tokens * rate <= amount_msat)
223
+ using preimage k, and the remainder (amount_msat - actual_cost_msat)
224
+ is refunded to the buyer.
225
+ """
226
+ row = self.db._one('''
227
+ SELECT payment_hash, rail, status, amount_msat, buyer, seller
228
+ FROM escrows WHERE ask_id = ?
229
+ ''', (ask_id.lower(),))
230
+
231
+ if not row:
232
+ raise ValueError(f"escrow not found for ask_id {ask_id}")
233
+ pay_hash, rail_id, status, amount_msat, buyer, seller = row
234
+
235
+ if status != 'HELD':
236
+ raise ValueError(f"cannot reconcile escrow in status {status} (must be HELD)")
237
+
238
+ if actual_cost_msat < 0:
239
+ raise ValueError(f"actual_cost_msat cannot be negative: {actual_cost_msat}")
240
+
241
+ if actual_cost_msat > amount_msat:
242
+ raise ValueError(
243
+ f"metered cost exceeds escrow ceiling ({actual_cost_msat} > {amount_msat})"
244
+ )
245
+
246
+ # Verify preimage hashes to payment_hash
247
+ calc_hash = hashlib.sha256(bytes.fromhex(preimage)).hexdigest()
248
+ if calc_hash != pay_hash.lower():
249
+ raise ValueError("disclosed preimage does not hash to escrow payment_hash")
250
+
251
+ r = self.get_rail(rail_id)
252
+ if not r.settle_payment(pay_hash, preimage):
253
+ raise ValueError(f"rail '{rail_id}' payment settlement failed")
254
+
255
+ refund_msat = amount_msat - actual_cost_msat
256
+ now = self.clock()
257
+
258
+ self.db.execute('''
259
+ UPDATE escrows
260
+ SET status = 'RECONCILED',
261
+ amount_msat = ?,
262
+ preimage = ?,
263
+ settled_at = ?
264
+ WHERE ask_id = ?
265
+ ''', (actual_cost_msat, preimage, now, ask_id.lower()))
266
+ self.db.commit()
267
+
268
+ return {
269
+ 'ask_id': ask_id.lower(),
270
+ 'status': 'RECONCILED',
271
+ 'buyer': buyer,
272
+ 'seller': seller,
273
+ 'budget_ceiling_msat': amount_msat,
274
+ 'claimed_msat': actual_cost_msat,
275
+ 'refunded_msat': refund_msat,
276
+ 'payment_hash': pay_hash,
277
+ 'preimage': preimage,
278
+ 'settled_at': now,
279
+ }
280
+
281
+ def stream_micropayment(
282
+ self,
283
+ ask_id: str,
284
+ delta_tokens: int,
285
+ rate_msat_per_token: int,
286
+ cashu_token: Optional[str] = None,
287
+ ) -> Mapping[str, Any]:
288
+ """Process Pay-As-You-Stream micropayment milestone (Cashu Nutzap)."""
289
+ if delta_tokens < 0:
290
+ raise ValueError(f"delta_tokens cannot be negative ({delta_tokens})")
291
+ if rate_msat_per_token < 0:
292
+ raise ValueError(f"rate_msat_per_token cannot be negative ({rate_msat_per_token})")
293
+ cost_msat = delta_tokens * rate_msat_per_token
294
+ return {
295
+ 'ask_id': ask_id.lower(),
296
+ 'delta_tokens': delta_tokens,
297
+ 'cost_msat': cost_msat,
298
+ 'token_delivered': bool(cashu_token),
299
+ 'status': 'STREAM_PAID',
300
+ }
301
+
302
+ def get_escrow(self, ask_id: str) -> Optional[EscrowRowDict]:
303
+ """Retrieve escrow record by ask_id."""
304
+ row = self.db._one('''
305
+ SELECT ask_id, buyer, seller, amount_msat, payment_hash, preimage, status, rail, created_at, settled_at
306
+ FROM escrows WHERE ask_id = ?
307
+ ''', (ask_id.lower(),))
308
+
309
+ if not row:
310
+ return None
311
+ return {
312
+ 'ask_id': row[0],
313
+ 'buyer': row[1],
314
+ 'seller': row[2],
315
+ 'amount_msat': row[3],
316
+ 'payment_hash': row[4],
317
+ 'preimage': row[5],
318
+ 'status': row[6],
319
+ 'rail': row[7],
320
+ 'created_at': row[8],
321
+ 'settled_at': row[9],
322
+ }
323
+
324
+ def list_escrows(self, limit: int = 50) -> list[EscrowRowDict]:
325
+ rows = self.db._all('''
326
+ SELECT ask_id, buyer, seller, amount_msat, payment_hash, preimage, status, rail, created_at, settled_at
327
+ FROM escrows ORDER BY created_at DESC LIMIT ?
328
+ ''', (limit,))
329
+
330
+ out: list[EscrowRowDict] = []
331
+ for r in rows:
332
+ out.append({
333
+ 'ask_id': r[0],
334
+ 'buyer': r[1],
335
+ 'seller': r[2],
336
+ 'amount_msat': r[3],
337
+ 'payment_hash': r[4],
338
+ 'preimage': r[5],
339
+ 'status': r[6],
340
+ 'rail': r[7],
341
+ 'created_at': r[8],
342
+ 'settled_at': r[9],
343
+ })
344
+ return out
345
+
346
+ def close(self) -> None:
347
+ """Safely close underlying settlement database."""
348
+ self.db.close()
349
+
350
+ def expand_grant_with_nutzap(
351
+ self,
352
+ grant_store: grants_mod.GrantStore,
353
+ peer_pk: str,
354
+ nutzap_event: Mapping[str, Any],
355
+ conversion_rate: Optional[Mapping[str, int]] = None,
356
+ ) -> Mapping[str, Any]:
357
+ """Asymmetric settlement: expand peer grant rate-buckets upon receiving verified Nutzap.
358
+
359
+ Spec reference: spec.html line 7268 (Bridge 4: NIP-61 Cashu Nutzap Asymmetric Settlement).
360
+
361
+ Order matters: the nutzap is verified (recipient, amount, proofs) and
362
+ the grant is found and clamped BEFORE any secret is marked spent, so
363
+ a refused expansion never burns the payer's proofs. The spent check,
364
+ the grant read, the clamp and the two writes all happen under
365
+ self._lock: two threads presenting the same nutzap used to both pass
366
+ the check outside the lock and both credit the grant.
367
+
368
+ Custody caveat: the proofs are VERIFIED (mint key or NUT-07
369
+ check-state), not SWAPPED. Until a swap is wired in, the payer still
370
+ holds proofs the mint considers unspent and could spend them
371
+ elsewhere after this node credited the grant; the spent-secret
372
+ ledger only protects this node from being paid twice with them. A
373
+ rail that cannot verify at all (no mint key, no verifier, no explicit
374
+ allow_unverified_proofs) is refused up front.
375
+ """
376
+ if not self.node_pk:
377
+ raise ValueError("settlement engine has no node_pk; cannot tell whom a nutzap pays")
378
+ cashu_rail = self.cashu_rail()
379
+ if not cashu_rail.can_verify_proofs():
380
+ raise ValueError("invalid nutzap: no mint key or proof verifier configured: proofs cannot be verified")
381
+ ok_nut, reason, amount_msat = cashu_rail.verify_nutzap(nutzap_event, expected_recipient=self.node_pk)
382
+ if not ok_nut:
383
+ raise ValueError(f"invalid nutzap: {reason}")
384
+
385
+ proofs = cashu.proofs_of(nutzap_event)
386
+ secrets: list[str] = []
387
+ for p in proofs:
388
+ sec = p.get('secret')
389
+ if not isinstance(sec, str) or not sec:
390
+ raise ValueError("cashu proof without a secret")
391
+ if sec in secrets:
392
+ raise ValueError(f"double-spend attempt on secret: {sec}")
393
+ secrets.append(sec)
394
+
395
+ # Conversion rate: default 1 msat -> +10 bytes_in, +1 event per 1000 msat
396
+ rates = conversion_rate or {
397
+ 'bytes_in_per_msat': 10,
398
+ 'events_in_per_sat': 1,
399
+ }
400
+
401
+ extra_bytes = amount_msat * rates.get('bytes_in_per_msat', 10)
402
+ extra_events = (amount_msat // 1000) * rates.get('events_in_per_sat', 1)
403
+
404
+ with self._lock:
405
+ for sec in secrets:
406
+ if cashu_rail.is_spent(sec):
407
+ raise ValueError(f"double-spend attempt on secret: {sec}")
408
+
409
+ # Retrieve current issued grant for peer
410
+ grant = grant_store.get(peer_pk, grants_mod.ISSUED)
411
+ if not grant:
412
+ raise ValueError(f"no issued grant found for peer {peer_pk[:16]}")
413
+
414
+ held = grant['payload']
415
+ payload = dict(held)
416
+ limits: dict[str, int] = dict(held.get('limits', {}))
417
+ before = dict(limits)
418
+
419
+ limits['bytes_in'] = limits.get('bytes_in', 0) + extra_bytes
420
+ limits['events_in'] = limits.get('events_in', 0) + extra_events
421
+ limits['blob_bytes'] = limits.get('blob_bytes', 0) + (extra_bytes // 2)
422
+
423
+ # Clamp to the paid ceiling for the grant's tier (see PAID_CEILING_MULTIPLIER).
424
+ caps = paid_ceilings(held.get('tier') or 'probation')
425
+ clamped: dict[str, int] = {}
426
+ for k, cap in caps.items():
427
+ # Never lower a limit the grant already holds (e.g. one an operator
428
+ # set by hand above the paid cap); only stop payment adding to it.
429
+ floor = max(cap, before.get(k, 0))
430
+ if k in limits and limits[k] > floor:
431
+ clamped[k] = limits[k] - floor
432
+ limits[k] = floor
433
+ if all(limits.get(k, 0) <= before.get(k, 0) for k in ('bytes_in', 'events_in', 'blob_bytes')):
434
+ raise ValueError("grant is already at the paid ceiling for its tier; nutzap would buy nothing")
435
+
436
+ now = self.clock()
437
+ for p in proofs:
438
+ cashu_rail.mark_spent(p['secret'], p.get('id'), p.get('amount'), now)
439
+ payload['limits'] = limits
440
+ grant_store.put(
441
+ peer_pk,
442
+ grants_mod.ISSUED,
443
+ payload,
444
+ envelope_id=grant['envelope_id'],
445
+ now=now,
446
+ )
447
+
448
+ return {
449
+ 'peer': peer_pk,
450
+ 'amount_msat': amount_msat,
451
+ 'added_bytes_in': limits['bytes_in'] - before.get('bytes_in', 0),
452
+ 'added_events_in': limits['events_in'] - before.get('events_in', 0),
453
+ 'new_limits': limits,
454
+ 'clamped': clamped,
455
+ }
456
+