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