inflowpay 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.
- inflowpay/__init__.py +10 -0
- inflowpay/_runtime.py +271 -0
- inflowpay/errors.py +21 -0
- inflowpay/mpp/__init__.py +35 -0
- inflowpay/mpp/_pympp.py +60 -0
- inflowpay/mpp/_requests.py +105 -0
- inflowpay/mpp/_wire.py +198 -0
- inflowpay/mpp/buyer.py +271 -0
- inflowpay/mpp/seller.py +255 -0
- inflowpay/options.py +16 -0
- inflowpay/py.typed +0 -0
- inflowpay/x402/__init__.py +52 -0
- inflowpay/x402/_core.py +104 -0
- inflowpay/x402/_payment.py +185 -0
- inflowpay/x402/_seller.py +172 -0
- inflowpay/x402/buyer.py +383 -0
- inflowpay/x402/eip7702.py +331 -0
- inflowpay/x402/facilitator.py +146 -0
- inflowpay/x402/seller.py +256 -0
- inflowpay-0.1.0.dist-info/METADATA +768 -0
- inflowpay-0.1.0.dist-info/RECORD +23 -0
- inflowpay-0.1.0.dist-info/WHEEL +4 -0
- inflowpay-0.1.0.dist-info/licenses/LICENSE +21 -0
inflowpay/__init__.py
ADDED
inflowpay/_runtime.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
import random
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Self, TypeVar
|
|
8
|
+
from urllib.parse import quote, urlsplit
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from .errors import InflowApiError
|
|
13
|
+
from .options import ClientOptions
|
|
14
|
+
|
|
15
|
+
_RETRY_STATUSES = {429, 502, 503, 504}
|
|
16
|
+
_SENSITIVE = {
|
|
17
|
+
"authorization",
|
|
18
|
+
"proxyauthorization",
|
|
19
|
+
"cookie",
|
|
20
|
+
"setcookie",
|
|
21
|
+
"xapikey",
|
|
22
|
+
"apikey",
|
|
23
|
+
"accesstoken",
|
|
24
|
+
"refreshtoken",
|
|
25
|
+
"privatekey",
|
|
26
|
+
"secretkey",
|
|
27
|
+
"password",
|
|
28
|
+
"credential",
|
|
29
|
+
"signature",
|
|
30
|
+
"paymentsignature",
|
|
31
|
+
"xpayment",
|
|
32
|
+
}
|
|
33
|
+
_T = TypeVar("_T")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _sensitive(key: str) -> bool:
|
|
37
|
+
return key.lower().replace("-", "").replace("_", "") in _SENSITIVE
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _redact(value: object, secrets: tuple[str, ...]) -> object:
|
|
41
|
+
if isinstance(value, str):
|
|
42
|
+
for secret in secrets:
|
|
43
|
+
value = value.replace(secret, "[REDACTED]")
|
|
44
|
+
return value
|
|
45
|
+
if isinstance(value, dict):
|
|
46
|
+
return {
|
|
47
|
+
key: "[REDACTED]" if _sensitive(key) else _redact(item, secrets)
|
|
48
|
+
for key, item in value.items()
|
|
49
|
+
}
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
return [_redact(item, secrets) for item in value]
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _text(body: Mapping[str, object], key: str, fallback: str) -> str:
|
|
56
|
+
value = body.get(key)
|
|
57
|
+
return value if isinstance(value, str) and value else fallback
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _error(path: str, response: httpx.Response, secrets: tuple[str, ...]) -> InflowApiError:
|
|
61
|
+
body = _redact(_parse(response), secrets)
|
|
62
|
+
code, message = "UNEXPECTED_ERROR", "request failed"
|
|
63
|
+
if isinstance(body, dict):
|
|
64
|
+
code = _text(body, "code", code)
|
|
65
|
+
message = _text(body, "message", message)
|
|
66
|
+
entries = body.get("errors")
|
|
67
|
+
if isinstance(entries, list) and entries and isinstance(entries[0], dict):
|
|
68
|
+
code = _text(entries[0], "code", code)
|
|
69
|
+
message = _text(entries[0], "message", message)
|
|
70
|
+
message = _text(body, "detail", message)
|
|
71
|
+
headers = {
|
|
72
|
+
key: str(_redact(value, secrets))
|
|
73
|
+
for key, value in response.headers.items()
|
|
74
|
+
if not _sensitive(key)
|
|
75
|
+
}
|
|
76
|
+
return InflowApiError(
|
|
77
|
+
message,
|
|
78
|
+
code=code,
|
|
79
|
+
http_status=response.status_code,
|
|
80
|
+
endpoint=path,
|
|
81
|
+
body=body,
|
|
82
|
+
headers=headers,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse(response: httpx.Response) -> object:
|
|
87
|
+
if not response.content:
|
|
88
|
+
return None
|
|
89
|
+
try:
|
|
90
|
+
return json.loads(response.content)
|
|
91
|
+
except (ValueError, UnicodeDecodeError):
|
|
92
|
+
return response.text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _positive(value: float) -> None:
|
|
96
|
+
if not math.isfinite(value) or value <= 0:
|
|
97
|
+
raise ValueError("timeout and poll interval must be finite and positive")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _credential(value: str) -> None:
|
|
101
|
+
if not value or any(ord(char) <= 32 or ord(char) >= 127 for char in value):
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"credentials must be nonempty ASCII without whitespace or control characters"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Client:
|
|
108
|
+
def __init__(self, options: ClientOptions) -> None:
|
|
109
|
+
if options.environment not in ("production", "sandbox"):
|
|
110
|
+
raise ValueError("unknown InFlow environment")
|
|
111
|
+
base = options.base_url or (
|
|
112
|
+
"https://api.inflowpay.ai"
|
|
113
|
+
if options.environment == "production"
|
|
114
|
+
else "https://sandbox.inflowpay.ai"
|
|
115
|
+
)
|
|
116
|
+
parsed = urlsplit(base)
|
|
117
|
+
if (
|
|
118
|
+
parsed.scheme not in ("http", "https")
|
|
119
|
+
or not parsed.hostname
|
|
120
|
+
or parsed.username is not None
|
|
121
|
+
or parsed.password is not None
|
|
122
|
+
or "?" in base
|
|
123
|
+
or "#" in base
|
|
124
|
+
):
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"base URL must be HTTP or HTTPS without credentials, query, or fragment"
|
|
127
|
+
)
|
|
128
|
+
if options.api_key is not None:
|
|
129
|
+
_credential(options.api_key)
|
|
130
|
+
if options.access_token is not None:
|
|
131
|
+
raise ValueError("API key and access token provider are mutually exclusive")
|
|
132
|
+
_positive(options.timeout)
|
|
133
|
+
self.base_url = base.rstrip("/")
|
|
134
|
+
self._options = options
|
|
135
|
+
self._http = httpx.AsyncClient(
|
|
136
|
+
transport=options.transport,
|
|
137
|
+
follow_redirects=False,
|
|
138
|
+
timeout=None,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def __aenter__(self) -> Self:
|
|
142
|
+
await self._http.__aenter__()
|
|
143
|
+
return self
|
|
144
|
+
|
|
145
|
+
async def __aexit__(
|
|
146
|
+
self,
|
|
147
|
+
exc_type: type[BaseException] | None,
|
|
148
|
+
exc: BaseException | None,
|
|
149
|
+
traceback: TracebackType | None,
|
|
150
|
+
) -> None:
|
|
151
|
+
await self.aclose()
|
|
152
|
+
|
|
153
|
+
async def aclose(self) -> None:
|
|
154
|
+
await self._http.aclose()
|
|
155
|
+
|
|
156
|
+
async def request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
*,
|
|
161
|
+
body: object = None,
|
|
162
|
+
headers: Mapping[str, str] | None = None,
|
|
163
|
+
retries: int = 0,
|
|
164
|
+
) -> object:
|
|
165
|
+
# Retry permission belongs to the protocol operation, not the HTTP method.
|
|
166
|
+
if not path.startswith("/") or path.startswith("//") or "#" in path:
|
|
167
|
+
raise ValueError("expected an InFlow API path beginning with one slash")
|
|
168
|
+
if retries < 0:
|
|
169
|
+
raise ValueError("retries must not be negative")
|
|
170
|
+
extra = dict(headers or {})
|
|
171
|
+
if any(key.lower() in ("authorization", "x-api-key", "cookie") for key in extra):
|
|
172
|
+
raise ValueError("request headers must not override authentication")
|
|
173
|
+
content = None if body is None else json.dumps(body, allow_nan=False).encode()
|
|
174
|
+
attempt = 0
|
|
175
|
+
while True:
|
|
176
|
+
error = await self._attempt(method, path, content, extra)
|
|
177
|
+
if not isinstance(error, InflowApiError):
|
|
178
|
+
return error
|
|
179
|
+
if attempt >= min(retries, 3) or (
|
|
180
|
+
error.http_status not in _RETRY_STATUSES and error.http_status != 0
|
|
181
|
+
):
|
|
182
|
+
raise error
|
|
183
|
+
await asyncio.sleep(0.2 * 2**attempt * (1 + random.random() / 4))
|
|
184
|
+
attempt += 1
|
|
185
|
+
|
|
186
|
+
async def _attempt(
|
|
187
|
+
self,
|
|
188
|
+
method: str,
|
|
189
|
+
path: str,
|
|
190
|
+
content: bytes | None,
|
|
191
|
+
extra: dict[str, str],
|
|
192
|
+
) -> object:
|
|
193
|
+
await asyncio.sleep(0)
|
|
194
|
+
token = self._options.api_key
|
|
195
|
+
# Provider failures are application errors, not retryable transport failures.
|
|
196
|
+
if self._options.access_token is not None:
|
|
197
|
+
token = await self._options.access_token()
|
|
198
|
+
_credential(token)
|
|
199
|
+
# A provider can request cancellation without yielding before it returns.
|
|
200
|
+
await asyncio.sleep(0)
|
|
201
|
+
headers = httpx.Headers(extra)
|
|
202
|
+
headers["Accept"] = "application/json"
|
|
203
|
+
headers["User-Agent"] = "inflowpay (python)"
|
|
204
|
+
if content is not None:
|
|
205
|
+
headers["Content-Type"] = "application/json"
|
|
206
|
+
if token is not None:
|
|
207
|
+
if self._options.api_key is not None:
|
|
208
|
+
headers["X-API-KEY"] = token
|
|
209
|
+
else:
|
|
210
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
211
|
+
# A directly constructed Request does not inherit the HTTPX client's cookie jar.
|
|
212
|
+
request = httpx.Request(method, self.base_url + path, content=content, headers=headers)
|
|
213
|
+
try:
|
|
214
|
+
async with asyncio.timeout(self._options.timeout):
|
|
215
|
+
response = await self._http.send(request, follow_redirects=False)
|
|
216
|
+
task = asyncio.current_task()
|
|
217
|
+
assert task is not None
|
|
218
|
+
if task.cancelling():
|
|
219
|
+
await asyncio.sleep(0)
|
|
220
|
+
except (TimeoutError, httpx.TimeoutException):
|
|
221
|
+
return InflowApiError(
|
|
222
|
+
"request timed out",
|
|
223
|
+
code="TIMEOUT",
|
|
224
|
+
http_status=0,
|
|
225
|
+
endpoint=path,
|
|
226
|
+
)
|
|
227
|
+
except (httpx.TransportError, httpx.DecodingError):
|
|
228
|
+
return InflowApiError(
|
|
229
|
+
"network request failed",
|
|
230
|
+
code="NETWORK_ERROR",
|
|
231
|
+
http_status=0,
|
|
232
|
+
endpoint=path,
|
|
233
|
+
)
|
|
234
|
+
if response.is_success:
|
|
235
|
+
return _parse(response)
|
|
236
|
+
return _error(path, response, (token,) if token else ())
|
|
237
|
+
|
|
238
|
+
async def cancel_approval(self, approval_id: str) -> None:
|
|
239
|
+
async def cancel() -> None:
|
|
240
|
+
try:
|
|
241
|
+
async with asyncio.timeout(5):
|
|
242
|
+
await self.request(
|
|
243
|
+
"POST", f"/v1/approvals/{quote(approval_id, safe='')}/cancel"
|
|
244
|
+
)
|
|
245
|
+
except Exception:
|
|
246
|
+
# Cleanup must not replace the payment flow's original failure.
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
# Unlike Node fire-and-forget, await bounded cleanup before the application exits.
|
|
250
|
+
task = asyncio.create_task(cancel())
|
|
251
|
+
cancelled = False
|
|
252
|
+
while not task.done():
|
|
253
|
+
try:
|
|
254
|
+
await asyncio.shield(task)
|
|
255
|
+
except asyncio.CancelledError:
|
|
256
|
+
cancelled = True
|
|
257
|
+
if cancelled:
|
|
258
|
+
raise asyncio.CancelledError
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def poll(read: Callable[[], Awaitable[tuple[_T, bool, float]]], *, timeout: float) -> _T:
|
|
262
|
+
_positive(timeout)
|
|
263
|
+
# Protocol code interprets states and chooses which read failures may be retried.
|
|
264
|
+
async with asyncio.timeout(timeout):
|
|
265
|
+
while True:
|
|
266
|
+
value, done, interval = await read()
|
|
267
|
+
if done:
|
|
268
|
+
return value
|
|
269
|
+
if not math.isfinite(interval) or interval < 0:
|
|
270
|
+
raise ValueError("poll interval must be finite and nonnegative")
|
|
271
|
+
await asyncio.sleep(interval)
|
inflowpay/errors.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InflowApiError(Exception):
|
|
5
|
+
def __init__(
|
|
6
|
+
self,
|
|
7
|
+
message: str,
|
|
8
|
+
*,
|
|
9
|
+
code: str,
|
|
10
|
+
http_status: int,
|
|
11
|
+
endpoint: str,
|
|
12
|
+
body: object = None,
|
|
13
|
+
headers: Mapping[str, str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.code = code
|
|
17
|
+
self.http_status = http_status
|
|
18
|
+
self.endpoint = endpoint
|
|
19
|
+
self.body = body
|
|
20
|
+
self.headers = dict(headers or {})
|
|
21
|
+
self.request_id = self.headers.get("x-request-id")
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""InFlow MPP wire codecs. Decoding does not authenticate or settle a payment."""
|
|
2
|
+
|
|
3
|
+
from ._pympp import from_pympp_challenge, to_pympp_challenge
|
|
4
|
+
from ._requests import validate_payload, validate_request
|
|
5
|
+
from ._wire import (
|
|
6
|
+
JsonValue,
|
|
7
|
+
MppCodecError,
|
|
8
|
+
WireObject,
|
|
9
|
+
canonicalize,
|
|
10
|
+
decode,
|
|
11
|
+
decode_credential,
|
|
12
|
+
decode_receipt,
|
|
13
|
+
encode,
|
|
14
|
+
parse_challenge_header,
|
|
15
|
+
parse_challenge_headers,
|
|
16
|
+
render_challenge_header,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"JsonValue",
|
|
21
|
+
"MppCodecError",
|
|
22
|
+
"WireObject",
|
|
23
|
+
"canonicalize",
|
|
24
|
+
"decode",
|
|
25
|
+
"decode_credential",
|
|
26
|
+
"decode_receipt",
|
|
27
|
+
"encode",
|
|
28
|
+
"from_pympp_challenge",
|
|
29
|
+
"parse_challenge_header",
|
|
30
|
+
"parse_challenge_headers",
|
|
31
|
+
"render_challenge_header",
|
|
32
|
+
"to_pympp_challenge",
|
|
33
|
+
"validate_payload",
|
|
34
|
+
"validate_request",
|
|
35
|
+
]
|
inflowpay/mpp/_pympp.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from dataclasses import dataclass, field, fields, replace
|
|
3
|
+
|
|
4
|
+
from mpp import Challenge, ChallengeEcho
|
|
5
|
+
|
|
6
|
+
from ._wire import (
|
|
7
|
+
MppCodecError,
|
|
8
|
+
WireObject,
|
|
9
|
+
decode,
|
|
10
|
+
object_value,
|
|
11
|
+
render_challenge_header,
|
|
12
|
+
string,
|
|
13
|
+
validate_challenge,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class _WireChallenge(Challenge):
|
|
19
|
+
_wire: WireObject = field(default_factory=dict, repr=False, compare=False)
|
|
20
|
+
|
|
21
|
+
def to_echo(self) -> ChallengeEcho:
|
|
22
|
+
if "opaque" not in self._wire:
|
|
23
|
+
return super().to_echo()
|
|
24
|
+
opaque = string(self._wire["opaque"])
|
|
25
|
+
if decode(opaque) != self.opaque:
|
|
26
|
+
raise MppCodecError("pympp opaque data differs from its encoded value")
|
|
27
|
+
# pympp otherwise re-encodes opaque. Keep the original challenge-bound bytes.
|
|
28
|
+
return replace(super().to_echo(), opaque=opaque)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def to_pympp_challenge(value: WireObject) -> Challenge:
|
|
32
|
+
# Parse only at this boundary; the wire object retains extensions that pympp cannot represent.
|
|
33
|
+
validate_challenge(value)
|
|
34
|
+
parsed = Challenge.from_www_authenticate(render_challenge_header(value))
|
|
35
|
+
return _WireChallenge(
|
|
36
|
+
**{item.name: getattr(parsed, item.name) for item in fields(parsed)}, _wire=deepcopy(value)
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def from_pympp_challenge(value: Challenge) -> WireObject:
|
|
41
|
+
# request_b64 is signed input. Re-encoding the decoded request can invalidate its binding.
|
|
42
|
+
result: WireObject = {
|
|
43
|
+
**(deepcopy(value._wire) if isinstance(value, _WireChallenge) else {}),
|
|
44
|
+
"id": value.id,
|
|
45
|
+
"realm": value.realm,
|
|
46
|
+
"method": value.method,
|
|
47
|
+
"intent": value.intent,
|
|
48
|
+
"request": value.request_b64,
|
|
49
|
+
}
|
|
50
|
+
for name in ("expires", "description", "digest", "header"):
|
|
51
|
+
item = getattr(value, name)
|
|
52
|
+
if item is not None:
|
|
53
|
+
result[name] = item
|
|
54
|
+
if value.opaque is not None:
|
|
55
|
+
# Converted challenges retain raw opaque bytes; native pympp challenges expose its echo.
|
|
56
|
+
result["opaque"] = value.to_echo().opaque
|
|
57
|
+
validate_challenge(result)
|
|
58
|
+
if object_value(decode(value.request_b64)) != value.request:
|
|
59
|
+
raise ValueError("pympp challenge request differs from its encoded request")
|
|
60
|
+
return deepcopy(result)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import re
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
|
|
5
|
+
from ._wire import MppCodecError, WireObject, object_value, string, timestamp
|
|
6
|
+
|
|
7
|
+
_UUID = r"[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}"
|
|
8
|
+
_ADDRESS = r"0x[0-9a-fA-F]{40}"
|
|
9
|
+
_MEMO = r"0x[0-9a-fA-F]{64}"
|
|
10
|
+
_INTEGER = r"(?:0|[1-9][0-9]*)"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _match(value: object, pattern: str) -> str:
|
|
14
|
+
result = string(value)
|
|
15
|
+
if not re.fullmatch(pattern, result):
|
|
16
|
+
raise MppCodecError("Invalid payment field format")
|
|
17
|
+
return result
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _optional_strings(data: WireObject, fields: tuple[str, ...], pattern: str = r"[\s\S]+") -> None:
|
|
21
|
+
for field in fields:
|
|
22
|
+
if field in data:
|
|
23
|
+
_match(data[field], pattern)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def validate_request(method: str, intent: str, value: object) -> WireObject:
|
|
27
|
+
request = object_value(value)
|
|
28
|
+
if method == "inflow" and intent in ("charge", "subscription"):
|
|
29
|
+
amount = _match(request.get("amount"), r"-?[0-9]+(?:\.[0-9]+)?")
|
|
30
|
+
string(request.get("currency"))
|
|
31
|
+
_optional_strings(request, ("recipient",), _UUID)
|
|
32
|
+
if "methodDetails" in request:
|
|
33
|
+
details = object_value(request["methodDetails"])
|
|
34
|
+
if "rail" in details and details["rail"] not in ("balance", "instrument"):
|
|
35
|
+
raise MppCodecError("Unsupported InFlow rail")
|
|
36
|
+
_optional_strings(details, ("instrumentId",), _UUID)
|
|
37
|
+
if intent == "subscription":
|
|
38
|
+
if amount.startswith("-") or not re.search("[1-9]", amount):
|
|
39
|
+
raise MppCodecError("Subscription amount must be positive")
|
|
40
|
+
unit, count = request.get("periodUnit"), request.get("periodCount")
|
|
41
|
+
if unit not in ("minute", "hour", "day", "week", "month", "quarter", "year"):
|
|
42
|
+
raise MppCodecError("Unsupported subscription period")
|
|
43
|
+
if (
|
|
44
|
+
isinstance(count, bool)
|
|
45
|
+
or not isinstance(count, (int, float))
|
|
46
|
+
or not 1 <= count <= 9007199254740991
|
|
47
|
+
or count % 1 != 0
|
|
48
|
+
):
|
|
49
|
+
raise MppCodecError("periodCount must be a positive safe integer")
|
|
50
|
+
if unit == "minute" and count < 5:
|
|
51
|
+
raise MppCodecError("Minute subscriptions require periodCount of at least five")
|
|
52
|
+
timestamp(request.get("subscriptionExpires"))
|
|
53
|
+
if "externalId" in request:
|
|
54
|
+
external = string(request["externalId"])
|
|
55
|
+
if not external.strip() or len(external.encode("utf-16-le")) // 2 > 128:
|
|
56
|
+
raise MppCodecError("externalId must contain 1 to 128 characters")
|
|
57
|
+
elif method == "tempo" and intent == "charge":
|
|
58
|
+
_match(request.get("amount"), _INTEGER)
|
|
59
|
+
_optional_strings(request, ("currency", "recipient"), _ADDRESS)
|
|
60
|
+
_optional_strings(request, ("description", "externalId"))
|
|
61
|
+
if "methodDetails" in request:
|
|
62
|
+
details = object_value(request["methodDetails"])
|
|
63
|
+
if "chainId" in details:
|
|
64
|
+
chain = details["chainId"]
|
|
65
|
+
if (
|
|
66
|
+
isinstance(chain, bool)
|
|
67
|
+
or not isinstance(chain, (int, float))
|
|
68
|
+
or (isinstance(chain, float) and not math.isfinite(chain))
|
|
69
|
+
):
|
|
70
|
+
raise MppCodecError("chainId must be a finite number")
|
|
71
|
+
if "feePayer" in details and type(details["feePayer"]) is not bool:
|
|
72
|
+
raise MppCodecError("feePayer must be a boolean")
|
|
73
|
+
_optional_strings(details, ("memo",), _MEMO)
|
|
74
|
+
if "supportedModes" in details:
|
|
75
|
+
modes = details["supportedModes"]
|
|
76
|
+
if not isinstance(modes, list) or any(
|
|
77
|
+
mode not in ("pull", "push") for mode in modes
|
|
78
|
+
):
|
|
79
|
+
raise MppCodecError("Unsupported Tempo submission mode")
|
|
80
|
+
if "splits" in details:
|
|
81
|
+
splits = details["splits"]
|
|
82
|
+
if not isinstance(splits, list):
|
|
83
|
+
raise MppCodecError("splits must be an array")
|
|
84
|
+
for value in splits:
|
|
85
|
+
split = object_value(value)
|
|
86
|
+
_match(split.get("amount"), _INTEGER)
|
|
87
|
+
_match(split.get("recipient"), _ADDRESS)
|
|
88
|
+
_optional_strings(split, ("memo",), _MEMO)
|
|
89
|
+
else:
|
|
90
|
+
raise MppCodecError("Unsupported payment method or intent")
|
|
91
|
+
return deepcopy(request)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_payload(method: str, value: object) -> WireObject:
|
|
95
|
+
payload = object_value(value)
|
|
96
|
+
if method == "tempo":
|
|
97
|
+
_optional_strings(payload, ("hash", "signature"), r"0x[0-9a-fA-F]+")
|
|
98
|
+
_optional_strings(payload, ("transactionId",))
|
|
99
|
+
kind = payload.get("type")
|
|
100
|
+
if kind not in ("hash", "transaction", "proof"):
|
|
101
|
+
raise MppCodecError("Unsupported Tempo credential type")
|
|
102
|
+
string(payload.get("hash" if kind == "hash" else "signature"))
|
|
103
|
+
elif method != "inflow":
|
|
104
|
+
raise MppCodecError("Unsupported payment method")
|
|
105
|
+
return deepcopy(payload)
|