send21-haystack 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.
- send21_haystack/__init__.py +8 -0
- send21_haystack/client.py +126 -0
- send21_haystack/component.py +67 -0
- send21_haystack/webhook.py +26 -0
- send21_haystack-0.1.0.dist-info/METADATA +98 -0
- send21_haystack-0.1.0.dist-info/RECORD +8 -0
- send21_haystack-0.1.0.dist-info/WHEEL +4 -0
- send21_haystack-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""send21 for Haystack. Not an official deepset integration."""
|
|
2
|
+
from .client import MockSend21Client, Send21MCPClient, build_create_args, pay_link
|
|
3
|
+
from .component import Send21PaymentRequest
|
|
4
|
+
from .webhook import verify_send21_signature
|
|
5
|
+
|
|
6
|
+
__all__ = ["Send21PaymentRequest", "verify_send21_signature", "Send21MCPClient",
|
|
7
|
+
"MockSend21Client", "build_create_args", "pay_link"]
|
|
8
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""send21 MCP client (live) and a deterministic mock with the same interface.
|
|
2
|
+
|
|
3
|
+
Verified against github.com/send21io/send21-examples (mcp-payment-request):
|
|
4
|
+
- endpoint https://send21.io/mcp, Streamable HTTP, Authorization: Bearer <key>
|
|
5
|
+
- tool create_payment_request with fiatCurrency, fiatAmount, options[{currency,network,address,method?}],
|
|
6
|
+
orderId, memo, expiryHours, idempotencyKey (all keys sent, null for optional ones)
|
|
7
|
+
- result content[0].text is JSON with id and payPath; pay link = https://send21.io + payPath
|
|
8
|
+
|
|
9
|
+
Only create_payment_request is used. Arguments are also validated against the live
|
|
10
|
+
inputSchema at call time.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
from decimal import Decimal
|
|
17
|
+
from typing import Any
|
|
18
|
+
from urllib.parse import urljoin
|
|
19
|
+
|
|
20
|
+
SITE_URL = "https://send21.io"
|
|
21
|
+
TOOLS = ("create_payment_request",)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def pay_link(pay_path: str) -> str:
|
|
25
|
+
return urljoin(SITE_URL, pay_path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_create_args(*, amount: Decimal, fiat_currency: str, currency: str, network: str, address: str,
|
|
29
|
+
order_id: str, memo: str, idempotency_key: str, expiry_hours: int | None = None) -> dict:
|
|
30
|
+
return {
|
|
31
|
+
"fiatCurrency": fiat_currency.upper(),
|
|
32
|
+
"fiatAmount": float(amount),
|
|
33
|
+
"options": [{"currency": currency, "network": network, "address": address}],
|
|
34
|
+
"orderId": order_id,
|
|
35
|
+
"memo": memo,
|
|
36
|
+
"expiryHours": expiry_hours,
|
|
37
|
+
"idempotencyKey": idempotency_key,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _type_of(v: Any) -> str:
|
|
42
|
+
if v is None:
|
|
43
|
+
return "null"
|
|
44
|
+
if isinstance(v, bool):
|
|
45
|
+
return "boolean"
|
|
46
|
+
if isinstance(v, int):
|
|
47
|
+
return "integer"
|
|
48
|
+
if isinstance(v, float):
|
|
49
|
+
return "number"
|
|
50
|
+
if isinstance(v, str):
|
|
51
|
+
return "string"
|
|
52
|
+
if isinstance(v, list):
|
|
53
|
+
return "array"
|
|
54
|
+
return "object"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate(value: Any, schema: dict, path: str = "arguments") -> list[str]:
|
|
58
|
+
"""Minimal JSON Schema check (type, required, properties, items), ported from the TS example."""
|
|
59
|
+
errors: list[str] = []
|
|
60
|
+
t = schema.get("type")
|
|
61
|
+
if t:
|
|
62
|
+
allowed = t if isinstance(t, list) else [t]
|
|
63
|
+
actual = _type_of(value)
|
|
64
|
+
if not (actual in allowed or (actual == "integer" and "number" in allowed)):
|
|
65
|
+
return [f"{path}: expected {' or '.join(allowed)}, got {actual}"]
|
|
66
|
+
if isinstance(value, dict):
|
|
67
|
+
for key in schema.get("required", []):
|
|
68
|
+
if key not in value:
|
|
69
|
+
errors.append(f"{path}.{key}: required")
|
|
70
|
+
for key, sub in (schema.get("properties") or {}).items():
|
|
71
|
+
if key in value:
|
|
72
|
+
errors += validate(value[key], sub, f"{path}.{key}")
|
|
73
|
+
if isinstance(value, list) and schema.get("items"):
|
|
74
|
+
for i, item in enumerate(value):
|
|
75
|
+
errors += validate(item, schema["items"], f"{path}[{i}]")
|
|
76
|
+
return errors
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class MockSend21Client:
|
|
80
|
+
"""No network. Deterministic ids so demo output is identical every run."""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
self.calls: list[tuple[str, dict]] = []
|
|
84
|
+
self._by_key: dict[str, dict] = {}
|
|
85
|
+
|
|
86
|
+
def call(self, tool: str, args: dict) -> dict:
|
|
87
|
+
if tool not in TOOLS:
|
|
88
|
+
raise ValueError(f"unknown tool {tool}")
|
|
89
|
+
self.calls.append((tool, args))
|
|
90
|
+
if tool == "create_payment_request":
|
|
91
|
+
key = args["idempotencyKey"]
|
|
92
|
+
if key not in self._by_key: # same key returns the same request, like the real API
|
|
93
|
+
n = len(self._by_key) + 1
|
|
94
|
+
self._by_key[key] = {"id": f"demo-{n:04d}", "payPath": f"/p/demo-{n:04d}", "status": "mock"}
|
|
95
|
+
return dict(self._by_key[key])
|
|
96
|
+
raise ValueError(tool)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Send21MCPClient:
|
|
100
|
+
"""Live client over the send21 MCP server. Sync wrapper around the async MCP SDK."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, url: str, api_key: str) -> None:
|
|
103
|
+
self.url, self.api_key = url, api_key
|
|
104
|
+
|
|
105
|
+
async def _call(self, tool: str, args: dict) -> dict:
|
|
106
|
+
from mcp import ClientSession
|
|
107
|
+
from mcp.client.streamable_http import streamablehttp_client
|
|
108
|
+
|
|
109
|
+
headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
|
|
110
|
+
async with streamablehttp_client(self.url, headers=headers) as (read, write, _):
|
|
111
|
+
async with ClientSession(read, write) as session:
|
|
112
|
+
await session.initialize()
|
|
113
|
+
tools = {t.name: t for t in (await session.list_tools()).tools}
|
|
114
|
+
if tool not in tools:
|
|
115
|
+
raise RuntimeError(f"The server does not offer {tool}")
|
|
116
|
+
problems = validate(args, tools[tool].inputSchema or {})
|
|
117
|
+
if problems:
|
|
118
|
+
raise RuntimeError(f"Arguments do not match the live {tool} schema: {problems}")
|
|
119
|
+
result = await session.call_tool(tool, args)
|
|
120
|
+
text = "\n".join(c.text for c in result.content if getattr(c, "type", "") == "text")
|
|
121
|
+
if result.isError:
|
|
122
|
+
raise RuntimeError(text or f"{tool} failed")
|
|
123
|
+
return json.loads(text)
|
|
124
|
+
|
|
125
|
+
def call(self, tool: str, args: dict) -> dict:
|
|
126
|
+
return asyncio.run(self._call(tool, args))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Haystack component: ask send21 to prepare a payment request and return the pay link.
|
|
2
|
+
|
|
3
|
+
send21 prepares payment instructions. It never holds keys or funds, never signs and never
|
|
4
|
+
broadcasts. Use an API key with the drafts:write scope only; such a key cannot move funds.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import uuid
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from haystack import component, default_from_dict, default_to_dict
|
|
14
|
+
from haystack.utils import Secret, deserialize_secrets_inplace
|
|
15
|
+
|
|
16
|
+
from .client import Send21MCPClient, build_create_args, pay_link
|
|
17
|
+
|
|
18
|
+
MCP_URL = "https://send21.io/mcp"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@component
|
|
22
|
+
class Send21PaymentRequest:
|
|
23
|
+
"""Creates a send21 payment request and returns its id and pay link.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
pr = Send21PaymentRequest(currency="USDC", network="base", address="0x...")
|
|
27
|
+
pr.run(amount=1.5, fiat_currency="USD", order_id="run-42", memo="agent top-up")
|
|
28
|
+
# {"id": "...", "pay_link": "https://send21.io/p/...", "raw": {...}}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, currency: str, network: str, address: str,
|
|
32
|
+
api_key: Secret = Secret.from_env_var("SEND21_API_KEY"),
|
|
33
|
+
mcp_url: str = MCP_URL, expiry_hours: Optional[int] = None,
|
|
34
|
+
client: Any = None) -> None:
|
|
35
|
+
self.currency, self.network, self.address = currency, network, address
|
|
36
|
+
self.api_key, self.mcp_url, self.expiry_hours = api_key, mcp_url, expiry_hours
|
|
37
|
+
self._client = client
|
|
38
|
+
|
|
39
|
+
def _get_client(self):
|
|
40
|
+
if self._client is None:
|
|
41
|
+
self._client = Send21MCPClient(self.mcp_url, self.api_key.resolve_value() or "")
|
|
42
|
+
return self._client
|
|
43
|
+
|
|
44
|
+
@component.output_types(id=str, pay_link=str, raw=dict)
|
|
45
|
+
def run(self, amount: float, fiat_currency: str, order_id: str, memo: str = "",
|
|
46
|
+
idempotency_key: Optional[str] = None) -> dict:
|
|
47
|
+
if Decimal(str(amount)) <= 0:
|
|
48
|
+
raise ValueError("amount must be positive")
|
|
49
|
+
args = build_create_args(amount=Decimal(str(amount)), fiat_currency=fiat_currency,
|
|
50
|
+
currency=self.currency, network=self.network, address=self.address,
|
|
51
|
+
order_id=order_id, memo=memo,
|
|
52
|
+
idempotency_key=idempotency_key or f"{order_id}-{uuid.uuid4()}",
|
|
53
|
+
expiry_hours=self.expiry_hours)
|
|
54
|
+
res = self._get_client().call("create_payment_request", args)
|
|
55
|
+
if "id" not in res or "payPath" not in res:
|
|
56
|
+
raise RuntimeError(f"unexpected send21 response: {res}")
|
|
57
|
+
return {"id": str(res["id"]), "pay_link": pay_link(res["payPath"]), "raw": res}
|
|
58
|
+
|
|
59
|
+
def to_dict(self) -> dict:
|
|
60
|
+
return default_to_dict(self, currency=self.currency, network=self.network, address=self.address,
|
|
61
|
+
api_key=self.api_key.to_dict(), mcp_url=self.mcp_url,
|
|
62
|
+
expiry_hours=self.expiry_hours)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict) -> "Send21PaymentRequest":
|
|
66
|
+
deserialize_secrets_inplace(data["init_parameters"], keys=["api_key"])
|
|
67
|
+
return default_from_dict(cls, data)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Webhook signature check.
|
|
2
|
+
|
|
3
|
+
Header X-Send21-Signature: sha256=<lowercase hex HMAC-SHA256 of the raw body>, keyed with the
|
|
4
|
+
endpoint signing secret. Check the raw bytes before parsing. X-Send21-Delivery is reused on
|
|
5
|
+
retries, so store it and ignore repeats. No timestamp header is documented.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
SIGNATURE_HEADER = "X-Send21-Signature"
|
|
14
|
+
DELIVERY_HEADER = "X-Send21-Delivery"
|
|
15
|
+
EVENT_HEADER = "X-Send21-Event"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sign(raw: bytes, secret: str) -> str:
|
|
19
|
+
return "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def verify_send21_signature(raw_body: bytes, signature_header: Optional[str], secret: str) -> bool:
|
|
23
|
+
"""True only if the header matches the HMAC of the raw body. Constant-time compare."""
|
|
24
|
+
if not secret or not isinstance(signature_header, str) or not isinstance(raw_body, (bytes, bytearray)):
|
|
25
|
+
return False
|
|
26
|
+
return hmac.compare_digest(sign(bytes(raw_body), secret).encode(), signature_header.encode())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: send21-haystack
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Haystack component that asks send21 to prepare a non-custodial payment request (pay link). Not an official deepset integration.
|
|
5
|
+
Project-URL: Repository, https://github.com/send21io/send21-haystack
|
|
6
|
+
Project-URL: Issues, https://github.com/send21io/send21-haystack/issues
|
|
7
|
+
Author-email: send21 <info@send21.io>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agents,haystack,mcp,payments,send21
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: haystack-ai>=2.0
|
|
13
|
+
Requires-Dist: mcp>=1.2
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# send21-haystack
|
|
19
|
+
|
|
20
|
+
A Haystack 2.x component that asks send21 to prepare a payment request and returns a pay link, plus a webhook signature helper.
|
|
21
|
+
|
|
22
|
+
This is a community package. It is **not an official deepset integration**.
|
|
23
|
+
|
|
24
|
+
send21 prepares payment instructions. A person opens the pay link and signs in their own wallet; funds go straight from the payer's wallet to the receiver's wallet. send21 never holds keys or funds, never signs and never broadcasts. Use an API key with the `drafts:write` scope only. That key can prepare a payment request but cannot move funds.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
Not on PyPI yet. Install from git:
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
pip install git+https://github.com/send21io/send21-haystack
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage in a pipeline
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from haystack import Pipeline
|
|
38
|
+
from send21_haystack import Send21PaymentRequest
|
|
39
|
+
|
|
40
|
+
# reads SEND21_API_KEY from the environment (scope: drafts:write)
|
|
41
|
+
payment = Send21PaymentRequest(currency="USDC", network="base", address="0xYourReceivingAddress")
|
|
42
|
+
|
|
43
|
+
p = Pipeline()
|
|
44
|
+
p.add_component("payment", payment)
|
|
45
|
+
out = p.run({"payment": {"amount": 1.50, "fiat_currency": "USD",
|
|
46
|
+
"order_id": "run-42", "memo": "agent top-up"}})
|
|
47
|
+
print(out["payment"]["pay_link"]) # https://send21.io/p/...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Outputs: `id`, `pay_link`, `raw` (the full tool result).
|
|
51
|
+
|
|
52
|
+
Offline: pass `client=MockSend21Client()` to get deterministic fake ids with no network. See `examples/pipeline.py`.
|
|
53
|
+
|
|
54
|
+
## What it sends
|
|
55
|
+
|
|
56
|
+
The component calls the send21 MCP server at `https://send21.io/mcp` (Streamable HTTP, `Authorization: Bearer <key>`), tool `create_payment_request`, with these fields only:
|
|
57
|
+
|
|
58
|
+
`fiatCurrency`, `fiatAmount`, `options: [{currency, network, address}]`, `orderId`, `memo`, `expiryHours` (null if unset), `idempotencyKey`.
|
|
59
|
+
|
|
60
|
+
It reads `id` and `payPath` from the result; the pay link is `https://send21.io` + `payPath`. Arguments are checked against the server's live `inputSchema` before the call.
|
|
61
|
+
|
|
62
|
+
## Webhooks
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from send21_haystack import verify_send21_signature
|
|
66
|
+
|
|
67
|
+
ok = verify_send21_signature(raw_body_bytes, headers.get("X-Send21-Signature"), signing_secret)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`X-Send21-Signature` is `sha256=<lowercase hex HMAC-SHA256 of the raw body>` keyed with the endpoint signing secret. Check the raw bytes before parsing. Store `X-Send21-Delivery` and ignore repeats; deliveries can be retried and arrive out of order. A stdlib receiver is in `examples/webhook_receiver.py`.
|
|
71
|
+
|
|
72
|
+
## Tests
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
pip install -e ".[dev]" && pytest
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Tests run fully offline (the MCP client is mocked).
|
|
79
|
+
|
|
80
|
+
## Unverified
|
|
81
|
+
|
|
82
|
+
Not confirmed against the live API yet; check https://send21.io/docs and the live MCP `tools/list` first.
|
|
83
|
+
|
|
84
|
+
- The `status` and `expiresAt` values in the `create_payment_request` response, and the exact `payPath` format. Only `id` and `payPath` are relied on.
|
|
85
|
+
- Exact accepted `currency` and `network` names for `options`; the examples use `USDC` / `base`.
|
|
86
|
+
- Whether API keys can target testnet. Assume mainnet; use small amounts.
|
|
87
|
+
- Webhook payload fields beyond `event` and `data.orderId` (for example `paymentRequestId`, `fiatAmount`).
|
|
88
|
+
- Whether a key's scopes can be read through the API. Create the key with `drafts:write` only.
|
|
89
|
+
|
|
90
|
+
## Related
|
|
91
|
+
|
|
92
|
+
- Agent spend-cap handoff example: https://github.com/send21io/send21-over-budget-handoff
|
|
93
|
+
- More examples: https://github.com/send21io/send21-examples
|
|
94
|
+
- Docs: https://send21.io/docs
|
|
95
|
+
|
|
96
|
+
## License and contact
|
|
97
|
+
|
|
98
|
+
MIT. Questions: info@send21.io
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
send21_haystack/__init__.py,sha256=4KukQgGASF_fY_mRI-taPkU_rw7kvLNJVqvgX8Dcf04,404
|
|
2
|
+
send21_haystack/client.py,sha256=7TY4vJxw-GMxsfANDvN9k4HWOvvz1gFXHFgWWcNFj2A,5101
|
|
3
|
+
send21_haystack/component.py,sha256=Wq44p1A1cma71wrBzkmdGv5gRH6p8FRsKXfBI9P0qQ4,3134
|
|
4
|
+
send21_haystack/webhook.py,sha256=SaibRu5aXQZHvG5srUXZaqzpaSzuR2MxgZQv9z5ZQo4,1043
|
|
5
|
+
send21_haystack-0.1.0.dist-info/METADATA,sha256=rPHaysQxZZN4tL91LZtad8EplsiqIxujvtvavlYhkQ4,4038
|
|
6
|
+
send21_haystack-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
send21_haystack-0.1.0.dist-info/licenses/LICENSE,sha256=_sIkanGpKqb_AgEqV25-w5X9loAc_g6r7DYjJc4lKWI,1063
|
|
8
|
+
send21_haystack-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 send21
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|