agent-bitcoin 26.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.
- agent_bitcoin/__init__.py +65 -0
- agent_bitcoin/agents/__init__.py +15 -0
- agent_bitcoin/agents/base.py +0 -0
- agent_bitcoin/agents/payment_decision.py +244 -0
- agent_bitcoin/client.py +258 -0
- agent_bitcoin/constants.py +116 -0
- agent_bitcoin/exceptions.py +46 -0
- agent_bitcoin/l402/__init__.py +10 -0
- agent_bitcoin/l402/client.py +183 -0
- agent_bitcoin/lightning.py +499 -0
- agent_bitcoin/models.py +96 -0
- agent_bitcoin/nostr/__init__.py +20 -0
- agent_bitcoin/nostr/nip17.py +93 -0
- agent_bitcoin/nostr/nip44.py +174 -0
- agent_bitcoin/nostr/nip46.py +183 -0
- agent_bitcoin/nostr/relay.py +331 -0
- agent_bitcoin/nostr/swarm.py +88 -0
- agent_bitcoin/nwc/__init__.py +73 -0
- agent_bitcoin/nwc/bus.py +84 -0
- agent_bitcoin/nwc/client.py +342 -0
- agent_bitcoin/nwc/crypto.py +103 -0
- agent_bitcoin/nwc/errors.py +21 -0
- agent_bitcoin/nwc/flow.py +99 -0
- agent_bitcoin/nwc/policy.py +180 -0
- agent_bitcoin/nwc/relays.py +18 -0
- agent_bitcoin/nwc/service.py +312 -0
- agent_bitcoin/nwc/uri.py +123 -0
- agent_bitcoin/prompts.py +24 -0
- agent_bitcoin/spend_ledger.py +108 -0
- agent_bitcoin/utils.py +51 -0
- agent_bitcoin-26.2.0.dist-info/METADATA +211 -0
- agent_bitcoin-26.2.0.dist-info/RECORD +35 -0
- agent_bitcoin-26.2.0.dist-info/WHEEL +5 -0
- agent_bitcoin-26.2.0.dist-info/licenses/LICENSE +21 -0
- agent_bitcoin-26.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent-Bitcoin SDK
|
|
3
|
+
=================
|
|
4
|
+
|
|
5
|
+
A Python library for Lightning Network payments between autonomous AI agents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
__version__ = "26.2.0"
|
|
9
|
+
|
|
10
|
+
# Core
|
|
11
|
+
from .client import AgentBitcoinClient, create_client
|
|
12
|
+
from .constants import (
|
|
13
|
+
DEFAULT_L402_PRICE_SATS,
|
|
14
|
+
DEFAULT_MAX_PAYMENT_SATS,
|
|
15
|
+
DEFAULT_MIN_PAYMENT_SATS,
|
|
16
|
+
)
|
|
17
|
+
from .l402 import L402Client, L402Challenge, parse_www_authenticate
|
|
18
|
+
from .models import LightningConfig, Invoice, InvoiceQuote, PayerDecisionInputs
|
|
19
|
+
|
|
20
|
+
# Exceptions
|
|
21
|
+
from .exceptions import (
|
|
22
|
+
AgentBitcoinError,
|
|
23
|
+
InvoiceCreationError,
|
|
24
|
+
PaymentError,
|
|
25
|
+
MacaroonError,
|
|
26
|
+
InsufficientBalanceError,
|
|
27
|
+
NoRouteError,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Intelligent Agents (kept for future use)
|
|
31
|
+
from .agents.payment_decision import (
|
|
32
|
+
PaymentDecisionAgent,
|
|
33
|
+
create_payment_decision_agent,
|
|
34
|
+
create_grok_payment_decision_agent,
|
|
35
|
+
PaymentDecision,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Main public API
|
|
39
|
+
__all__ = [
|
|
40
|
+
# Core
|
|
41
|
+
"AgentBitcoinClient",
|
|
42
|
+
"LightningConfig",
|
|
43
|
+
"Invoice",
|
|
44
|
+
"InvoiceQuote",
|
|
45
|
+
"PayerDecisionInputs",
|
|
46
|
+
"create_client",
|
|
47
|
+
"DEFAULT_MIN_PAYMENT_SATS",
|
|
48
|
+
"DEFAULT_MAX_PAYMENT_SATS",
|
|
49
|
+
"DEFAULT_L402_PRICE_SATS",
|
|
50
|
+
"L402Client",
|
|
51
|
+
"L402Challenge",
|
|
52
|
+
"parse_www_authenticate",
|
|
53
|
+
# Exceptions
|
|
54
|
+
"AgentBitcoinError",
|
|
55
|
+
"InvoiceCreationError",
|
|
56
|
+
"PaymentError",
|
|
57
|
+
"MacaroonError",
|
|
58
|
+
"InsufficientBalanceError",
|
|
59
|
+
"NoRouteError",
|
|
60
|
+
# Intelligent Agents
|
|
61
|
+
"PaymentDecisionAgent",
|
|
62
|
+
"create_payment_decision_agent",
|
|
63
|
+
"create_grok_payment_decision_agent",
|
|
64
|
+
"PaymentDecision",
|
|
65
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# agent_bitcoin/agents/__init__.py
|
|
2
|
+
|
|
3
|
+
from .payment_decision import (
|
|
4
|
+
PaymentDecisionAgent,
|
|
5
|
+
BitcoinLNDAgent,
|
|
6
|
+
create_grok_payment_decision_agent,
|
|
7
|
+
create_grok_bitcoin_lnd_agent,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PaymentDecisionAgent",
|
|
12
|
+
"BitcoinLNDAgent",
|
|
13
|
+
"create_grok_payment_decision_agent",
|
|
14
|
+
"create_grok_bitcoin_lnd_agent",
|
|
15
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Payment decision agents with coded policy limits (not prompt-only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
11
|
+
from langchain_xai import ChatXAI
|
|
12
|
+
|
|
13
|
+
from agent_bitcoin.constants import (
|
|
14
|
+
min_payment_sats,
|
|
15
|
+
payment_decision_max_sats,
|
|
16
|
+
)
|
|
17
|
+
from agent_bitcoin.prompts import (
|
|
18
|
+
BITCOIN_LND_SYSTEM_PROMPT,
|
|
19
|
+
PAYMENT_DECISION_DEFAULT_INSTRUCTIONS,
|
|
20
|
+
PAYMENT_DECISION_SYSTEM_PROMPT,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PaymentDecision(Enum):
|
|
27
|
+
PAY = "PAY"
|
|
28
|
+
REJECT = "REJECT"
|
|
29
|
+
CONFIRM_REQUIRED = "CONFIRM_REQUIRED"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PaymentDecisionAgent:
|
|
33
|
+
"""
|
|
34
|
+
Conservative gatekeeper for Lightning invoice payments.
|
|
35
|
+
|
|
36
|
+
Policy is enforced in code first; the LLM only runs if hard limits pass.
|
|
37
|
+
This agent never executes payments — callers must act on the returned decision.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
api_key: Optional[str] = None,
|
|
43
|
+
model: str = "grok-4-1-fast-reasoning",
|
|
44
|
+
min_sats: Optional[int] = None,
|
|
45
|
+
max_sats: Optional[int] = None,
|
|
46
|
+
confirm_above_sats: Optional[int] = None,
|
|
47
|
+
):
|
|
48
|
+
self.llm = ChatXAI(
|
|
49
|
+
model=model,
|
|
50
|
+
api_key=api_key,
|
|
51
|
+
temperature=0.1,
|
|
52
|
+
)
|
|
53
|
+
self.system_prompt = PAYMENT_DECISION_SYSTEM_PROMPT
|
|
54
|
+
self.default_instructions = PAYMENT_DECISION_DEFAULT_INSTRUCTIONS
|
|
55
|
+
|
|
56
|
+
# Coded limits (shared defaults; constructor / env overrides)
|
|
57
|
+
self.min_sats = min_sats if min_sats is not None else min_payment_sats()
|
|
58
|
+
self.max_sats = (
|
|
59
|
+
max_sats if max_sats is not None else payment_decision_max_sats()
|
|
60
|
+
)
|
|
61
|
+
# Amounts above this require human confirmation (no automatic PAY)
|
|
62
|
+
if confirm_above_sats is not None:
|
|
63
|
+
self.confirm_above_sats = confirm_above_sats
|
|
64
|
+
else:
|
|
65
|
+
raw = os.getenv("PAYMENT_DECISION_CONFIRM_ABOVE_SATS", "").strip()
|
|
66
|
+
self.confirm_above_sats = int(raw) if raw else None
|
|
67
|
+
|
|
68
|
+
def _parse_amount(self, invoice_data: dict) -> Optional[int]:
|
|
69
|
+
raw = invoice_data.get("amount_sats")
|
|
70
|
+
if raw is None:
|
|
71
|
+
return None
|
|
72
|
+
try:
|
|
73
|
+
return int(raw)
|
|
74
|
+
except (TypeError, ValueError):
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def _policy_gate(self, amount: Optional[int]) -> Optional[dict[str, Any]]:
|
|
78
|
+
"""Return a reject/confirm result if policy blocks LLM, else None."""
|
|
79
|
+
if amount is None:
|
|
80
|
+
return {
|
|
81
|
+
"decision": PaymentDecision.REJECT.value,
|
|
82
|
+
"reasoning": "Policy: missing or invalid amount_sats; refusing to evaluate.",
|
|
83
|
+
"blocked_by_policy": True,
|
|
84
|
+
"policy_code": "INVALID_AMOUNT",
|
|
85
|
+
"amount_sats": None,
|
|
86
|
+
}
|
|
87
|
+
if amount < self.min_sats:
|
|
88
|
+
return {
|
|
89
|
+
"decision": PaymentDecision.REJECT.value,
|
|
90
|
+
"reasoning": (
|
|
91
|
+
f"Policy: amount {amount} sats is below minimum {self.min_sats} sats."
|
|
92
|
+
),
|
|
93
|
+
"blocked_by_policy": True,
|
|
94
|
+
"policy_code": "BELOW_MINIMUM",
|
|
95
|
+
"amount_sats": amount,
|
|
96
|
+
}
|
|
97
|
+
if amount > self.max_sats:
|
|
98
|
+
return {
|
|
99
|
+
"decision": PaymentDecision.REJECT.value,
|
|
100
|
+
"reasoning": (
|
|
101
|
+
f"Policy: amount {amount} sats exceeds hard maximum "
|
|
102
|
+
f"{self.max_sats} sats (PAYMENT_DECISION_MAX_SATS)."
|
|
103
|
+
),
|
|
104
|
+
"blocked_by_policy": True,
|
|
105
|
+
"policy_code": "ABOVE_MAXIMUM",
|
|
106
|
+
"amount_sats": amount,
|
|
107
|
+
}
|
|
108
|
+
if self.confirm_above_sats is not None and amount > self.confirm_above_sats:
|
|
109
|
+
return {
|
|
110
|
+
"decision": PaymentDecision.CONFIRM_REQUIRED.value,
|
|
111
|
+
"reasoning": (
|
|
112
|
+
f"Policy: amount {amount} sats is above confirmation threshold "
|
|
113
|
+
f"{self.confirm_above_sats} sats. Human approval required before PAY."
|
|
114
|
+
),
|
|
115
|
+
"blocked_by_policy": True,
|
|
116
|
+
"policy_code": "CONFIRM_REQUIRED",
|
|
117
|
+
"amount_sats": amount,
|
|
118
|
+
}
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def decide_payment(self, invoice_data: dict, context: str = "") -> dict:
|
|
122
|
+
amount = self._parse_amount(invoice_data)
|
|
123
|
+
gate = self._policy_gate(amount)
|
|
124
|
+
if gate is not None:
|
|
125
|
+
result = {
|
|
126
|
+
**gate,
|
|
127
|
+
"raw_response": None,
|
|
128
|
+
"policy": {
|
|
129
|
+
"min_sats": self.min_sats,
|
|
130
|
+
"max_sats": self.max_sats,
|
|
131
|
+
"confirm_above_sats": self.confirm_above_sats,
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
logger.info(
|
|
135
|
+
"payment_decision policy_block code=%s decision=%s amount=%s",
|
|
136
|
+
result.get("policy_code"),
|
|
137
|
+
result["decision"],
|
|
138
|
+
amount,
|
|
139
|
+
)
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
# Never put full BOLT11 / secrets in the prompt
|
|
143
|
+
pay_req = str(invoice_data.get("payment_request") or "")
|
|
144
|
+
pay_req_preview = (pay_req[:48] + "…") if len(pay_req) > 48 else pay_req
|
|
145
|
+
memo = str(invoice_data.get("memo") or "No memo")[:200]
|
|
146
|
+
ctx = (context or "No additional context")[:500]
|
|
147
|
+
|
|
148
|
+
prompt = f"""Invoice details:
|
|
149
|
+
Amount: {amount} sats
|
|
150
|
+
Memo: {memo}
|
|
151
|
+
Payment Request (truncated): {pay_req_preview or "N/A"}
|
|
152
|
+
|
|
153
|
+
Context: {ctx}
|
|
154
|
+
|
|
155
|
+
Hard policy already applied (do not override):
|
|
156
|
+
- Minimum: {self.min_sats} sats
|
|
157
|
+
- Maximum: {self.max_sats} sats
|
|
158
|
+
{f"- Confirm above: {self.confirm_above_sats} sats" if self.confirm_above_sats else ""}
|
|
159
|
+
|
|
160
|
+
{self.default_instructions}
|
|
161
|
+
|
|
162
|
+
Should we pay this invoice? Respond with clear reasoning and final decision (PAY / REJECT).
|
|
163
|
+
Do not invent payment execution; only decide."""
|
|
164
|
+
|
|
165
|
+
messages = [
|
|
166
|
+
SystemMessage(content=self.system_prompt),
|
|
167
|
+
HumanMessage(content=prompt),
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
response = self.llm.invoke(messages)
|
|
171
|
+
text = response.content if hasattr(response, "content") else str(response)
|
|
172
|
+
text_upper = text.upper()
|
|
173
|
+
|
|
174
|
+
# Prefer explicit REJECT if both words appear
|
|
175
|
+
if "REJECT" in text_upper and "PAY" in text_upper:
|
|
176
|
+
# last occurrence wins lightly: if REJECT after last PAY → reject
|
|
177
|
+
decision = (
|
|
178
|
+
PaymentDecision.REJECT.value
|
|
179
|
+
if text_upper.rfind("REJECT") > text_upper.rfind("PAY")
|
|
180
|
+
else PaymentDecision.PAY.value
|
|
181
|
+
)
|
|
182
|
+
elif "REJECT" in text_upper:
|
|
183
|
+
decision = PaymentDecision.REJECT.value
|
|
184
|
+
elif "PAY" in text_upper:
|
|
185
|
+
decision = PaymentDecision.PAY.value
|
|
186
|
+
else:
|
|
187
|
+
decision = PaymentDecision.REJECT.value # fail closed
|
|
188
|
+
|
|
189
|
+
result = {
|
|
190
|
+
"decision": decision,
|
|
191
|
+
"reasoning": text,
|
|
192
|
+
"raw_response": text,
|
|
193
|
+
"blocked_by_policy": False,
|
|
194
|
+
"policy_code": None,
|
|
195
|
+
"amount_sats": amount,
|
|
196
|
+
"policy": {
|
|
197
|
+
"min_sats": self.min_sats,
|
|
198
|
+
"max_sats": self.max_sats,
|
|
199
|
+
"confirm_above_sats": self.confirm_above_sats,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
logger.info(
|
|
203
|
+
"payment_decision llm decision=%s amount=%s",
|
|
204
|
+
decision,
|
|
205
|
+
amount,
|
|
206
|
+
)
|
|
207
|
+
return result
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class BitcoinLNDAgent:
|
|
211
|
+
"""Agent for the counterparty node (agent-bitcoin-lnd). Prompt helper only."""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self, api_key: Optional[str] = None, model: str = "grok-4-1-fast-reasoning"
|
|
215
|
+
):
|
|
216
|
+
self.llm = ChatXAI(
|
|
217
|
+
model=model,
|
|
218
|
+
api_key=api_key,
|
|
219
|
+
temperature=0.3,
|
|
220
|
+
)
|
|
221
|
+
self.system_prompt = BITCOIN_LND_SYSTEM_PROMPT
|
|
222
|
+
|
|
223
|
+
def create_invoice_prompt(self, amount_sats: int, memo: str) -> str:
|
|
224
|
+
return (
|
|
225
|
+
f"Create a Lightning invoice for {amount_sats} sats "
|
|
226
|
+
f"with memo: '{memo}'. Be professional and clear."
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def create_grok_payment_decision_agent(
|
|
231
|
+
api_key: Optional[str] = None,
|
|
232
|
+
**policy_kwargs,
|
|
233
|
+
):
|
|
234
|
+
"""Create Grok-powered payment decision agent (optional policy kwargs)."""
|
|
235
|
+
return PaymentDecisionAgent(api_key=api_key, **policy_kwargs)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def create_grok_bitcoin_lnd_agent(api_key: Optional[str] = None):
|
|
239
|
+
"""Create Grok-powered agent for the counterparty LND node."""
|
|
240
|
+
return BitcoinLNDAgent(api_key=api_key)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Alias for backward compatibility with __init__.py
|
|
244
|
+
create_payment_decision_agent = create_grok_payment_decision_agent
|
agent_bitcoin/client.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
from dotenv import load_dotenv
|
|
2
|
+
|
|
3
|
+
from .constants import (
|
|
4
|
+
autopay_allowed,
|
|
5
|
+
fee_send_allowed,
|
|
6
|
+
max_daily_payment_sats,
|
|
7
|
+
max_payment_sats,
|
|
8
|
+
min_payment_sats,
|
|
9
|
+
current_network,
|
|
10
|
+
)
|
|
11
|
+
from .lightning import LNDClient
|
|
12
|
+
from .models import (
|
|
13
|
+
Invoice,
|
|
14
|
+
InvoiceQuote,
|
|
15
|
+
PayerDecisionInputs,
|
|
16
|
+
PaymentResult,
|
|
17
|
+
OnChainSendResult,
|
|
18
|
+
LightningBalance,
|
|
19
|
+
ChannelBalance,
|
|
20
|
+
)
|
|
21
|
+
from .spend_ledger import assert_can_spend, record_spend
|
|
22
|
+
|
|
23
|
+
load_dotenv()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _invoice_amount_sats(decoded: dict) -> int:
|
|
27
|
+
"""Extract sat amount from lncli/gRPC decodepayreq dict."""
|
|
28
|
+
for key in ("num_satoshis", "num_sats", "amt_sat"):
|
|
29
|
+
if decoded.get(key) is not None:
|
|
30
|
+
try:
|
|
31
|
+
return int(decoded[key])
|
|
32
|
+
except (TypeError, ValueError):
|
|
33
|
+
pass
|
|
34
|
+
for key in ("num_msat", "amt_msat"):
|
|
35
|
+
if decoded.get(key) is not None:
|
|
36
|
+
try:
|
|
37
|
+
return int(decoded[key]) // 1000
|
|
38
|
+
except (TypeError, ValueError):
|
|
39
|
+
pass
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentBitcoinClient:
|
|
44
|
+
def __init__(self):
|
|
45
|
+
self.lnd = LNDClient()
|
|
46
|
+
|
|
47
|
+
self.min_payment_sats = min_payment_sats()
|
|
48
|
+
self.max_payment_sats = max_payment_sats()
|
|
49
|
+
self.max_daily_payment_sats = max_daily_payment_sats()
|
|
50
|
+
|
|
51
|
+
def create_invoice(
|
|
52
|
+
self, memo: str, amount_sats: int, expiry_seconds: int = 3600
|
|
53
|
+
) -> Invoice:
|
|
54
|
+
if amount_sats < self.min_payment_sats:
|
|
55
|
+
raise ValueError(f"Minimum payment is {self.min_payment_sats} sats")
|
|
56
|
+
if amount_sats > self.max_payment_sats:
|
|
57
|
+
raise ValueError(f"Maximum payment is {self.max_payment_sats} sats")
|
|
58
|
+
return self.lnd.create_invoice(memo, int(amount_sats), expiry_seconds)
|
|
59
|
+
|
|
60
|
+
def create_invoice_quote(
|
|
61
|
+
self,
|
|
62
|
+
memo: str,
|
|
63
|
+
amount_sats: int,
|
|
64
|
+
expiry_seconds: int = 3600,
|
|
65
|
+
) -> InvoiceQuote:
|
|
66
|
+
"""
|
|
67
|
+
Payee: create one BOLT11 for the requested amount.
|
|
68
|
+
|
|
69
|
+
amount_sats is the service amount (min/max apply). BOLT11 and
|
|
70
|
+
total_cost_sats equal that amount. There is no platform fee.
|
|
71
|
+
"""
|
|
72
|
+
if amount_sats < self.min_payment_sats:
|
|
73
|
+
raise ValueError(f"Minimum payment is {self.min_payment_sats} sats")
|
|
74
|
+
if amount_sats > self.max_payment_sats:
|
|
75
|
+
raise ValueError(f"Maximum payment is {self.max_payment_sats} sats")
|
|
76
|
+
total = int(amount_sats)
|
|
77
|
+
inv = self.lnd.create_invoice(memo, total, expiry_seconds)
|
|
78
|
+
return InvoiceQuote(
|
|
79
|
+
payment_request=inv.payment_request,
|
|
80
|
+
amount_sats=total,
|
|
81
|
+
total_cost_sats=total,
|
|
82
|
+
memo=memo or "",
|
|
83
|
+
r_hash=inv.r_hash,
|
|
84
|
+
payment_hash=inv.payment_hash,
|
|
85
|
+
network=current_network(),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def validate_invoice_quote(self, quote: InvoiceQuote | dict) -> InvoiceQuote:
|
|
89
|
+
"""
|
|
90
|
+
Payer: check quote internal consistency and BOLT11 amount match.
|
|
91
|
+
Raises ValueError if invalid.
|
|
92
|
+
"""
|
|
93
|
+
if isinstance(quote, dict):
|
|
94
|
+
quote = InvoiceQuote.model_validate(quote)
|
|
95
|
+
if quote.amount_sats < 0:
|
|
96
|
+
raise ValueError("amount_sats must be >= 0")
|
|
97
|
+
if quote.total_cost_sats != quote.amount_sats:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"total_cost_sats={quote.total_cost_sats} != "
|
|
100
|
+
f"amount_sats={quote.amount_sats}"
|
|
101
|
+
)
|
|
102
|
+
if not quote.payment_request:
|
|
103
|
+
raise ValueError("payment_request is required")
|
|
104
|
+
|
|
105
|
+
decoded = self.lnd.decode_pay_req(quote.payment_request.strip())
|
|
106
|
+
bolt_amt = _invoice_amount_sats(decoded)
|
|
107
|
+
if bolt_amt != quote.total_cost_sats:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"BOLT11 amount {bolt_amt} does not match quote total_cost_sats "
|
|
110
|
+
f"{quote.total_cost_sats} (requested {quote.amount_sats})"
|
|
111
|
+
)
|
|
112
|
+
return quote
|
|
113
|
+
|
|
114
|
+
def build_payer_decision_inputs(
|
|
115
|
+
self,
|
|
116
|
+
quote: InvoiceQuote | dict,
|
|
117
|
+
routing_fee_limit_sats: int = 200,
|
|
118
|
+
) -> PayerDecisionInputs:
|
|
119
|
+
"""
|
|
120
|
+
Payer: produce decision inputs for PaymentDecisionAgent / budget checks.
|
|
121
|
+
Does not pay. Sets quote_valid False on validation failure (no raise).
|
|
122
|
+
"""
|
|
123
|
+
if isinstance(quote, dict):
|
|
124
|
+
try:
|
|
125
|
+
q = InvoiceQuote.model_validate(quote)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return PayerDecisionInputs(
|
|
128
|
+
payment_request=str(quote.get("payment_request") or ""),
|
|
129
|
+
amount_sats=int(quote.get("amount_sats") or 0),
|
|
130
|
+
total_cost_sats=int(quote.get("total_cost_sats") or 0),
|
|
131
|
+
routing_fee_limit_sats=int(routing_fee_limit_sats),
|
|
132
|
+
quote_valid=False,
|
|
133
|
+
validation_error=str(e),
|
|
134
|
+
)
|
|
135
|
+
else:
|
|
136
|
+
q = quote
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
q = self.validate_invoice_quote(q)
|
|
140
|
+
decoded = self.lnd.decode_pay_req(q.payment_request.strip())
|
|
141
|
+
dest = str(decoded.get("destination") or "")
|
|
142
|
+
return PayerDecisionInputs(
|
|
143
|
+
payment_request=q.payment_request,
|
|
144
|
+
amount_sats=q.amount_sats,
|
|
145
|
+
total_cost_sats=q.total_cost_sats,
|
|
146
|
+
routing_fee_limit_sats=int(routing_fee_limit_sats),
|
|
147
|
+
quote_valid=True,
|
|
148
|
+
validation_error=None,
|
|
149
|
+
memo=q.memo or str(decoded.get("description") or ""),
|
|
150
|
+
destination=dest,
|
|
151
|
+
)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
return PayerDecisionInputs(
|
|
154
|
+
payment_request=q.payment_request,
|
|
155
|
+
amount_sats=q.amount_sats,
|
|
156
|
+
total_cost_sats=q.total_cost_sats,
|
|
157
|
+
routing_fee_limit_sats=int(routing_fee_limit_sats),
|
|
158
|
+
quote_valid=False,
|
|
159
|
+
validation_error=str(e),
|
|
160
|
+
memo=q.memo,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
def pay_invoice(
|
|
164
|
+
self, payment_request: str, fee_limit_sats: int = 200
|
|
165
|
+
) -> PaymentResult:
|
|
166
|
+
if not payment_request:
|
|
167
|
+
raise ValueError("Payment request is required")
|
|
168
|
+
if not autopay_allowed():
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
"Lightning pay blocked: set AGENT_BITCOIN_ALLOW_AUTOPAY=1 "
|
|
171
|
+
"(required on mainnet; optional kill-switch on lab nets with =0). "
|
|
172
|
+
"See docs/mainnet-pilot.md Phase 2."
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
amount_sats = 0
|
|
176
|
+
try:
|
|
177
|
+
decoded = self.lnd.decode_pay_req(payment_request.strip())
|
|
178
|
+
amount_sats = _invoice_amount_sats(decoded)
|
|
179
|
+
except Exception:
|
|
180
|
+
decoded = {}
|
|
181
|
+
|
|
182
|
+
if amount_sats > 0:
|
|
183
|
+
if amount_sats < self.min_payment_sats:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"Invoice amount {amount_sats} below minimum {self.min_payment_sats}"
|
|
186
|
+
)
|
|
187
|
+
if amount_sats > self.max_payment_sats:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"Invoice amount {amount_sats} above maximum {self.max_payment_sats}"
|
|
190
|
+
)
|
|
191
|
+
assert_can_spend(amount_sats, self.max_daily_payment_sats)
|
|
192
|
+
|
|
193
|
+
result = self.lnd.pay_invoice(
|
|
194
|
+
payment_request.strip(), fee_limit_sats=fee_limit_sats
|
|
195
|
+
)
|
|
196
|
+
if result.success:
|
|
197
|
+
recorded = amount_sats or int(result.amount or 0)
|
|
198
|
+
if recorded > 0:
|
|
199
|
+
record_spend(recorded, payment_hash=result.payment_hash)
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
def pay_invoice_quote(
|
|
203
|
+
self,
|
|
204
|
+
quote: InvoiceQuote | dict,
|
|
205
|
+
routing_fee_limit_sats: int = 200,
|
|
206
|
+
) -> PaymentResult:
|
|
207
|
+
"""
|
|
208
|
+
Payer: validate quote, budget total_cost_sats, pay BOLT11 amount on LN.
|
|
209
|
+
|
|
210
|
+
total_cost_sats equals the requested amount (no platform fee).
|
|
211
|
+
Daily/deposit ledger uses total_cost_sats.
|
|
212
|
+
"""
|
|
213
|
+
q = self.validate_invoice_quote(quote)
|
|
214
|
+
if not autopay_allowed():
|
|
215
|
+
raise RuntimeError(
|
|
216
|
+
"Lightning pay blocked: set AGENT_BITCOIN_ALLOW_AUTOPAY=1 "
|
|
217
|
+
"(required on mainnet; optional kill-switch on lab nets with =0). "
|
|
218
|
+
"See docs/mainnet-pilot.md Phase 2."
|
|
219
|
+
)
|
|
220
|
+
if q.amount_sats < self.min_payment_sats:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"Invoice amount {q.amount_sats} below minimum {self.min_payment_sats}"
|
|
223
|
+
)
|
|
224
|
+
if q.amount_sats > self.max_payment_sats:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"Invoice amount {q.amount_sats} above maximum {self.max_payment_sats}"
|
|
227
|
+
)
|
|
228
|
+
assert_can_spend(q.total_cost_sats, self.max_daily_payment_sats)
|
|
229
|
+
|
|
230
|
+
result = self.lnd.pay_invoice(
|
|
231
|
+
q.payment_request.strip(), fee_limit_sats=routing_fee_limit_sats
|
|
232
|
+
)
|
|
233
|
+
if result.success:
|
|
234
|
+
record_spend(q.total_cost_sats, payment_hash=result.payment_hash)
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
def send_onchain(self, address: str, amount_sats: int) -> OnChainSendResult:
|
|
238
|
+
if not fee_send_allowed():
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
"On-chain fee/send blocked on mainnet unless "
|
|
241
|
+
"AGENT_BITCOIN_ALLOW_MAINNET_FEE=1 (pilot default off)."
|
|
242
|
+
)
|
|
243
|
+
if not address:
|
|
244
|
+
raise ValueError("Destination address is required")
|
|
245
|
+
if amount_sats <= 0:
|
|
246
|
+
raise ValueError("Amount must be positive")
|
|
247
|
+
return self.lnd.send_coins(address, amount_sats)
|
|
248
|
+
|
|
249
|
+
def get_balance(self) -> LightningBalance:
|
|
250
|
+
return self.lnd.get_balance()
|
|
251
|
+
|
|
252
|
+
def get_channel_balance(self) -> ChannelBalance:
|
|
253
|
+
return self.lnd.get_channel_balance()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def create_client() -> AgentBitcoinClient:
|
|
257
|
+
"""Factory function"""
|
|
258
|
+
return AgentBitcoinClient()
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared payment policy defaults for SDK, backend API, and agents.
|
|
3
|
+
|
|
4
|
+
Env vars may still override at runtime; these are the single source of truth
|
|
5
|
+
for default numeric limits so invoice and agent ceilings cannot drift.
|
|
6
|
+
|
|
7
|
+
Mainnet pilot defaults (docs/mainnet-pilot.md Phase 0):
|
|
8
|
+
single pay 50_000, daily 100_000, autopay off, fee sends off.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
# --- Canonical defaults (sats) ---
|
|
16
|
+
DEFAULT_MIN_PAYMENT_SATS = 1_000
|
|
17
|
+
DEFAULT_MAX_PAYMENT_SATS = 1_000_000
|
|
18
|
+
# When LND_NETWORK=mainnet and MAX_PAYMENT_SATS unset (pilot ceiling)
|
|
19
|
+
DEFAULT_MAINNET_MAX_PAYMENT_SATS = 50_000
|
|
20
|
+
DEFAULT_MAX_DAILY_PAYMENT_SATS = 100_000
|
|
21
|
+
DEFAULT_MAX_FEE_SEND_SATS = 100_000
|
|
22
|
+
# Aperture L402 price per paid request (matches MIN_PAYMENT_SATS)
|
|
23
|
+
DEFAULT_L402_PRICE_SATS = 1_000
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def env_int(name: str, default: int) -> int:
|
|
27
|
+
"""Read a positive-capable int from the environment, or return default."""
|
|
28
|
+
raw = os.getenv(name)
|
|
29
|
+
if raw is None or str(raw).strip() == "":
|
|
30
|
+
return default
|
|
31
|
+
return int(raw)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def current_network() -> str:
|
|
35
|
+
return os.getenv("LND_NETWORK", "regtest").strip().lower() or "regtest"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_mainnet() -> bool:
|
|
39
|
+
return current_network() == "mainnet"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def min_payment_sats() -> int:
|
|
43
|
+
return env_int("MIN_PAYMENT_SATS", DEFAULT_MIN_PAYMENT_SATS)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def max_payment_sats() -> int:
|
|
47
|
+
"""
|
|
48
|
+
Maximum invoice / payment amount (sats).
|
|
49
|
+
|
|
50
|
+
MAX_INVOICE_SATS and PAYMENT_DECISION_MAX_SATS both default to this value.
|
|
51
|
+
On mainnet, default ceiling is pilot 50_000 unless MAX_PAYMENT_SATS is set.
|
|
52
|
+
"""
|
|
53
|
+
if os.getenv("MAX_PAYMENT_SATS", "").strip():
|
|
54
|
+
return env_int("MAX_PAYMENT_SATS", DEFAULT_MAX_PAYMENT_SATS)
|
|
55
|
+
if is_mainnet():
|
|
56
|
+
return DEFAULT_MAINNET_MAX_PAYMENT_SATS
|
|
57
|
+
return DEFAULT_MAX_PAYMENT_SATS
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def max_daily_payment_sats() -> int:
|
|
61
|
+
"""
|
|
62
|
+
Max sum of successful Lightning pays per UTC day (sats).
|
|
63
|
+
|
|
64
|
+
0 = disabled (no daily cap). On mainnet defaults to pilot 100_000;
|
|
65
|
+
on lab nets defaults to 0 (unlimited) unless MAX_DAILY_PAYMENT_SATS is set.
|
|
66
|
+
"""
|
|
67
|
+
if os.getenv("MAX_DAILY_PAYMENT_SATS", "").strip():
|
|
68
|
+
return env_int("MAX_DAILY_PAYMENT_SATS", 0)
|
|
69
|
+
if is_mainnet():
|
|
70
|
+
return DEFAULT_MAX_DAILY_PAYMENT_SATS
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def max_invoice_sats() -> int:
|
|
75
|
+
"""Backend invoice ceiling; defaults to shared max payment."""
|
|
76
|
+
if os.getenv("MAX_INVOICE_SATS", "").strip():
|
|
77
|
+
return env_int("MAX_INVOICE_SATS", DEFAULT_MAX_PAYMENT_SATS)
|
|
78
|
+
return max_payment_sats()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def payment_decision_max_sats() -> int:
|
|
82
|
+
"""Agent hard max; defaults to shared max payment."""
|
|
83
|
+
if os.getenv("PAYMENT_DECISION_MAX_SATS", "").strip():
|
|
84
|
+
return env_int("PAYMENT_DECISION_MAX_SATS", DEFAULT_MAX_PAYMENT_SATS)
|
|
85
|
+
return max_payment_sats()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def max_fee_send_sats() -> int:
|
|
89
|
+
return env_int("MAX_FEE_SEND_SATS", DEFAULT_MAX_FEE_SEND_SATS)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def autopay_allowed() -> bool:
|
|
93
|
+
"""
|
|
94
|
+
Whether non-interactive Lightning pay is allowed.
|
|
95
|
+
|
|
96
|
+
Mainnet: require AGENT_BITCOIN_ALLOW_AUTOPAY=1 (human pilot default off).
|
|
97
|
+
Lab nets: allowed unless AGENT_BITCOIN_ALLOW_AUTOPAY=0.
|
|
98
|
+
"""
|
|
99
|
+
flag = (os.getenv("AGENT_BITCOIN_ALLOW_AUTOPAY") or "").strip()
|
|
100
|
+
if is_mainnet():
|
|
101
|
+
return flag == "1"
|
|
102
|
+
if flag == "0":
|
|
103
|
+
return False
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def fee_send_allowed() -> bool:
|
|
108
|
+
"""
|
|
109
|
+
Generic on-chain send (`send_onchain`).
|
|
110
|
+
|
|
111
|
+
Mainnet: disabled unless AGENT_BITCOIN_ALLOW_MAINNET_FEE=1.
|
|
112
|
+
Lab nets: allowed.
|
|
113
|
+
"""
|
|
114
|
+
if is_mainnet():
|
|
115
|
+
return (os.getenv("AGENT_BITCOIN_ALLOW_MAINNET_FEE") or "").strip() == "1"
|
|
116
|
+
return True
|