langchain-send21 0.1.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.
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""send21 tools for LangChain.
|
|
2
|
+
|
|
3
|
+
send21 prepares payment instructions. It never holds keys or funds, never signs and
|
|
4
|
+
never broadcasts. A key with drafts:write can create a pay link but cannot move money.
|
|
5
|
+
"""
|
|
6
|
+
from langchain_send21.tools import (
|
|
7
|
+
PayOption,
|
|
8
|
+
Send21Client,
|
|
9
|
+
Send21CreatePayLinkTool,
|
|
10
|
+
Send21GetPayLinkStatusTool,
|
|
11
|
+
Send21Toolkit,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = ["PayOption", "Send21Client", "Send21CreatePayLinkTool", "Send21GetPayLinkStatusTool", "Send21Toolkit"]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Literal, Optional
|
|
6
|
+
from urllib.parse import urljoin
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from langchain_core.tools import BaseTool, BaseToolkit
|
|
10
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
11
|
+
|
|
12
|
+
# Supported asset/network pairs. USDT only on Solana and Ethereum, no ETH on Polygon.
|
|
13
|
+
SUPPORTED = {
|
|
14
|
+
"Btc": {"Mainnet"},
|
|
15
|
+
"Usdc": {"Solana", "Ethereum", "Base", "Arbitrum", "Polygon"},
|
|
16
|
+
"Usdt": {"Solana", "Ethereum"},
|
|
17
|
+
"Eurc": {"Solana", "Ethereum", "Base"},
|
|
18
|
+
"Sol": {"Solana"},
|
|
19
|
+
"Eth": {"Ethereum", "Base", "Arbitrum"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PayOption(BaseModel):
|
|
24
|
+
currency: Literal["Btc", "Usdc", "Usdt", "Eurc", "Sol", "Eth"]
|
|
25
|
+
network: Literal["Mainnet", "Solana", "Ethereum", "Base", "Arbitrum", "Polygon"]
|
|
26
|
+
address: str = Field(description="The receiver's own address, or Lightning address name@wallet.com")
|
|
27
|
+
method: Optional[Literal["MultiOutput", "Lightning"]] = None
|
|
28
|
+
|
|
29
|
+
def check(self) -> None:
|
|
30
|
+
if self.network not in SUPPORTED[self.currency]:
|
|
31
|
+
raise ValueError(f"{self.currency} is not supported on {self.network}")
|
|
32
|
+
if self.method == "Lightning" and self.currency != "Btc":
|
|
33
|
+
raise ValueError("Lightning is only for Btc")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Send21Client:
|
|
37
|
+
"""Thin REST client. Uses a scoped send21 API key (https://send21.io/api-keys)."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, api_key: str | None = None, base_url: str = "https://send21.io",
|
|
40
|
+
http: httpx.Client | None = None) -> None:
|
|
41
|
+
self.api_key = api_key or os.environ.get("SEND21_API_KEY")
|
|
42
|
+
if not self.api_key:
|
|
43
|
+
raise ValueError("Set SEND21_API_KEY (create a scoped key at https://send21.io/api-keys)")
|
|
44
|
+
self.base_url = base_url.rstrip("/")
|
|
45
|
+
self.http = http or httpx.Client(timeout=30)
|
|
46
|
+
|
|
47
|
+
def _req(self, method: str, path: str, **kw: Any) -> dict:
|
|
48
|
+
headers = {"Authorization": f"Bearer {self.api_key}", **kw.pop("headers", {})}
|
|
49
|
+
r = self.http.request(method, self.base_url + path, headers=headers, **kw)
|
|
50
|
+
if r.status_code >= 400:
|
|
51
|
+
raise RuntimeError(f"send21 {r.status_code}: {r.text[:500]}")
|
|
52
|
+
return r.json()
|
|
53
|
+
|
|
54
|
+
def create_payment_request(self, body: dict) -> dict:
|
|
55
|
+
return self._req("POST", "/api/v1/payment-requests", json=body,
|
|
56
|
+
headers={"Idempotency-Key": str(uuid.uuid4())})
|
|
57
|
+
|
|
58
|
+
def get_payment_request(self, pr_id: str) -> dict:
|
|
59
|
+
return self._req("GET", f"/api/v1/payment-requests/{pr_id}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CreatePayLinkInput(BaseModel):
|
|
63
|
+
fiat_amount: float = Field(gt=0, description="Amount in the fiat currency")
|
|
64
|
+
fiat_currency: str = Field(min_length=3, max_length=3, description="ISO 4217 code, e.g. EUR")
|
|
65
|
+
order_id: Optional[str] = Field(None, description="Your reference, echoed in webhooks")
|
|
66
|
+
memo: Optional[str] = Field(None, max_length=500)
|
|
67
|
+
decline_note: Optional[str] = Field(None, max_length=200, description="Shown as 'If you don't pay:'")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Send21CreatePayLinkTool(BaseTool):
|
|
71
|
+
name: str = "send21_create_pay_link"
|
|
72
|
+
description: str = (
|
|
73
|
+
"Create a send21 pay link so a human can pay you. Returns a link; the payer picks a currency, "
|
|
74
|
+
"scans one QR and signs in their own wallet. This tool cannot move funds."
|
|
75
|
+
)
|
|
76
|
+
args_schema: type[BaseModel] = CreatePayLinkInput
|
|
77
|
+
receive_options: list[PayOption]
|
|
78
|
+
_client: Send21Client = PrivateAttr()
|
|
79
|
+
|
|
80
|
+
def __init__(self, client: Send21Client, receive_options: list[PayOption], **kw: Any) -> None:
|
|
81
|
+
super().__init__(receive_options=receive_options, **kw)
|
|
82
|
+
for o in receive_options:
|
|
83
|
+
o.check()
|
|
84
|
+
self._client = client
|
|
85
|
+
|
|
86
|
+
def _run(self, fiat_amount: float, fiat_currency: str, order_id: str | None = None,
|
|
87
|
+
memo: str | None = None, decline_note: str | None = None, **_: Any) -> dict:
|
|
88
|
+
pr = self._client.create_payment_request({
|
|
89
|
+
"fiatAmount": fiat_amount,
|
|
90
|
+
"fiatCurrency": fiat_currency.upper(),
|
|
91
|
+
"options": [o.model_dump(exclude_none=True) for o in self.receive_options],
|
|
92
|
+
"orderId": order_id,
|
|
93
|
+
"memo": memo,
|
|
94
|
+
"declineNote": decline_note,
|
|
95
|
+
"reuseOpenForOrder": True if order_id else None,
|
|
96
|
+
})
|
|
97
|
+
return {
|
|
98
|
+
"payment_request_id": pr["id"],
|
|
99
|
+
"pay_link": urljoin(self._client.base_url + "/", pr["payPath"].lstrip("/")),
|
|
100
|
+
"status": pr.get("status"),
|
|
101
|
+
"expires_at": pr.get("expiresAt"),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class StatusInput(BaseModel):
|
|
106
|
+
payment_request_id: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Send21GetPayLinkStatusTool(BaseTool):
|
|
110
|
+
name: str = "send21_get_pay_link_status"
|
|
111
|
+
description: str = "Get the status of a send21 pay link (Open, Completed, Cancelled, Expired)."
|
|
112
|
+
args_schema: type[BaseModel] = StatusInput
|
|
113
|
+
_client: Send21Client = PrivateAttr()
|
|
114
|
+
|
|
115
|
+
def __init__(self, client: Send21Client, **kw: Any) -> None:
|
|
116
|
+
super().__init__(**kw)
|
|
117
|
+
self._client = client
|
|
118
|
+
|
|
119
|
+
def _run(self, payment_request_id: str, **_: Any) -> dict:
|
|
120
|
+
pr = self._client.get_payment_request(payment_request_id)
|
|
121
|
+
return {"status": pr.get("status"), "draft_status": pr.get("draftStatus"),
|
|
122
|
+
"expires_at": pr.get("expiresAt"), "change_notes": pr.get("changeNotes") or []}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Send21Toolkit(BaseToolkit):
|
|
126
|
+
"""Both tools. Receiving addresses are fixed by the app, never chosen by the model."""
|
|
127
|
+
|
|
128
|
+
receive_options: list[PayOption]
|
|
129
|
+
api_key: Optional[str] = None
|
|
130
|
+
base_url: str = "https://send21.io"
|
|
131
|
+
|
|
132
|
+
def get_tools(self) -> list[BaseTool]:
|
|
133
|
+
c = Send21Client(self.api_key, self.base_url)
|
|
134
|
+
return [Send21CreatePayLinkTool(c, self.receive_options), Send21GetPayLinkStatusTool(c)]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: langchain-send21
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: send21 tools for LangChain: prepare non-custodial pay links. The payer signs in their own wallet.
|
|
5
|
+
Project-URL: Homepage, https://send21.io
|
|
6
|
+
Project-URL: Documentation, https://send21.io/docs
|
|
7
|
+
Project-URL: Source, https://github.com/send21io/send21-examples/tree/main/integrations/langchain
|
|
8
|
+
Author-email: send21 <info@send21.io>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: bitcoin,langchain,lightning,non-custodial,payments,send21,tools,usdc
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: langchain-core<2,>=0.3
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# langchain-send21
|
|
19
|
+
|
|
20
|
+
send21 tools for LangChain. Let an agent prepare a pay link and check its status without holding keys.
|
|
21
|
+
|
|
22
|
+
send21 is non-custodial software that prepares payment instructions. It never holds keys or funds, never signs and never broadcasts. The scoped API key this package uses can create a pay link but cannot move money: the payer signs in their own wallet.
|
|
23
|
+
|
|
24
|
+
Not an official LangChain package. Questions: info@send21.io
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install langchain-send21
|
|
30
|
+
export SEND21_API_KEY=s21_... # scopes: drafts:write, drafts:read (https://send21.io/api-keys)
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Use
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from langchain.agents import create_agent
|
|
37
|
+
from langchain_send21 import PayOption, Send21Toolkit
|
|
38
|
+
|
|
39
|
+
tools = Send21Toolkit(receive_options=[
|
|
40
|
+
PayOption(currency="Usdc", network="Solana", address="<your-solana-address>"),
|
|
41
|
+
PayOption(currency="Btc", network="Mainnet", address="name@wallet.com", method="Lightning"),
|
|
42
|
+
]).get_tools()
|
|
43
|
+
|
|
44
|
+
agent = create_agent("openai:gpt-5-mini", tools=tools)
|
|
45
|
+
agent.invoke({"messages": [{"role": "user", "content": "Create a pay link for 40 EUR for order inv-1042."}]})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `send21_create_pay_link(fiat_amount, fiat_currency, order_id?, memo?, decline_note?)` returns `pay_link`, `payment_request_id`, `status`, `expires_at`. Receiving addresses come from your code, never from the model. Each call uses a fresh idempotency key, and with an `order_id` a retry returns the open link instead of creating a second one.
|
|
49
|
+
- `send21_get_pay_link_status(payment_request_id)` returns `Open`, `Completed`, `Cancelled` or `Expired` plus any "what changed" notes.
|
|
50
|
+
|
|
51
|
+
Supported pairs: BTC on Bitcoin and Lightning; USDC on Solana, Ethereum, Base, Arbitrum, Polygon; USDT on Solana and Ethereum; EURC on Solana, Ethereum, Base; SOL; ETH on Ethereum, Base, Arbitrum. Others are refused before any API call.
|
|
52
|
+
|
|
53
|
+
Already using MCP? `langchain-mcp-adapters` can load all 11 tools from https://send21.io/mcp directly.
|
|
54
|
+
|
|
55
|
+
## Develop
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
pip install -e '.[test]' && pytest # offline, mocked HTTP
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
langchain_send21/__init__.py,sha256=CTeFfrvrsvhks2wLtuWc8gDSBj3-yCOPRan3s6T9fsY,474
|
|
2
|
+
langchain_send21/tools.py,sha256=VCQezw4dKH3hyIJZZ2xtqnmz7FBBzja7Qh2Q2XdU4cE,5682
|
|
3
|
+
langchain_send21-0.1.0.dist-info/METADATA,sha256=wF2-V4rUaG9ijlh3SYCQPrP49RMS3eUXlxFktz8Lsek,2699
|
|
4
|
+
langchain_send21-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
5
|
+
langchain_send21-0.1.0.dist-info/RECORD,,
|