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,512 @@
|
|
|
1
|
+
"""Modular Settlement Rails: the adapters that move money, and nothing portable.
|
|
2
|
+
|
|
3
|
+
Spec reference: spec.html "Modular Settlement Rails & Zero-Custody Payments
|
|
4
|
+
(PaymentRail)" and "Bridge 4: NIP-60 / NIP-61 Cashu Nutzap Asymmetric
|
|
5
|
+
Settlement".
|
|
6
|
+
|
|
7
|
+
The maths and wire formats - BDHKE over secp256k1 (NUT-00..03), Cashu tokens,
|
|
8
|
+
NIP-61 Nutzap events and the proof-acceptance policy - live in
|
|
9
|
+
fleetmesh_core.accounting.cashu. What this module owns:
|
|
10
|
+
|
|
11
|
+
- `PaymentRail`, the adapter interface and the escrow state machine
|
|
12
|
+
- `CashuPaymentRail` (Chaumian e-cash blind token settlement)
|
|
13
|
+
- `LightningHoldPaymentRail` (HTLC hold-invoice escrow lifecycle)
|
|
14
|
+
- `MockPaymentRail` (in-memory testing rail)
|
|
15
|
+
- `SpentProofs`, the durable set of proofs this node already took
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import abc
|
|
20
|
+
import hashlib
|
|
21
|
+
import logging
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections import Counter
|
|
25
|
+
from dataclasses import asdict, dataclass, field
|
|
26
|
+
from typing import Any, Mapping, Optional, Sequence, cast
|
|
27
|
+
|
|
28
|
+
from fleetmesh_core.accounting.cashu import (
|
|
29
|
+
BlindSignature, CashuProof, NUTZAP_KIND, ProofVerifier,
|
|
30
|
+
accept_proofs, deserialize_cashu_token, point_to_hex,
|
|
31
|
+
proofs_of, verify_nutzap_event,
|
|
32
|
+
)
|
|
33
|
+
from fleetmesh_core.crypto import curve
|
|
34
|
+
from fleetmesh_core.wire.interfaces import NostrEvent
|
|
35
|
+
|
|
36
|
+
log = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
'DEFAULT_MINT_URL',
|
|
40
|
+
'PaymentError',
|
|
41
|
+
'IllegalTransition',
|
|
42
|
+
'TRANSITIONS',
|
|
43
|
+
'TERMINAL_STATES',
|
|
44
|
+
'HoldInvoice',
|
|
45
|
+
'UNKNOWN_INVOICE',
|
|
46
|
+
'preimage_matches',
|
|
47
|
+
'SpentProofs',
|
|
48
|
+
'PaymentRail',
|
|
49
|
+
'LightningHoldPaymentRail',
|
|
50
|
+
'CashuPaymentRail',
|
|
51
|
+
'MockPaymentRail',
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
DEFAULT_MINT_URL: str = 'https://mint.example.org'
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PaymentError(ValueError):
|
|
58
|
+
"""A rail refused to move money. A ValueError so the callers that already
|
|
59
|
+
catch ValueError around rail actions (settlement subsystem) keep working."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IllegalTransition(PaymentError):
|
|
63
|
+
"""The one error for an escrow asked to go where its state machine does
|
|
64
|
+
not: settle from CREATED, hold twice, cancel what is settled, hold what
|
|
65
|
+
has expired, or act on a hash no rail has seen (from_state UNKNOWN)."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, payment_hash: str, from_state: str, to_state: str, why: str = '') -> None:
|
|
68
|
+
self.payment_hash = payment_hash
|
|
69
|
+
self.from_state = from_state
|
|
70
|
+
self.to_state = to_state
|
|
71
|
+
self.why = why
|
|
72
|
+
tail = f' ({why})' if why else ''
|
|
73
|
+
super().__init__(f'escrow {payment_hash[:16]}: {from_state} -> {to_state} is not a legal transition{tail}')
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# The escrow state machine every rail shares: CREATED -> HELD -> SETTLED |
|
|
77
|
+
# CANCELLED | FAILED, CREATED -> CANCELLED. Terminal states have no exits.
|
|
78
|
+
TRANSITIONS: dict[str, frozenset[str]] = {
|
|
79
|
+
'CREATED': frozenset({'HELD', 'CANCELLED'}),
|
|
80
|
+
'HELD': frozenset({'SETTLED', 'CANCELLED', 'FAILED'}),
|
|
81
|
+
'SETTLED': frozenset(),
|
|
82
|
+
'CANCELLED': frozenset(),
|
|
83
|
+
'FAILED': frozenset(),
|
|
84
|
+
}
|
|
85
|
+
TERMINAL_STATES: frozenset[str] = frozenset(s for s, outs in TRANSITIONS.items() if not outs)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
from .types import HoldInvoiceDict, PaymentRailStats
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(slots=True)
|
|
92
|
+
class HoldInvoice:
|
|
93
|
+
"""One rail's escrow against a payment hash, from CREATED through HELD to
|
|
94
|
+
SETTLED or CANCELLED. Every rail keeps the same record; the Lightning
|
|
95
|
+
one adds a bolt11, the Cashu one the proofs it is holding, the NWC one
|
|
96
|
+
the wallet's invoice. UNKNOWN is what get_status answers for a hash no
|
|
97
|
+
rail has seen."""
|
|
98
|
+
rail: str
|
|
99
|
+
payment_hash: str
|
|
100
|
+
amount_msat: int
|
|
101
|
+
status: str = 'CREATED'
|
|
102
|
+
created_at: int = 0
|
|
103
|
+
expiry: int = 3600
|
|
104
|
+
description: str = ''
|
|
105
|
+
preimage: Optional[str] = None
|
|
106
|
+
bolt11: Optional[str] = None
|
|
107
|
+
mint: Optional[str] = None
|
|
108
|
+
invoice: Optional[str] = None # the wallet's bolt11, on the NWC rail
|
|
109
|
+
wallet_payment_hash: Optional[str] = None
|
|
110
|
+
proofs: list[CashuProof] = field(default_factory=list)
|
|
111
|
+
held_at: Optional[int] = None
|
|
112
|
+
settled_at: Optional[int] = None
|
|
113
|
+
cancelled_at: Optional[int] = None
|
|
114
|
+
cancel_reason: Optional[str] = None
|
|
115
|
+
swapped_at: Optional[int] = None # live Cashu rail: held proofs swapped at the mint
|
|
116
|
+
new_signatures: list[BlindSignature] = field(default_factory=list)
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> HoldInvoiceDict:
|
|
119
|
+
return cast(HoldInvoiceDict, asdict(self))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
UNKNOWN_INVOICE = HoldInvoice(rail='', payment_hash='', amount_msat=0, status='UNKNOWN')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Payment Rail Adapter Interface & Drivers
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def preimage_matches(preimage: Any, payment_hash: str) -> bool:
|
|
130
|
+
"""SHA256(preimage) == payment_hash, and False - never an exception - for
|
|
131
|
+
a preimage that is not hex. Every rail took bytes.fromhex on the caller's
|
|
132
|
+
string, so a stranger settling with 'zz' raised ValueError out of the
|
|
133
|
+
handler rather than being refused like any other wrong preimage."""
|
|
134
|
+
if not isinstance(preimage, str):
|
|
135
|
+
return False
|
|
136
|
+
try:
|
|
137
|
+
p_bytes = bytes.fromhex(preimage)
|
|
138
|
+
except ValueError:
|
|
139
|
+
return False
|
|
140
|
+
return hashlib.sha256(p_bytes).hexdigest().lower() == payment_hash.lower()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class SpentProofs:
|
|
144
|
+
"""The set of proof secrets this node has already accepted.
|
|
145
|
+
|
|
146
|
+
An ecash proof is bearer money: whoever shows it first is paid, and the
|
|
147
|
+
only thing stopping the same proof buying twice is this set. It was a
|
|
148
|
+
Python set on the rail object, so a restart emptied it, and the
|
|
149
|
+
`spent_proofs` SQLite table beside it was written to and never read -
|
|
150
|
+
every proof ever accepted became spendable again on every restart.
|
|
151
|
+
|
|
152
|
+
Subclasses back this with a table; the default is in-memory for rails
|
|
153
|
+
that are constructed without a store (tests, throwaway engines).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
def __init__(self) -> None:
|
|
157
|
+
self._mem: set[str] = set()
|
|
158
|
+
|
|
159
|
+
def is_spent(self, secret: str) -> bool:
|
|
160
|
+
return secret in self._mem
|
|
161
|
+
|
|
162
|
+
def mark_spent(self, secret: str, keyset_id: Optional[str] = None,
|
|
163
|
+
amount: Optional[int] = None, now: Optional[int] = None) -> None:
|
|
164
|
+
self._mem.add(secret)
|
|
165
|
+
|
|
166
|
+
def __contains__(self, secret: object) -> bool:
|
|
167
|
+
return isinstance(secret, str) and self.is_spent(secret)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class PaymentRail(abc.ABC):
|
|
171
|
+
"""The zero-custody escrow state machine every rail shares.
|
|
172
|
+
|
|
173
|
+
One escrow per payment hash, CREATED -> HELD -> SETTLED | CANCELLED |
|
|
174
|
+
FAILED (see TRANSITIONS). The base owns the escrow table, the lock, the
|
|
175
|
+
transitions, expiry and the bookkeeping (`stats`); a concrete rail only
|
|
176
|
+
implements its own I/O: `_hold_io` (is this token/proof/payment real
|
|
177
|
+
money for this escrow?) and `_settle_io` (spend/forward it). Rails that
|
|
178
|
+
need to talk to a wallet to create an invoice override
|
|
179
|
+
`create_hold_invoice` and call `_new_invoice` + `_register`.
|
|
180
|
+
|
|
181
|
+
The public verbs (`hold_payment`, `settle_payment`, `cancel_payment`)
|
|
182
|
+
answer False for every illegal move - unknown hash, wrong state, expired
|
|
183
|
+
invoice, bad preimage - and count the refusal; `create_hold_invoice`
|
|
184
|
+
raises IllegalTransition for a hash already in flight, and
|
|
185
|
+
PaymentError for a non-positive amount or expiry. Nothing silently
|
|
186
|
+
succeeds twice.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
rail_id: str = ''
|
|
190
|
+
|
|
191
|
+
def __init__(self) -> None:
|
|
192
|
+
self.escrows: dict[str, HoldInvoice] = {}
|
|
193
|
+
self._lock: threading.RLock = threading.RLock()
|
|
194
|
+
self._refusals: Counter[str] = Counter()
|
|
195
|
+
|
|
196
|
+
# -- the table -----------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def invoices(self) -> dict[str, HoldInvoice]:
|
|
200
|
+
"""The escrow table under its Lightning-era name (`rail.invoices`)."""
|
|
201
|
+
return self.escrows
|
|
202
|
+
|
|
203
|
+
def _new_invoice(self, amount_msat: int, payment_hash: str, expiry: int, description: str) -> HoldInvoice:
|
|
204
|
+
"""A CREATED escrow for this rail, not yet registered. Refuses a
|
|
205
|
+
non-positive amount or expiry and a hash whose escrow is in flight -
|
|
206
|
+
a second CREATED over a HELD one would be a way to lose the hold."""
|
|
207
|
+
h = payment_hash.lower()
|
|
208
|
+
if amount_msat <= 0:
|
|
209
|
+
raise PaymentError(f'escrow {h[:16]}: amount_msat must be positive, got {amount_msat}')
|
|
210
|
+
if expiry <= 0:
|
|
211
|
+
raise PaymentError(f'escrow {h[:16]}: expiry must be positive, got {expiry}')
|
|
212
|
+
with self._lock:
|
|
213
|
+
prior = self.escrows.get(h)
|
|
214
|
+
if prior is not None:
|
|
215
|
+
raise IllegalTransition(h, prior.status, 'CREATED', 'an escrow for this hash already exists')
|
|
216
|
+
return HoldInvoice(rail=self.rail_id, payment_hash=h, amount_msat=amount_msat, expiry=expiry,
|
|
217
|
+
created_at=int(time.time()), description=description)
|
|
218
|
+
|
|
219
|
+
def _register(self, inv: HoldInvoice) -> HoldInvoice:
|
|
220
|
+
with self._lock:
|
|
221
|
+
prior = self.escrows.get(inv.payment_hash)
|
|
222
|
+
if prior is not None:
|
|
223
|
+
raise IllegalTransition(inv.payment_hash, prior.status, 'CREATED', 'an escrow for this hash already exists')
|
|
224
|
+
self.escrows[inv.payment_hash] = inv
|
|
225
|
+
return inv
|
|
226
|
+
|
|
227
|
+
def _escrow_for(self, payment_hash: str, to_state: str) -> HoldInvoice:
|
|
228
|
+
"""The escrow for a hash, or IllegalTransition(UNKNOWN -> to_state)."""
|
|
229
|
+
inv = self.escrows.get(payment_hash.lower())
|
|
230
|
+
if inv is None:
|
|
231
|
+
raise IllegalTransition(payment_hash.lower(), 'UNKNOWN', to_state, 'no escrow for this hash')
|
|
232
|
+
return inv
|
|
233
|
+
|
|
234
|
+
# -- the state machine ---------------------------------------------------
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def is_expired(inv: HoldInvoice, now: Optional[int] = None) -> bool:
|
|
238
|
+
"""A CREATED escrow nobody paid within its expiry. HELD and the
|
|
239
|
+
terminal states do not expire: money already locked is settled or
|
|
240
|
+
cancelled explicitly."""
|
|
241
|
+
if inv.status != 'CREATED' or not inv.created_at:
|
|
242
|
+
return False
|
|
243
|
+
return (now if now is not None else int(time.time())) >= inv.created_at + inv.expiry
|
|
244
|
+
|
|
245
|
+
def _check_transition(self, inv: HoldInvoice, from_states: Sequence[str], to_state: str) -> None:
|
|
246
|
+
"""Raise IllegalTransition unless inv may go to to_state from here."""
|
|
247
|
+
if inv.status not in from_states or to_state not in TRANSITIONS.get(inv.status, frozenset()):
|
|
248
|
+
raise IllegalTransition(inv.payment_hash, inv.status, to_state)
|
|
249
|
+
if to_state == 'HELD' and self.is_expired(inv):
|
|
250
|
+
raise IllegalTransition(inv.payment_hash, inv.status, to_state, 'invoice expired')
|
|
251
|
+
|
|
252
|
+
def _transition(self, inv: HoldInvoice, from_states: Sequence[str], to_state: str) -> HoldInvoice:
|
|
253
|
+
"""Move inv to to_state, stamping the matching *_at field. Caller holds the lock."""
|
|
254
|
+
self._check_transition(inv, from_states, to_state)
|
|
255
|
+
now = int(time.time())
|
|
256
|
+
inv.status = to_state
|
|
257
|
+
if to_state == 'HELD':
|
|
258
|
+
inv.held_at = now
|
|
259
|
+
elif to_state == 'SETTLED':
|
|
260
|
+
inv.settled_at = now
|
|
261
|
+
elif to_state in ('CANCELLED', 'FAILED'):
|
|
262
|
+
inv.cancelled_at = now
|
|
263
|
+
return inv
|
|
264
|
+
|
|
265
|
+
def _refuse(self, action: str, err: PaymentError) -> bool:
|
|
266
|
+
self._refusals[action] += 1
|
|
267
|
+
log.info('%s rail refused %s: %s', self.rail_id, action, err)
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
# -- rail-specific I/O hooks ---------------------------------------------
|
|
271
|
+
|
|
272
|
+
def _hold_io(self, inv: HoldInvoice, token_or_proof: Any) -> None:
|
|
273
|
+
"""Verify that token_or_proof pays inv; raise PaymentError if not.
|
|
274
|
+
The default rail takes the hold on the operator's word (Lightning
|
|
275
|
+
hold invoice already accepted by the node, mock)."""
|
|
276
|
+
|
|
277
|
+
def _settle_io(self, inv: HoldInvoice, preimage: str) -> None:
|
|
278
|
+
"""Spend/forward the held money; raise PaymentError to keep it HELD."""
|
|
279
|
+
|
|
280
|
+
# -- the public verbs ----------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def create_hold_invoice(
|
|
283
|
+
self,
|
|
284
|
+
amount_msat: int,
|
|
285
|
+
payment_hash: str,
|
|
286
|
+
expiry: int = 3600,
|
|
287
|
+
description: str = '',
|
|
288
|
+
) -> HoldInvoice:
|
|
289
|
+
"""Create a conditional hold invoice keyed to payment_hash."""
|
|
290
|
+
return self._register(self._new_invoice(amount_msat, payment_hash, expiry, description))
|
|
291
|
+
|
|
292
|
+
def hold_payment(self, payment_hash: str, token_or_proof: Any = None) -> bool:
|
|
293
|
+
"""Lock/hold funds in escrow against payment_hash: CREATED -> HELD."""
|
|
294
|
+
with self._lock:
|
|
295
|
+
try:
|
|
296
|
+
inv = self._escrow_for(payment_hash, 'HELD')
|
|
297
|
+
self._check_transition(inv, ('CREATED',), 'HELD')
|
|
298
|
+
self._hold_io(inv, token_or_proof)
|
|
299
|
+
self._transition(inv, ('CREATED',), 'HELD')
|
|
300
|
+
except PaymentError as e:
|
|
301
|
+
return self._refuse('hold', e)
|
|
302
|
+
return True
|
|
303
|
+
|
|
304
|
+
def settle_payment(self, payment_hash: str, preimage: str) -> bool:
|
|
305
|
+
"""Release escrowed funds upon providing the preimage: HELD -> SETTLED."""
|
|
306
|
+
with self._lock:
|
|
307
|
+
try:
|
|
308
|
+
inv = self._escrow_for(payment_hash, 'SETTLED')
|
|
309
|
+
self._check_transition(inv, ('HELD',), 'SETTLED')
|
|
310
|
+
if not preimage_matches(preimage, inv.payment_hash):
|
|
311
|
+
raise PaymentError(f'escrow {inv.payment_hash[:16]}: preimage does not hash to payment_hash')
|
|
312
|
+
self._settle_io(inv, preimage)
|
|
313
|
+
self._transition(inv, ('HELD',), 'SETTLED')
|
|
314
|
+
inv.preimage = preimage
|
|
315
|
+
except PaymentError as e:
|
|
316
|
+
return self._refuse('settle', e)
|
|
317
|
+
return True
|
|
318
|
+
|
|
319
|
+
def cancel_payment(self, payment_hash: str, reason: str = '') -> bool:
|
|
320
|
+
"""Cancel the escrow and release what is locked: CREATED | HELD -> CANCELLED."""
|
|
321
|
+
with self._lock:
|
|
322
|
+
try:
|
|
323
|
+
inv = self._escrow_for(payment_hash, 'CANCELLED')
|
|
324
|
+
self._transition(inv, ('CREATED', 'HELD'), 'CANCELLED')
|
|
325
|
+
inv.cancel_reason = reason
|
|
326
|
+
except PaymentError as e:
|
|
327
|
+
return self._refuse('cancel', e)
|
|
328
|
+
return True
|
|
329
|
+
|
|
330
|
+
def fail_payment(self, payment_hash: str, reason: str = '') -> bool:
|
|
331
|
+
"""A HELD escrow whose settlement I/O is known lost: HELD -> FAILED."""
|
|
332
|
+
with self._lock:
|
|
333
|
+
try:
|
|
334
|
+
inv = self._escrow_for(payment_hash, 'FAILED')
|
|
335
|
+
self._transition(inv, ('HELD',), 'FAILED')
|
|
336
|
+
inv.cancel_reason = reason
|
|
337
|
+
except PaymentError as e:
|
|
338
|
+
return self._refuse('fail', e)
|
|
339
|
+
return True
|
|
340
|
+
|
|
341
|
+
def get_status(self, payment_hash: str) -> HoldInvoice:
|
|
342
|
+
"""The escrow as it stands: CREATED, HELD, SETTLED, CANCELLED, FAILED; UNKNOWN_INVOICE for a hash never seen."""
|
|
343
|
+
return self.escrows.get(payment_hash.lower(), UNKNOWN_INVOICE)
|
|
344
|
+
|
|
345
|
+
def stats(self) -> PaymentRailStats:
|
|
346
|
+
"""Escrow counts and msat by state, plus refusals by action."""
|
|
347
|
+
with self._lock:
|
|
348
|
+
by_state: Counter[str] = Counter()
|
|
349
|
+
msat: Counter[str] = Counter()
|
|
350
|
+
for inv in self.escrows.values():
|
|
351
|
+
by_state[inv.status] += 1
|
|
352
|
+
msat[inv.status] += inv.amount_msat
|
|
353
|
+
return {
|
|
354
|
+
'rail': self.rail_id,
|
|
355
|
+
'escrows': len(self.escrows),
|
|
356
|
+
'by_state': dict(by_state),
|
|
357
|
+
'msat_by_state': dict(msat),
|
|
358
|
+
'refusals': dict(self._refusals),
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class LightningHoldPaymentRail(PaymentRail):
|
|
363
|
+
"""Native Lightning hold-invoice rail. Its only I/O is minting the bolt11
|
|
364
|
+
for the escrow; the node's own LN backend accepts the HTLC."""
|
|
365
|
+
|
|
366
|
+
rail_id: str = 'lightning'
|
|
367
|
+
|
|
368
|
+
def create_hold_invoice(
|
|
369
|
+
self,
|
|
370
|
+
amount_msat: int,
|
|
371
|
+
payment_hash: str,
|
|
372
|
+
expiry: int = 3600,
|
|
373
|
+
description: str = 'fleetmesh-compute',
|
|
374
|
+
) -> HoldInvoice:
|
|
375
|
+
inv = self._new_invoice(amount_msat, payment_hash, expiry, description)
|
|
376
|
+
inv.bolt11 = f"lnbc{amount_msat}u1p{inv.payment_hash[:32]}"
|
|
377
|
+
return self._register(inv)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class CashuPaymentRail(PaymentRail):
|
|
381
|
+
"""Chaumian ecash settlement rail implementing NIP-61 Nutzap tokens."""
|
|
382
|
+
|
|
383
|
+
rail_id: str = 'cashu'
|
|
384
|
+
|
|
385
|
+
def __init__(
|
|
386
|
+
self,
|
|
387
|
+
mint_url: str = DEFAULT_MINT_URL,
|
|
388
|
+
mint_privkey_hex: Optional[str] = None,
|
|
389
|
+
spent: Optional[SpentProofs] = None,
|
|
390
|
+
proof_verifier: Optional[ProofVerifier] = None,
|
|
391
|
+
allow_unverified_proofs: bool = False,
|
|
392
|
+
) -> None:
|
|
393
|
+
super().__init__()
|
|
394
|
+
self.mint_url: str = mint_url
|
|
395
|
+
self.mint_privkey_hex: Optional[str] = mint_privkey_hex
|
|
396
|
+
self.mint_pubkey_hex: Optional[str] = None
|
|
397
|
+
if mint_privkey_hex:
|
|
398
|
+
mint_point = curve.mul_g(int(mint_privkey_hex, 16))
|
|
399
|
+
# mul_g returns None only for k == 0 mod N, which is not a usable
|
|
400
|
+
# signing key; refuse it loudly instead of running keyless.
|
|
401
|
+
if mint_point is None:
|
|
402
|
+
raise ValueError('mint private key is zero mod N')
|
|
403
|
+
self.mint_pubkey_hex = point_to_hex(mint_point)
|
|
404
|
+
# One ledger of spent secrets, shared by the escrow path and the
|
|
405
|
+
# grant-expansion path, persistent when the engine hands one in.
|
|
406
|
+
self.spent: SpentProofs = spent if spent is not None else SpentProofs()
|
|
407
|
+
# How proofs are vouched for when this node is not the mint. None with
|
|
408
|
+
# no mint key means every nutzap is refused, which is the safe default;
|
|
409
|
+
# allow_unverified_proofs is the explicit opt-in for a test rail.
|
|
410
|
+
self.proof_verifier: Optional[ProofVerifier] = proof_verifier
|
|
411
|
+
self.allow_unverified_proofs: bool = allow_unverified_proofs
|
|
412
|
+
|
|
413
|
+
def is_spent(self, secret: str) -> bool:
|
|
414
|
+
return self.spent.is_spent(secret)
|
|
415
|
+
|
|
416
|
+
def mark_spent(self, secret: str, keyset_id: Optional[str] = None, amount: Optional[int] = None,
|
|
417
|
+
now: Optional[int] = None) -> None:
|
|
418
|
+
self.spent.mark_spent(secret, keyset_id, amount, now)
|
|
419
|
+
|
|
420
|
+
def verify_nutzap(self, event: NostrEvent | Mapping[str, Any], expected_recipient: Optional[str] = None) -> tuple[bool, str, int]:
|
|
421
|
+
"""`verify_nutzap_event` under this rail's own proof policy."""
|
|
422
|
+
return verify_nutzap_event(event, expected_recipient=expected_recipient,
|
|
423
|
+
mint_privkey_hex=self.mint_privkey_hex,
|
|
424
|
+
proof_verifier=self.proof_verifier,
|
|
425
|
+
allow_unverified_proofs=self.allow_unverified_proofs)
|
|
426
|
+
|
|
427
|
+
def _accept_proofs(self, proofs: Sequence[CashuProof]) -> tuple[bool, str]:
|
|
428
|
+
"""`accept_proofs` under this rail's own proof policy: the same rule
|
|
429
|
+
verify_nutzap applies, for proofs that did not arrive in a nutzap."""
|
|
430
|
+
return accept_proofs(proofs, mint_privkey_hex=self.mint_privkey_hex,
|
|
431
|
+
proof_verifier=self.proof_verifier,
|
|
432
|
+
allow_unverified_proofs=self.allow_unverified_proofs)
|
|
433
|
+
|
|
434
|
+
def can_verify_proofs(self) -> bool:
|
|
435
|
+
"""False when this rail would take any JSON with a `secret` as money."""
|
|
436
|
+
return bool(self.mint_privkey_hex) or self.proof_verifier is not None or self.allow_unverified_proofs
|
|
437
|
+
|
|
438
|
+
def create_hold_invoice(
|
|
439
|
+
self,
|
|
440
|
+
amount_msat: int,
|
|
441
|
+
payment_hash: str,
|
|
442
|
+
expiry: int = 3600,
|
|
443
|
+
description: str = 'fleetmesh-nutzap',
|
|
444
|
+
) -> HoldInvoice:
|
|
445
|
+
inv = self._new_invoice(amount_msat, payment_hash, expiry, description)
|
|
446
|
+
inv.mint = self.mint_url
|
|
447
|
+
return self._register(inv)
|
|
448
|
+
|
|
449
|
+
def _hold_io(self, inv: HoldInvoice, token_or_proof: Any) -> None:
|
|
450
|
+
"""Accept Cashu proofs or a Nutzap event as the money behind inv.
|
|
451
|
+
|
|
452
|
+
Whatever the shape - a cashuA token string, a NIP-61 event or a bare
|
|
453
|
+
proof list - the proofs go through the rail's one proof policy
|
|
454
|
+
(`_accept_proofs`). The string and list shapes used to skip it, so a
|
|
455
|
+
rail with no mint key held any JSON with a `secret` as payment.
|
|
456
|
+
"""
|
|
457
|
+
h = inv.payment_hash
|
|
458
|
+
proofs: list[CashuProof] = []
|
|
459
|
+
policy_applied = False
|
|
460
|
+
if isinstance(token_or_proof, str) and token_or_proof.startswith('cashuA'):
|
|
461
|
+
try:
|
|
462
|
+
tok = deserialize_cashu_token(token_or_proof)
|
|
463
|
+
except (ValueError, TypeError) as e:
|
|
464
|
+
raise PaymentError(f'escrow {h[:16]}: malformed cashu token: {e}') from e
|
|
465
|
+
for t in tok.get('token', []):
|
|
466
|
+
proofs.extend(t.get('proofs', []))
|
|
467
|
+
elif isinstance(token_or_proof, dict) and token_or_proof.get('kind') == NUTZAP_KIND:
|
|
468
|
+
# verify_nutzap applies the proof policy to these itself.
|
|
469
|
+
ok_nut, why, _ = self.verify_nutzap(token_or_proof)
|
|
470
|
+
if not ok_nut:
|
|
471
|
+
raise PaymentError(f'escrow {h[:16]}: nutzap refused: {why}')
|
|
472
|
+
policy_applied = True
|
|
473
|
+
try:
|
|
474
|
+
proofs = proofs_of(token_or_proof)
|
|
475
|
+
except ValueError as e:
|
|
476
|
+
raise PaymentError(f'escrow {h[:16]}: malformed nutzap proofs: {e}') from e
|
|
477
|
+
elif isinstance(token_or_proof, list):
|
|
478
|
+
proofs = list(token_or_proof)
|
|
479
|
+
|
|
480
|
+
if not proofs:
|
|
481
|
+
raise PaymentError(f'escrow {h[:16]}: no proofs offered')
|
|
482
|
+
|
|
483
|
+
# Verify not double spent
|
|
484
|
+
for p in proofs:
|
|
485
|
+
if not isinstance(p, dict):
|
|
486
|
+
raise PaymentError(f'escrow {h[:16]}: proof is not an object')
|
|
487
|
+
sec = p.get('secret')
|
|
488
|
+
if not isinstance(sec, str) or not sec:
|
|
489
|
+
raise PaymentError(f'escrow {h[:16]}: proof has no secret')
|
|
490
|
+
if self.is_spent(sec):
|
|
491
|
+
raise PaymentError(f'escrow {h[:16]}: proof already spent')
|
|
492
|
+
|
|
493
|
+
if not policy_applied:
|
|
494
|
+
ok_proofs, why = self._accept_proofs(proofs)
|
|
495
|
+
if not ok_proofs:
|
|
496
|
+
raise PaymentError(f'escrow {h[:16]}: proofs refused: {why}')
|
|
497
|
+
|
|
498
|
+
inv.proofs = proofs
|
|
499
|
+
|
|
500
|
+
def _settle_io(self, inv: HoldInvoice, preimage: str) -> None:
|
|
501
|
+
"""Mark the held proofs spent; the escrow then goes SETTLED."""
|
|
502
|
+
now = int(time.time())
|
|
503
|
+
for p in inv.proofs:
|
|
504
|
+
if 'secret' in p:
|
|
505
|
+
self.mark_spent(p['secret'], p.get('id'), p.get('amount'), now)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class MockPaymentRail(PaymentRail):
|
|
509
|
+
"""Deterministic in-memory settlement rail for testing: the base state
|
|
510
|
+
machine with no I/O at all."""
|
|
511
|
+
|
|
512
|
+
rail_id: str = 'mock'
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""SQLite persistence for settlement: the escrow ledger and the spent-proof
|
|
2
|
+
table, plus the `SpentProofs` that reads through to it so "has this secret
|
|
3
|
+
paid before" survives a restart."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from . import payment
|
|
9
|
+
from node.compartment.schema import Column, Index, Table
|
|
10
|
+
from node.storage import SqliteStore
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SettlementStore(SqliteStore):
|
|
14
|
+
"""Thread-safe SQLite storage for settlement escrows and spent proofs."""
|
|
15
|
+
|
|
16
|
+
SCHEMA = (
|
|
17
|
+
Table('escrows', (
|
|
18
|
+
Column('ask_id', 'TEXT', primary_key=True),
|
|
19
|
+
Column('buyer', 'TEXT', not_null=True),
|
|
20
|
+
Column('seller', 'TEXT', not_null=True),
|
|
21
|
+
Column('amount_msat', 'INTEGER', not_null=True),
|
|
22
|
+
Column('payment_hash', 'TEXT', not_null=True),
|
|
23
|
+
Column('preimage', 'TEXT'),
|
|
24
|
+
Column('status', 'TEXT', not_null=True, default="'CREATED'"),
|
|
25
|
+
Column('rail', 'TEXT', not_null=True),
|
|
26
|
+
Column('created_at', 'INTEGER', not_null=True),
|
|
27
|
+
Column('settled_at', 'INTEGER'),
|
|
28
|
+
), indices=(
|
|
29
|
+
Index('idx_escrows_hash', ('payment_hash',)),
|
|
30
|
+
)),
|
|
31
|
+
Table('spent_proofs', (
|
|
32
|
+
Column('secret', 'TEXT', primary_key=True),
|
|
33
|
+
Column('keyset_id', 'TEXT'),
|
|
34
|
+
Column('amount', 'INTEGER'),
|
|
35
|
+
Column('spent_at', 'INTEGER', not_null=True),
|
|
36
|
+
)),
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
class PersistentSpentProofs(payment.SpentProofs):
|
|
40
|
+
"""`SpentProofs` read through to the settlement store's spent_proofs table.
|
|
41
|
+
|
|
42
|
+
Loaded whole on init and written through on every mark, so the answer to
|
|
43
|
+
"has this secret paid before" survives a restart - which is the only
|
|
44
|
+
question the table exists to answer, and the one it was never asked.
|
|
45
|
+
The in-memory set is a cache of the table, not a second source of truth:
|
|
46
|
+
a miss in the cache still asks the table, so a second engine opened on
|
|
47
|
+
the same file (or a row written by another process) is honoured.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, db: SettlementStore, clock: Any) -> None:
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.db = db
|
|
53
|
+
self.clock = clock
|
|
54
|
+
for (secret,) in self.db._all('SELECT secret FROM spent_proofs'):
|
|
55
|
+
self._mem.add(secret)
|
|
56
|
+
|
|
57
|
+
def is_spent(self, secret: str) -> bool:
|
|
58
|
+
if secret in self._mem:
|
|
59
|
+
return True
|
|
60
|
+
row = self.db._one('SELECT 1 FROM spent_proofs WHERE secret = ?', (secret,))
|
|
61
|
+
if row:
|
|
62
|
+
self._mem.add(secret)
|
|
63
|
+
return True
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
def mark_spent(self, secret: str, keyset_id: Optional[str] = None,
|
|
67
|
+
amount: Optional[int] = None, now: Optional[int] = None) -> None:
|
|
68
|
+
self.db.execute('''
|
|
69
|
+
INSERT OR IGNORE INTO spent_proofs (secret, keyset_id, amount, spent_at)
|
|
70
|
+
VALUES (?, ?, ?, ?)
|
|
71
|
+
''', (secret, keyset_id, amount, now if now is not None else self.clock()))
|
|
72
|
+
self.db.commit()
|
|
73
|
+
self._mem.add(secret)
|
|
74
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Types and records for the Settlement compartment."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional, TypedDict
|
|
5
|
+
|
|
6
|
+
from fleetmesh_core.accounting.cashu import BlindSignature, CashuProof
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HoldInvoiceDict(TypedDict, total=False):
|
|
10
|
+
"""Normalized dictionary representation of a HoldInvoice record."""
|
|
11
|
+
rail: str
|
|
12
|
+
payment_hash: str
|
|
13
|
+
amount_msat: int
|
|
14
|
+
status: str
|
|
15
|
+
created_at: int
|
|
16
|
+
expiry: int
|
|
17
|
+
description: str
|
|
18
|
+
preimage: Optional[str]
|
|
19
|
+
bolt11: Optional[str]
|
|
20
|
+
mint: Optional[str]
|
|
21
|
+
invoice: Optional[str]
|
|
22
|
+
wallet_payment_hash: Optional[str]
|
|
23
|
+
proofs: list[CashuProof]
|
|
24
|
+
held_at: Optional[int]
|
|
25
|
+
settled_at: Optional[int]
|
|
26
|
+
cancelled_at: Optional[int]
|
|
27
|
+
cancel_reason: Optional[str]
|
|
28
|
+
swapped_at: Optional[int]
|
|
29
|
+
new_signatures: list[BlindSignature]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PaymentRailStats(TypedDict):
|
|
33
|
+
"""Aggregate statistics for an escrow settlement rail."""
|
|
34
|
+
rail: str
|
|
35
|
+
escrows: int
|
|
36
|
+
by_state: dict[str, int]
|
|
37
|
+
msat_by_state: dict[str, int]
|
|
38
|
+
refusals: dict[str, int]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EscrowRowDict(TypedDict, total=False):
|
|
42
|
+
"""Row from the escrows database table."""
|
|
43
|
+
ask_id: str
|
|
44
|
+
buyer: str
|
|
45
|
+
seller: str
|
|
46
|
+
amount_msat: int
|
|
47
|
+
payment_hash: str
|
|
48
|
+
preimage: Optional[str]
|
|
49
|
+
status: str
|
|
50
|
+
rail: str
|
|
51
|
+
created_at: int
|
|
52
|
+
settled_at: Optional[int]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SettleShowDict(TypedDict):
|
|
56
|
+
"""Summary of active settlement rails and current held escrows."""
|
|
57
|
+
rails: list[str]
|
|
58
|
+
escrows: list[EscrowRowDict]
|