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.
- fleetmesh_node-0.2.0.dist-info/METADATA +57 -0
- fleetmesh_node-0.2.0.dist-info/RECORD +338 -0
- fleetmesh_node-0.2.0.dist-info/WHEEL +5 -0
- fleetmesh_node-0.2.0.dist-info/entry_points.txt +2 -0
- fleetmesh_node-0.2.0.dist-info/licenses/LICENSE +121 -0
- fleetmesh_node-0.2.0.dist-info/top_level.txt +2 -0
- node/RELEASE +1 -0
- node/__init__.py +35 -0
- node/app/__init__.py +49 -0
- node/app/commands.py +265 -0
- node/app/config.py +131 -0
- node/app/control.py +670 -0
- node/app/core.py +867 -0
- node/app/plugins.py +76 -0
- node/app/router.py +83 -0
- node/app/socket.py +431 -0
- node/app/systemd.py +36 -0
- node/app/views.py +204 -0
- node/channels/__init__.py +378 -0
- node/channels/bus.py +331 -0
- node/channels/journal.py +46 -0
- node/commands/__init__.py +140 -0
- node/commands/adapter.py +210 -0
- node/commands/base.py +278 -0
- node/commands/cluster_cmds.py +183 -0
- node/commands/compute_cmds.py +401 -0
- node/commands/core.py +474 -0
- node/commands/gossip_cmds.py +106 -0
- node/commands/nsite_cmds.py +141 -0
- node/commands/peering.py +402 -0
- node/commands/plugin_cmds.py +135 -0
- node/commands/sidecar_cmds.py +120 -0
- node/commands/sync.py +388 -0
- node/commands/transport_cmds.py +451 -0
- node/commands/video_cmds.py +99 -0
- node/compartment/__init__.py +124 -0
- node/compartment/base.py +868 -0
- node/compartment/command.py +458 -0
- node/compartment/context.py +163 -0
- node/compartment/every.py +108 -0
- node/compartment/handler.py +131 -0
- node/compartment/lifecycle.py +460 -0
- node/compartment/lint.py +388 -0
- node/compartment/need.py +158 -0
- node/compartment/problem.py +521 -0
- node/compartment/refusals.py +120 -0
- node/compartment/registry.py +790 -0
- node/compartment/schema.py +290 -0
- node/compartment/sidecar.py +499 -0
- node/compartment/stores.py +662 -0
- node/compartment/wire.py +225 -0
- node/compartment/worker.py +61 -0
- node/config/__init__.py +43 -0
- node/config/base.py +116 -0
- node/config/change.py +441 -0
- node/config/changestore.py +201 -0
- node/config/compartments.py +1144 -0
- node/config/node.py +318 -0
- node/config/parameter.py +563 -0
- node/config/protocols.py +274 -0
- node/config/records.py +171 -0
- node/examples/__init__.py +1 -0
- node/examples/demo.py +226 -0
- node/examples/follower.py +46 -0
- node/interfaces.py +235 -0
- node/keystore/__init__.py +27 -0
- node/keystore/core.py +103 -0
- node/keystore/enclave.py +688 -0
- node/meshnode.py +111 -0
- node/metrics/__init__.py +396 -0
- node/metrics/adapters.py +102 -0
- node/metrics/face.py +110 -0
- node/planes/__init__.py +21 -0
- node/planes/identity/__init__.py +20 -0
- node/planes/identity/announce/__init__.py +9 -0
- node/planes/identity/announce/compartment.py +447 -0
- node/planes/identity/announce/types.py +26 -0
- node/planes/identity/discovery/__init__.py +9 -0
- node/planes/identity/discovery/compartment.py +522 -0
- node/planes/identity/discovery/types.py +13 -0
- node/planes/identity/linked/__init__.py +1 -0
- node/planes/identity/linked/compartment.py +270 -0
- node/planes/identity/linked/types.py +25 -0
- node/planes/identity/presence/__init__.py +7 -0
- node/planes/identity/presence/compartment.py +409 -0
- node/planes/identity/presence/types.py +14 -0
- node/planes/operations/__init__.py +13 -0
- node/planes/operations/scheduler/__init__.py +9 -0
- node/planes/operations/scheduler/compartment.py +353 -0
- node/planes/operations/scheduler/types.py +21 -0
- node/planes/operations/updates/__init__.py +17 -0
- node/planes/operations/updates/core.py +310 -0
- node/planes/operations/updates/store.py +79 -0
- node/planes/operations/updates/types.py +20 -0
- node/planes/peering/__init__.py +28 -0
- node/planes/peering/budget/__init__.py +43 -0
- node/planes/peering/budget/chain.py +43 -0
- node/planes/peering/budget/core.py +537 -0
- node/planes/peering/budget/evaluation.py +80 -0
- node/planes/peering/budget/meter.py +100 -0
- node/planes/peering/budget/receipts.py +439 -0
- node/planes/peering/budget/shape.py +101 -0
- node/planes/peering/budget/tick.py +110 -0
- node/planes/peering/budget/types.py +67 -0
- node/planes/peering/budget/windows.py +346 -0
- node/planes/peering/capacity/__init__.py +7 -0
- node/planes/peering/capacity/compartment.py +342 -0
- node/planes/peering/capacity/types.py +24 -0
- node/planes/peering/peering/__init__.py +25 -0
- node/planes/peering/peering/core.py +464 -0
- node/planes/peering/peering/envelope.py +142 -0
- node/planes/peering/peering/handshake.py +198 -0
- node/planes/peering/peering/shape.py +80 -0
- node/planes/peering/peering/types.py +39 -0
- node/planes/peering/reporter/__init__.py +42 -0
- node/planes/peering/reporter/constants.py +29 -0
- node/planes/peering/reporter/core.py +299 -0
- node/planes/peering/reporter/finding.py +74 -0
- node/planes/peering/reporter/findings.py +171 -0
- node/planes/peering/reporter/publish.py +285 -0
- node/planes/peering/reporter/reading.py +193 -0
- node/planes/peering/reporter/shape.py +86 -0
- node/planes/peering/reporter/types.py +50 -0
- node/planes/peering/strain/__init__.py +9 -0
- node/planes/peering/strain/compartment.py +204 -0
- node/planes/peering/strain/types.py +21 -0
- node/planes/peering/trust/__init__.py +9 -0
- node/planes/peering/trust/compartment.py +239 -0
- node/planes/peering/trust/types.py +30 -0
- node/planes/services/__init__.py +33 -0
- node/planes/services/compute/__init__.py +160 -0
- node/planes/services/compute/common.py +134 -0
- node/planes/services/compute/compartment.py +681 -0
- node/planes/services/compute/executors/__init__.py +121 -0
- node/planes/services/compute/executors/agent.py +840 -0
- node/planes/services/compute/executors/bacalhau.py +605 -0
- node/planes/services/compute/executors/base.py +127 -0
- node/planes/services/compute/executors/harness.py +439 -0
- node/planes/services/compute/executors/mcp.py +454 -0
- node/planes/services/compute/executors/media.py +898 -0
- node/planes/services/compute/executors/nondet.py +283 -0
- node/planes/services/compute/executors/providers.py +499 -0
- node/planes/services/compute/executors/registry.py +140 -0
- node/planes/services/compute/executors/wasm.py +244 -0
- node/planes/services/compute/executors/zk.py +604 -0
- node/planes/services/compute/jobs.py +227 -0
- node/planes/services/compute/load.py +180 -0
- node/planes/services/compute/market.py +433 -0
- node/planes/services/compute/sandbox.py +282 -0
- node/planes/services/compute/types.py +84 -0
- node/planes/services/compute_wire/__init__.py +58 -0
- node/planes/services/compute_wire/compartment.py +256 -0
- node/planes/services/compute_wire/messages.py +320 -0
- node/planes/services/compute_wire/sealing.py +147 -0
- node/planes/services/compute_wire/session.py +572 -0
- node/planes/services/compute_wire/types.py +49 -0
- node/planes/services/compute_wire/verify.py +331 -0
- node/planes/services/dvm/__init__.py +24 -0
- node/planes/services/dvm/gateway.py +526 -0
- node/planes/services/dvm/protocol.py +35 -0
- node/planes/services/dvm/translate.py +15 -0
- node/planes/services/dvm/types.py +25 -0
- node/planes/services/gateway/__init__.py +40 -0
- node/planes/services/gateway/settle.py +279 -0
- node/planes/services/gateway/translate.py +644 -0
- node/planes/services/gossip/__init__.py +22 -0
- node/planes/services/gossip/core.py +929 -0
- node/planes/services/gossip/envelope.py +154 -0
- node/planes/services/gossip/mesh.py +127 -0
- node/planes/services/gossip/types.py +50 -0
- node/planes/services/nsite/__init__.py +7 -0
- node/planes/services/nsite/client.py +62 -0
- node/planes/services/nsite/compartment.py +752 -0
- node/planes/services/nsite/types.py +44 -0
- node/planes/services/paidfetch/__init__.py +7 -0
- node/planes/services/paidfetch/compartment.py +862 -0
- node/planes/services/paidfetch/types.py +99 -0
- node/planes/services/settle/__init__.py +27 -0
- node/planes/services/settle/compartment.py +384 -0
- node/planes/services/settle/engine.py +456 -0
- node/planes/services/settle/gateways.py +541 -0
- node/planes/services/settle/payment.py +512 -0
- node/planes/services/settle/store.py +74 -0
- node/planes/services/settle/types.py +58 -0
- node/planes/services/sidecars/__init__.py +8 -0
- node/planes/services/sidecars/compartment.py +461 -0
- node/planes/services/sidecars/manifest.py +304 -0
- node/planes/services/sidecars/manifests/10-grasp.json +37 -0
- node/planes/services/sidecars/manifests/20-ncontainer.json +31 -0
- node/planes/services/sidecars/manifests/30-magpie.json +28 -0
- node/planes/services/sidecars/types.py +43 -0
- node/planes/services/video/__init__.py +106 -0
- node/planes/services/video/barter.py +407 -0
- node/planes/services/video/cli.py +158 -0
- node/planes/services/video/compartment.py +932 -0
- node/planes/services/video/ingest.py +524 -0
- node/planes/services/video/ledger.py +705 -0
- node/planes/services/video/packager.py +847 -0
- node/planes/services/video/player.py +456 -0
- node/planes/services/video/timed_segments.py +124 -0
- node/planes/services/video/transit.py +478 -0
- node/planes/store/__init__.py +24 -0
- node/planes/store/blob_replication/__init__.py +7 -0
- node/planes/store/blob_replication/compartment.py +496 -0
- node/planes/store/blob_replication/types.py +35 -0
- node/planes/store/blob_wire/__init__.py +48 -0
- node/planes/store/blob_wire/client.py +57 -0
- node/planes/store/blob_wire/core.py +359 -0
- node/planes/store/blob_wire/intake.py +74 -0
- node/planes/store/blob_wire/messages.py +85 -0
- node/planes/store/projected/__init__.py +7 -0
- node/planes/store/projected/compartment.py +258 -0
- node/planes/store/projected/types.py +29 -0
- node/planes/store/retention/__init__.py +7 -0
- node/planes/store/retention/compartment.py +214 -0
- node/planes/store/retention/types.py +12 -0
- node/planes/store/sync/__init__.py +35 -0
- node/planes/store/sync/core.py +311 -0
- node/planes/store/sync/envelope.py +106 -0
- node/planes/store/sync/handover.py +206 -0
- node/planes/store/sync/initiator.py +128 -0
- node/planes/store/sync/responder.py +114 -0
- node/planes/store/sync/rules.py +179 -0
- node/planes/store/sync/shape.py +80 -0
- node/planes/store/sync/types.py +51 -0
- node/planes/transport/__init__.py +23 -0
- node/planes/transport/bridge/__init__.py +13 -0
- node/planes/transport/bridge/compartment.py +785 -0
- node/planes/transport/bridge/ratelimit.py +44 -0
- node/planes/transport/bridge/types.py +43 -0
- node/planes/transport/lan/__init__.py +9 -0
- node/planes/transport/lan/compartment.py +446 -0
- node/planes/transport/lan/types.py +25 -0
- node/planes/transport/link/__init__.py +7 -0
- node/planes/transport/link/compartment.py +257 -0
- node/planes/transport/link/types.py +27 -0
- node/planes/transport/mediation/__init__.py +45 -0
- node/planes/transport/mediation/compartment.py +238 -0
- node/planes/transport/mediation/leaf.py +107 -0
- node/planes/transport/mediation/messages.py +302 -0
- node/planes/transport/mediation/service.py +233 -0
- node/planes/transport/mediation/store.py +37 -0
- node/planes/transport/mediation/types.py +20 -0
- node/planes/transport/p2p/__init__.py +7 -0
- node/planes/transport/p2p/compartment.py +349 -0
- node/planes/transport/p2p/types.py +22 -0
- node/protocols/__init__.py +277 -0
- node/protocols/activitypub/__init__.py +56 -0
- node/protocols/activitypub/actor.py +230 -0
- node/protocols/activitypub/federation.py +490 -0
- node/protocols/activitypub/media.py +145 -0
- node/protocols/activitypub/models.py +286 -0
- node/protocols/activitypub/package.py +271 -0
- node/protocols/activitypub/store.py +564 -0
- node/protocols/arcade/__init__.py +26 -0
- node/protocols/atproto/__init__.py +13 -0
- node/protocols/atproto/firehose.py +126 -0
- node/protocols/atproto/metering.py +165 -0
- node/protocols/atproto/package.py +155 -0
- node/protocols/atproto/repo.py +250 -0
- node/protocols/atproto/xrpc.py +285 -0
- node/protocols/base.py +109 -0
- node/protocols/circles/__init__.py +29 -0
- node/protocols/dm/__init__.py +50 -0
- node/protocols/grasp/__init__.py +22 -0
- node/protocols/isotime.py +15 -0
- node/protocols/nostr/__init__.py +51 -0
- node/protocols/nostr/blobstore.py +383 -0
- node/protocols/nostr/blossom.py +627 -0
- node/protocols/nostr/client.py +377 -0
- node/protocols/nostr/descriptor.py +211 -0
- node/protocols/nostr/eventstore.py +304 -0
- node/protocols/nostr/package.py +182 -0
- node/protocols/nostr/peers.py +109 -0
- node/protocols/nostr/relayserver.py +656 -0
- node/protocols/p2panda/__init__.py +22 -0
- node/protocols/p2panda/identity.py +19 -0
- node/protocols/p2panda/package.py +236 -0
- node/protocols/p2panda/rung.py +152 -0
- node/protocols/p2panda/store.py +273 -0
- node/protocols/rhio/__init__.py +33 -0
- node/protocols/rhio/package.py +275 -0
- node/protocols/rhio/rung.py +136 -0
- node/protocols/rhio/store.py +1423 -0
- node/protocols/ssb/muxrpc.py +256 -0
- node/protocols/ssb/package.py +267 -0
- node/protocols/textile/__init__.py +111 -0
- node/protocols/textile/transducers.py +259 -0
- node/release.py +119 -0
- node/storage/__init__.py +88 -0
- node/storage/accounts.py +305 -0
- node/storage/adapter.py +161 -0
- node/storage/base.py +385 -0
- node/storage/dialect.py +209 -0
- node/storage/grants.py +317 -0
- node/storage/locator.py +150 -0
- node/storage/postgres.py +294 -0
- node/storage/projected.py +953 -0
- node/storage/sharding.py +448 -0
- node/storage/standing.py +568 -0
- node/testnet/__init__.py +9 -0
- node/testnet/cluster.py +731 -0
- node/testnet/fips_daemon.py +230 -0
- node/transport/__init__.py +94 -0
- node/transport/const.py +110 -0
- node/transport/didcomm_inbound.py +251 -0
- node/transport/dnstxt.py +169 -0
- node/transport/fips_rung/__init__.py +67 -0
- node/transport/fips_rung/core.py +666 -0
- node/transport/fips_rung/listener.py +96 -0
- node/transport/fips_rung/types.py +12 -0
- node/transport/host.py +334 -0
- node/transport/iroh_rung/__init__.py +29 -0
- node/transport/iroh_rung/core.py +477 -0
- node/transport/iroh_rung/quic.py +942 -0
- node/transport/iroh_rung/types.py +22 -0
- node/transport/ladder.py +145 -0
- node/transport/lineproto.py +49 -0
- node/transport/mdns.py +303 -0
- node/transport/netfetch.py +283 -0
- node/transport/relay_rung/__init__.py +23 -0
- node/transport/relay_rung/cooldown.py +87 -0
- node/transport/relay_rung/core.py +138 -0
- node/transport/relay_rung/inbound.py +161 -0
- node/transport/relay_rung/ledger.py +104 -0
- node/transport/relay_rung/listen.py +121 -0
- node/transport/relay_rung/outbound.py +181 -0
- node/transport/relay_rung/shape.py +71 -0
- node/transport/relay_rung/types.py +58 -0
- node/transport/relay_rung/wire.py +63 -0
- node/transport/router.py +761 -0
- node/transport/rung.py +276 -0
- node/transport/ssb_rung.py +166 -0
- node/transport/ws.py +502 -0
- sidecar/fleetmesh_announce.py +338 -0
- sidecar/fleetmesh_p2p.py +373 -0
- sidecar/fleetmesh_rhio.py +352 -0
- 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
|
+
|