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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any