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,541 @@
|
|
|
1
|
+
"""Live Payment Gateways: NIP-47 (Nostr Wallet Connect / NWC) & Cashu HTTP Mint Client.
|
|
2
|
+
|
|
3
|
+
Integrates real external settlement rails with MeshNode:
|
|
4
|
+
1. NIP-47 Nostr Wallet Connect (NWC):
|
|
5
|
+
- URI parser: nostr+walletconnect://<wallet_pubkey>?relay=<url>&secret=<secret_hex>&lud16=<lud16>
|
|
6
|
+
- Dual-mode encryption: NIP-04 (AES-256-CBC) and NIP-44 v2 (ChaCha20-Poly1305).
|
|
7
|
+
- Methods: pay_invoice, make_invoice, lookup_invoice, get_balance.
|
|
8
|
+
- `NWCLightningPaymentRail`: Implements PaymentRail for automated compute settlement via Lightning.
|
|
9
|
+
2. Cashu Mint HTTP Client (NUT-00, NUT-03, NUT-04, NUT-05):
|
|
10
|
+
- Standard library urllib client for v1 Cashu mint REST API.
|
|
11
|
+
- Quote generation, token minting, token melting (ecash-to-Lightning), and token swapping (NUT-03).
|
|
12
|
+
- `LiveCashuPaymentRail`: Extends CashuPaymentRail with live mint validation and atomic swaps.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from fleetmesh_core.wire import spelling # noqa: E402
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import threading
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, Mapping, Optional, Sequence, TypedDict, cast
|
|
24
|
+
from urllib.parse import parse_qs, urlparse
|
|
25
|
+
|
|
26
|
+
from cryptography.hazmat.primitives import padding
|
|
27
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
28
|
+
|
|
29
|
+
from fleetmesh_core.accounting.cashu import (
|
|
30
|
+
BlindedMessage, CashuProof, CheckStateResult,
|
|
31
|
+
MeltQuoteResult, MintQuoteResult, SwapResult,
|
|
32
|
+
hash_to_curve, point_to_hex,
|
|
33
|
+
)
|
|
34
|
+
from fleetmesh_core.crypto import crypto, curve, nip44
|
|
35
|
+
from fleetmesh_core.storage import store as store_mod
|
|
36
|
+
from fleetmesh_core.wire.interfaces import NostrEvent
|
|
37
|
+
from . import payment
|
|
38
|
+
from node.transport import netfetch
|
|
39
|
+
from node.protocols.nostr import client as relay
|
|
40
|
+
|
|
41
|
+
DEFAULT_MINT_URL: str = 'https://mint.example.org'
|
|
42
|
+
DEFAULT_RELAY_URL: str = 'wss://relay.example.org'
|
|
43
|
+
# A Cashu mint reply is JSON of a few KiB; 4 MiB leaves room for large
|
|
44
|
+
# keyset listings while bounding what a broken mint can push at us.
|
|
45
|
+
MINT_MAX_RESPONSE_BYTES: int = 4 * 1024 * 1024
|
|
46
|
+
|
|
47
|
+
# NIP-47 Kind Numbers
|
|
48
|
+
NWC_REQUEST_KIND: int = 23194
|
|
49
|
+
NWC_RESPONSE_KIND: int = 23195
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NwcPayResult(TypedDict, total=False):
|
|
53
|
+
preimage: str
|
|
54
|
+
fees_paid: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class NwcMakeInvoiceResult(TypedDict, total=False):
|
|
58
|
+
invoice: str
|
|
59
|
+
payment_hash: str
|
|
60
|
+
amount: int
|
|
61
|
+
expiry: int
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NwcLookupInvoiceResult(TypedDict, total=False):
|
|
65
|
+
type: str
|
|
66
|
+
invoice: str
|
|
67
|
+
description: str
|
|
68
|
+
description_hash: Optional[str]
|
|
69
|
+
preimage: Optional[str]
|
|
70
|
+
payment_hash: str
|
|
71
|
+
amount: int
|
|
72
|
+
fees_paid: int
|
|
73
|
+
created_at: int
|
|
74
|
+
expires_at: int
|
|
75
|
+
settled_at: Optional[int]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NwcBalanceResult(TypedDict, total=False):
|
|
79
|
+
balance: int
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class MintKeyset(TypedDict, total=False):
|
|
83
|
+
id: str
|
|
84
|
+
unit: str
|
|
85
|
+
active: bool
|
|
86
|
+
keys: dict[str, str]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class MintKeysResult(TypedDict, total=False):
|
|
90
|
+
keysets: list[MintKeyset]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class MintInfoResult(TypedDict, total=False):
|
|
94
|
+
name: str
|
|
95
|
+
pubkey: str
|
|
96
|
+
version: str
|
|
97
|
+
description: str
|
|
98
|
+
description_long: str
|
|
99
|
+
contact: list[list[str]]
|
|
100
|
+
nuts: Mapping[str, Any]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NwcPayParams(TypedDict, total=False):
|
|
104
|
+
invoice: str
|
|
105
|
+
amount: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class NwcLookupParams(TypedDict, total=False):
|
|
109
|
+
payment_hash: str
|
|
110
|
+
invoice: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# NIP-04 / NIP-44 Encryption Utilities
|
|
115
|
+
# ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
def nip04_encrypt(plaintext: str | bytes, sec_hex: str, pub_hex: str) -> str:
|
|
118
|
+
"""Encrypt payload using standard NIP-04 (AES-256-CBC with PKCS#7 padding)."""
|
|
119
|
+
shared_x = bytes.fromhex(curve.ecdh_x(sec_hex, pub_hex))
|
|
120
|
+
iv = os.urandom(16)
|
|
121
|
+
data = plaintext.encode('utf-8') if isinstance(plaintext, str) else plaintext
|
|
122
|
+
|
|
123
|
+
padder = padding.PKCS7(128).padder()
|
|
124
|
+
padded_data = padder.update(data) + padder.finalize()
|
|
125
|
+
|
|
126
|
+
cipher = Cipher(algorithms.AES(shared_x), modes.CBC(iv))
|
|
127
|
+
encryptor = cipher.encryptor()
|
|
128
|
+
ct = encryptor.update(padded_data) + encryptor.finalize()
|
|
129
|
+
|
|
130
|
+
ct_b64 = base64.b64encode(ct).decode('ascii')
|
|
131
|
+
iv_b64 = base64.b64encode(iv).decode('ascii')
|
|
132
|
+
return f"{ct_b64}?iv={iv_b64}"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def nip04_decrypt(payload: str, sec_hex: str, pub_hex: str) -> str:
|
|
136
|
+
"""Decrypt payload using standard NIP-04 (AES-256-CBC with PKCS#7 unpadding)."""
|
|
137
|
+
parts = payload.split('?iv=')
|
|
138
|
+
if len(parts) != 2:
|
|
139
|
+
raise ValueError("invalid NIP-04 payload: missing ?iv= delimiter")
|
|
140
|
+
|
|
141
|
+
ct = base64.b64decode(parts[0])
|
|
142
|
+
iv = base64.b64decode(parts[1])
|
|
143
|
+
|
|
144
|
+
shared_x = bytes.fromhex(curve.ecdh_x(sec_hex, pub_hex))
|
|
145
|
+
cipher = Cipher(algorithms.AES(shared_x), modes.CBC(iv))
|
|
146
|
+
decryptor = cipher.decryptor()
|
|
147
|
+
padded_data = decryptor.update(ct) + decryptor.finalize()
|
|
148
|
+
|
|
149
|
+
unpadder = padding.PKCS7(128).unpadder()
|
|
150
|
+
data = unpadder.update(padded_data) + unpadder.finalize()
|
|
151
|
+
return data.decode('utf-8')
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# NIP-47 (NWC) Client
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
class NWCError(Exception):
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class NWCUri:
|
|
163
|
+
"""NIP-47 nostr+walletconnect:// URI representation."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, uri: str) -> None:
|
|
166
|
+
raw = uri.strip()
|
|
167
|
+
if raw.startswith('nostrwalletconnect://'):
|
|
168
|
+
raw = 'nostr+walletconnect://' + raw[len('nostrwalletconnect://'):]
|
|
169
|
+
if not raw.startswith('nostr+walletconnect://'):
|
|
170
|
+
raise ValueError("URI must begin with nostr+walletconnect://")
|
|
171
|
+
|
|
172
|
+
parsed = urlparse(raw)
|
|
173
|
+
self.wallet_pubkey: str = (parsed.netloc or parsed.path.lstrip('/')).lower()
|
|
174
|
+
if not spelling.is_hex(self.wallet_pubkey, 64):
|
|
175
|
+
raise ValueError(f"invalid 64-hex wallet pubkey: {self.wallet_pubkey}")
|
|
176
|
+
|
|
177
|
+
qs = parse_qs(parsed.query)
|
|
178
|
+
self.relay: str = qs.get('relay', [''])[0]
|
|
179
|
+
self.secret: str = qs.get('secret', [''])[0].lower()
|
|
180
|
+
self.lud16: str = qs.get('lud16', [''])[0]
|
|
181
|
+
|
|
182
|
+
if not spelling.is_hex(self.secret, 64):
|
|
183
|
+
raise ValueError("secret must be a 64-hex secp256k1 private key")
|
|
184
|
+
|
|
185
|
+
self.client_pubkey: str = crypto.pubkey(self.secret)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class NWCClient:
|
|
189
|
+
"""NIP-47 Nostr Wallet Connect client."""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
uri: str,
|
|
194
|
+
timeout: float = 10.0,
|
|
195
|
+
encryption_mode: str = 'nip04', # 'nip04' or 'nip44'
|
|
196
|
+
) -> None:
|
|
197
|
+
self.uri_info: NWCUri = NWCUri(uri)
|
|
198
|
+
self.timeout: float = timeout
|
|
199
|
+
self.encryption_mode: str = encryption_mode
|
|
200
|
+
self._lock: threading.RLock = threading.RLock()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def wallet_pubkey(self) -> str:
|
|
204
|
+
return self.uri_info.wallet_pubkey
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def client_pubkey(self) -> str:
|
|
208
|
+
return self.uri_info.client_pubkey
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def secret(self) -> str:
|
|
212
|
+
return self.uri_info.secret
|
|
213
|
+
|
|
214
|
+
@property
|
|
215
|
+
def relay_url(self) -> str:
|
|
216
|
+
return self.uri_info.relay
|
|
217
|
+
|
|
218
|
+
def _encrypt(self, plaintext: str) -> str:
|
|
219
|
+
if self.encryption_mode == 'nip44':
|
|
220
|
+
conv = nip44.conversation_key(self.secret, self.wallet_pubkey)
|
|
221
|
+
return nip44.encrypt(plaintext, conv)
|
|
222
|
+
return nip04_encrypt(plaintext, self.secret, self.wallet_pubkey)
|
|
223
|
+
|
|
224
|
+
def _decrypt(self, ciphertext: str) -> str:
|
|
225
|
+
if self.encryption_mode == 'nip44':
|
|
226
|
+
conv = nip44.conversation_key(self.secret, self.wallet_pubkey)
|
|
227
|
+
return nip44.decrypt(ciphertext, conv)
|
|
228
|
+
return nip04_decrypt(ciphertext, self.secret, self.wallet_pubkey)
|
|
229
|
+
|
|
230
|
+
def call_method(self, method: str, params: Optional[Mapping[str, Any]] = None) -> Mapping[str, Any]:
|
|
231
|
+
"""Send NIP-47 request (Kind 23194) and wait for response (Kind 23195)."""
|
|
232
|
+
req_payload = {
|
|
233
|
+
'method': method,
|
|
234
|
+
'params': params or {},
|
|
235
|
+
}
|
|
236
|
+
json_body = json.dumps(req_payload, separators=(',', ':'))
|
|
237
|
+
ciphertext = self._encrypt(json_body)
|
|
238
|
+
|
|
239
|
+
now = int(time.time())
|
|
240
|
+
tags = [['p', self.wallet_pubkey]]
|
|
241
|
+
req_event = store_mod.make_signed_event(
|
|
242
|
+
self.secret,
|
|
243
|
+
now,
|
|
244
|
+
NWC_REQUEST_KIND,
|
|
245
|
+
tags,
|
|
246
|
+
ciphertext,
|
|
247
|
+
)
|
|
248
|
+
req_id = req_event['id']
|
|
249
|
+
|
|
250
|
+
if not self.relay_url:
|
|
251
|
+
raise NWCError("no relay URL configured in NWC URI")
|
|
252
|
+
|
|
253
|
+
# Publish request to relay and listen for response
|
|
254
|
+
r = relay.Relay(self.relay_url, timeout=self.timeout)
|
|
255
|
+
try:
|
|
256
|
+
r.connect()
|
|
257
|
+
|
|
258
|
+
# Subscribe to response first so ephemeral response is never missed
|
|
259
|
+
f = {
|
|
260
|
+
'kinds': [NWC_RESPONSE_KIND],
|
|
261
|
+
'authors': [self.wallet_pubkey],
|
|
262
|
+
'#p': [self.client_pubkey],
|
|
263
|
+
'#e': [req_id],
|
|
264
|
+
}
|
|
265
|
+
sub_id = r.subscribe([f], ready=True)
|
|
266
|
+
|
|
267
|
+
# Publish request to relay
|
|
268
|
+
r.publish(req_event)
|
|
269
|
+
|
|
270
|
+
resp_event: Optional[NostrEvent] = None
|
|
271
|
+
deadline = time.time() + self.timeout
|
|
272
|
+
|
|
273
|
+
for ev in r.events(sub_id, until=deadline):
|
|
274
|
+
if ev.get('kind') == NWC_RESPONSE_KIND:
|
|
275
|
+
tags_map = {t[0]: t[1] for t in ev.get('tags', []) if len(t) > 1}
|
|
276
|
+
if tags_map.get('e') == req_id:
|
|
277
|
+
resp_event = ev
|
|
278
|
+
break
|
|
279
|
+
|
|
280
|
+
if resp_event is None:
|
|
281
|
+
raise NWCError(f"timeout waiting for NWC response (method {method!r})")
|
|
282
|
+
|
|
283
|
+
# Decrypt response content
|
|
284
|
+
raw_resp = self._decrypt(resp_event.get('content', ''))
|
|
285
|
+
resp_data = json.loads(raw_resp)
|
|
286
|
+
|
|
287
|
+
if resp_data.get('error'):
|
|
288
|
+
err = resp_data['error']
|
|
289
|
+
msg_err = err.get('message') or err.get('code') or str(err)
|
|
290
|
+
raise NWCError(f"NWC error: {msg_err}")
|
|
291
|
+
|
|
292
|
+
return resp_data.get('result', {})
|
|
293
|
+
|
|
294
|
+
finally:
|
|
295
|
+
try:
|
|
296
|
+
r.close()
|
|
297
|
+
except OSError:
|
|
298
|
+
pass # best-effort close of a relay socket that may already be gone
|
|
299
|
+
|
|
300
|
+
def pay_invoice(self, invoice: str, amount_msat: Optional[int] = None) -> NwcPayResult:
|
|
301
|
+
"""Pay a Bolt11 Lightning invoice via NWC. Returns {'preimage': '...', 'fees_paid': ...}."""
|
|
302
|
+
params: NwcPayParams = {'invoice': invoice}
|
|
303
|
+
if amount_msat:
|
|
304
|
+
params['amount'] = amount_msat
|
|
305
|
+
return cast(NwcPayResult, self.call_method('pay_invoice', params))
|
|
306
|
+
|
|
307
|
+
def make_invoice(self, amount_msat: int, description: str = '', expiry: int = 3600) -> NwcMakeInvoiceResult:
|
|
308
|
+
"""Generate a Bolt11 Lightning invoice via NWC. Returns {'invoice': '...', 'payment_hash': '...'}."""
|
|
309
|
+
params = {
|
|
310
|
+
'amount': amount_msat,
|
|
311
|
+
'description': description,
|
|
312
|
+
'expiry': expiry,
|
|
313
|
+
}
|
|
314
|
+
return cast(NwcMakeInvoiceResult, self.call_method('make_invoice', params))
|
|
315
|
+
|
|
316
|
+
def lookup_invoice(self, payment_hash: Optional[str] = None, invoice: Optional[str] = None) -> NwcLookupInvoiceResult:
|
|
317
|
+
"""Check status of invoice via payment_hash or bolt11 string."""
|
|
318
|
+
params: NwcLookupParams = {}
|
|
319
|
+
if payment_hash:
|
|
320
|
+
params['payment_hash'] = payment_hash
|
|
321
|
+
if invoice:
|
|
322
|
+
params['invoice'] = invoice
|
|
323
|
+
return cast(NwcLookupInvoiceResult, self.call_method('lookup_invoice', params))
|
|
324
|
+
|
|
325
|
+
def get_balance(self) -> NwcBalanceResult:
|
|
326
|
+
"""Query wallet balance in msats. Returns {'balance': int}."""
|
|
327
|
+
return cast(NwcBalanceResult, self.call_method('get_balance'))
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# NWC Payment Rail Adapter
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
class NWCLightningPaymentRail(payment.PaymentRail):
|
|
335
|
+
"""PaymentRail adapter backed by a live Nostr Wallet Connect (NWC) gateway.
|
|
336
|
+
The escrow state machine is the base class's; this rail's own I/O is
|
|
337
|
+
asking the wallet for an invoice and paying out over NWC."""
|
|
338
|
+
|
|
339
|
+
rail_id: str = 'nwc'
|
|
340
|
+
|
|
341
|
+
def __init__(self, nwc_client: NWCClient) -> None:
|
|
342
|
+
super().__init__()
|
|
343
|
+
self.client: NWCClient = nwc_client
|
|
344
|
+
|
|
345
|
+
def create_hold_invoice(
|
|
346
|
+
self,
|
|
347
|
+
amount_msat: int,
|
|
348
|
+
payment_hash: str,
|
|
349
|
+
expiry: int = 3600,
|
|
350
|
+
description: str = 'fleetmesh-nwc-compute',
|
|
351
|
+
) -> payment.HoldInvoice:
|
|
352
|
+
"""Request a wallet invoice and register the hold escrow keyed to payment_hash.
|
|
353
|
+
|
|
354
|
+
A wallet failure raises NWCError and registers nothing: an escrow
|
|
355
|
+
nobody can pay must not sit in CREATED looking like one awaiting
|
|
356
|
+
payment (the old code swallowed the error and stored invoice=None).
|
|
357
|
+
"""
|
|
358
|
+
inv = self._new_invoice(amount_msat, payment_hash, expiry, description)
|
|
359
|
+
try:
|
|
360
|
+
res = self.client.make_invoice(amount_msat=amount_msat, description=description, expiry=expiry)
|
|
361
|
+
except NWCError:
|
|
362
|
+
raise
|
|
363
|
+
except Exception as e:
|
|
364
|
+
raise NWCError(f'make_invoice failed: {e}') from e
|
|
365
|
+
if not res.get('invoice'):
|
|
366
|
+
raise NWCError('make_invoice returned no invoice')
|
|
367
|
+
inv.invoice = res.get('invoice')
|
|
368
|
+
if res.get('payment_hash'):
|
|
369
|
+
inv.wallet_payment_hash = res['payment_hash'].lower()
|
|
370
|
+
return self._register(inv)
|
|
371
|
+
|
|
372
|
+
def pay_and_settle(self, payment_hash: str, bolt11: str) -> NwcPayResult:
|
|
373
|
+
"""Trigger outbound Lightning payment via NWC and unlock escrow with returned preimage."""
|
|
374
|
+
h = payment_hash.lower()
|
|
375
|
+
res = self.client.pay_invoice(bolt11)
|
|
376
|
+
preimage = res.get('preimage', '')
|
|
377
|
+
if preimage:
|
|
378
|
+
self.settle_payment(h, preimage)
|
|
379
|
+
return res
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ---------------------------------------------------------------------------
|
|
383
|
+
# Cashu HTTP Mint REST Client (NUT-00, NUT-03, NUT-04, NUT-05)
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
|
|
386
|
+
class CashuMintError(Exception):
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class CashuHttpClient:
|
|
391
|
+
"""REST Client for standard Cashu mints (v1 API)."""
|
|
392
|
+
|
|
393
|
+
def __init__(self, mint_url: str = DEFAULT_MINT_URL, timeout: float = 10.0) -> None:
|
|
394
|
+
self.mint_url: str = mint_url.rstrip('/')
|
|
395
|
+
self.timeout: float = timeout
|
|
396
|
+
|
|
397
|
+
def _request(self, method: str, endpoint: str, body: Optional[Mapping[str, Any]] = None) -> Mapping[str, Any]:
|
|
398
|
+
url = f"{self.mint_url}{endpoint}"
|
|
399
|
+
data = json.dumps(body).encode('utf-8') if body is not None else None
|
|
400
|
+
headers = {'Content-Type': 'application/json'} if body is not None else {}
|
|
401
|
+
|
|
402
|
+
# The mint URL is operator configuration, never taken from a peer or
|
|
403
|
+
# a job, so the address policy is off (allow_private=True): many
|
|
404
|
+
# operators run their own mint on loopback or a LAN box. The byte cap
|
|
405
|
+
# and scheme check still apply -- a mint answer is small JSON.
|
|
406
|
+
try:
|
|
407
|
+
resp_bytes, _ = netfetch.fetch(
|
|
408
|
+
url,
|
|
409
|
+
max_bytes=MINT_MAX_RESPONSE_BYTES,
|
|
410
|
+
timeout=self.timeout,
|
|
411
|
+
method=method,
|
|
412
|
+
data=data,
|
|
413
|
+
headers=headers,
|
|
414
|
+
allow_private=True,
|
|
415
|
+
)
|
|
416
|
+
except netfetch.FetchHTTPError as e:
|
|
417
|
+
err_text = e.body.decode('utf-8', errors='replace')
|
|
418
|
+
try:
|
|
419
|
+
err_json = json.loads(err_text)
|
|
420
|
+
detail = err_json.get('detail') or err_json.get('error') or err_text
|
|
421
|
+
except Exception:
|
|
422
|
+
detail = err_text
|
|
423
|
+
raise CashuMintError(f"mint HTTP {e.code}: {detail}")
|
|
424
|
+
except netfetch.FetchError as e:
|
|
425
|
+
raise CashuMintError(f"mint connection error: {e}")
|
|
426
|
+
if not resp_bytes:
|
|
427
|
+
return {}
|
|
428
|
+
try:
|
|
429
|
+
return json.loads(resp_bytes.decode('utf-8'))
|
|
430
|
+
except (ValueError, UnicodeDecodeError) as e:
|
|
431
|
+
raise CashuMintError(f"mint returned malformed JSON: {e}")
|
|
432
|
+
|
|
433
|
+
def get_info(self) -> MintInfoResult:
|
|
434
|
+
"""GET /v1/info: Mint capabilities and supported NUTs."""
|
|
435
|
+
return cast(MintInfoResult, self._request('GET', '/v1/info'))
|
|
436
|
+
|
|
437
|
+
def get_keys(self) -> MintKeysResult:
|
|
438
|
+
"""GET /v1/keys: Mint active keysets."""
|
|
439
|
+
return cast(MintKeysResult, self._request('GET', '/v1/keys'))
|
|
440
|
+
|
|
441
|
+
def request_mint_quote(self, amount_sat: int, unit: str = 'sat') -> MintQuoteResult:
|
|
442
|
+
"""POST /v1/mint/quote/bolt11: Request quote to deposit satoshis."""
|
|
443
|
+
return cast(MintQuoteResult, self._request('POST', '/v1/mint/quote/bolt11', {'amount': amount_sat, 'unit': unit}))
|
|
444
|
+
|
|
445
|
+
def check_mint_quote(self, quote_id: str) -> MintQuoteResult:
|
|
446
|
+
"""GET /v1/mint/quote/bolt11/{quote_id}: Check if quote was paid."""
|
|
447
|
+
return cast(MintQuoteResult, self._request('GET', f"/v1/mint/quote/bolt11/{quote_id}"))
|
|
448
|
+
|
|
449
|
+
def mint_tokens(self, quote_id: str, outputs: Sequence[BlindedMessage]) -> SwapResult:
|
|
450
|
+
"""POST /v1/mint/bolt11: Issue blinded signatures after quote payment."""
|
|
451
|
+
return cast(SwapResult, self._request('POST', '/v1/mint/bolt11', {'quote': quote_id, 'outputs': list(outputs)}))
|
|
452
|
+
|
|
453
|
+
def request_melt_quote(self, invoice: str, unit: str = 'sat') -> MeltQuoteResult:
|
|
454
|
+
"""POST /v1/melt/quote/bolt11: Quote to melt ecash into paying a Lightning invoice."""
|
|
455
|
+
return cast(MeltQuoteResult, self._request('POST', '/v1/melt/quote/bolt11', {'request': invoice, 'unit': unit}))
|
|
456
|
+
|
|
457
|
+
def melt_tokens(self, quote_id: str, inputs: Sequence[CashuProof]) -> MeltQuoteResult:
|
|
458
|
+
"""POST /v1/melt/bolt11: Spend ecash proofs to pay a Lightning invoice."""
|
|
459
|
+
return cast(MeltQuoteResult, self._request('POST', '/v1/melt/bolt11', {'quote': quote_id, 'inputs': list(inputs)}))
|
|
460
|
+
|
|
461
|
+
def swap_tokens(self, inputs: Sequence[CashuProof], outputs: Sequence[BlindedMessage]) -> SwapResult:
|
|
462
|
+
"""POST /v1/swap: Atomic swap of input proofs for fresh output blinded secrets."""
|
|
463
|
+
return cast(SwapResult, self._request('POST', '/v1/swap', {'inputs': list(inputs), 'outputs': list(outputs)}))
|
|
464
|
+
|
|
465
|
+
def check_state(self, ys: Sequence[str]) -> CheckStateResult:
|
|
466
|
+
"""POST /v1/checkstate (NUT-07): the mint's own word on whether each
|
|
467
|
+
proof, named by Y = hash_to_curve(secret), is UNSPENT, PENDING or SPENT.
|
|
468
|
+
A proof the mint does not know is reported SPENT-equivalent by most
|
|
469
|
+
implementations; either way it is not UNSPENT and is not payment."""
|
|
470
|
+
return cast(CheckStateResult, self._request('POST', '/v1/checkstate', {'Ys': list(ys)}))
|
|
471
|
+
|
|
472
|
+
def proofs_unspent(self, proofs: Sequence[CashuProof]) -> bool:
|
|
473
|
+
"""`ProofVerifier` for `verify_nutzap_event`: every proof is UNSPENT at the mint."""
|
|
474
|
+
ys = [point_to_hex(hash_to_curve(p['secret'])) for p in proofs]
|
|
475
|
+
res = self.check_state(ys)
|
|
476
|
+
states = {s.get('Y'): s.get('state') for s in res.get('states', []) if isinstance(s, dict)}
|
|
477
|
+
return all(states.get(y) == 'UNSPENT' for y in ys)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ---------------------------------------------------------------------------
|
|
481
|
+
# Live Cashu Payment Rail Adapter
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
class LiveCashuPaymentRail(payment.CashuPaymentRail):
|
|
485
|
+
"""CashuPaymentRail with live HTTP mint verification and token swaps."""
|
|
486
|
+
|
|
487
|
+
def __init__(
|
|
488
|
+
self,
|
|
489
|
+
mint_url: str = DEFAULT_MINT_URL,
|
|
490
|
+
mint_privkey_hex: Optional[str] = None,
|
|
491
|
+
http_client: Optional[CashuHttpClient] = None,
|
|
492
|
+
spent: Optional[payment.SpentProofs] = None,
|
|
493
|
+
) -> None:
|
|
494
|
+
self.http: CashuHttpClient = http_client or CashuHttpClient(mint_url=mint_url)
|
|
495
|
+
# Not being the mint, this rail's proof policy is "ask the mint"
|
|
496
|
+
# (NUT-07 check-state) - never "take the payer's word".
|
|
497
|
+
super().__init__(mint_url=mint_url, mint_privkey_hex=mint_privkey_hex, spent=spent,
|
|
498
|
+
proof_verifier=self.http.proofs_unspent)
|
|
499
|
+
|
|
500
|
+
def swap_and_settle(self, payment_hash: str, new_outputs: Sequence[BlindedMessage]) -> SwapResult:
|
|
501
|
+
"""Swap held proofs for newly blinded proofs at the live mint to prevent double-spending.
|
|
502
|
+
HELD -> SETTLED through the base state machine; raises IllegalTransition
|
|
503
|
+
(a ValueError) for any other state, and the escrow stays HELD if the
|
|
504
|
+
mint refuses the swap."""
|
|
505
|
+
with self._lock:
|
|
506
|
+
inv = self._escrow_for(payment_hash, 'SETTLED')
|
|
507
|
+
self._check_transition(inv, ('HELD',), 'SETTLED')
|
|
508
|
+
proofs = inv.proofs
|
|
509
|
+
if not proofs:
|
|
510
|
+
raise payment.PaymentError("no proofs held in escrow")
|
|
511
|
+
|
|
512
|
+
# Execute live swap
|
|
513
|
+
swap_res = self.http.swap_tokens(inputs=proofs, outputs=new_outputs)
|
|
514
|
+
self._settle_io(inv, '')
|
|
515
|
+
self._transition(inv, ('HELD',), 'SETTLED')
|
|
516
|
+
inv.swapped_at = inv.settled_at
|
|
517
|
+
inv.new_signatures = list(swap_res.get('signatures', []))
|
|
518
|
+
return swap_res
|
|
519
|
+
|
|
520
|
+
def melt_to_lightning(self, payment_hash: str, bolt11: str) -> MeltQuoteResult:
|
|
521
|
+
"""Melt held ecash proofs to pay an external Lightning invoice.
|
|
522
|
+
HELD -> SETTLED only when the mint reports the invoice paid."""
|
|
523
|
+
with self._lock:
|
|
524
|
+
inv = self._escrow_for(payment_hash, 'SETTLED')
|
|
525
|
+
self._check_transition(inv, ('HELD',), 'SETTLED')
|
|
526
|
+
|
|
527
|
+
proofs = inv.proofs
|
|
528
|
+
quote_res = self.http.request_melt_quote(invoice=bolt11)
|
|
529
|
+
quote_id = quote_res.get('quote')
|
|
530
|
+
if not isinstance(quote_id, str) or not quote_id:
|
|
531
|
+
# A melt without a quote id would be a blind spend; refuse rather
|
|
532
|
+
# than send the mint an empty reference.
|
|
533
|
+
raise payment.PaymentError('mint melt quote response carried no quote id')
|
|
534
|
+
|
|
535
|
+
melt_res = self.http.melt_tokens(quote_id=quote_id, inputs=proofs)
|
|
536
|
+
if melt_res.get('paid'):
|
|
537
|
+
preimage = str(melt_res.get('payment_preimage', ''))
|
|
538
|
+
self._settle_io(inv, preimage)
|
|
539
|
+
self._transition(inv, ('HELD',), 'SETTLED')
|
|
540
|
+
inv.preimage = preimage
|
|
541
|
+
return melt_res
|