swig-developer-sdk 0.6.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,163 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ from dataclasses import dataclass
6
+ from typing import Literal, Protocol
7
+ from urllib.parse import parse_qs, urlencode, urljoin, urlparse
8
+
9
+ from .common import JsonObject
10
+
11
+ DEFAULT_ONE_BUSINESS_URL = "https://swig-one-business.vercel.app"
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class OneBusinessGrantAccessResult:
16
+ status: str
17
+ swig_pubkey: str
18
+ wallet_address: str
19
+ role_id: int
20
+ authority_public_key: str
21
+ signature: str | None = None
22
+ state: str | None = None
23
+
24
+
25
+ class OneBusinessGrantAccessCallbackError(Exception):
26
+ def __init__(
27
+ self,
28
+ *,
29
+ code: str,
30
+ description: str | None = None,
31
+ state: str | None = None,
32
+ ) -> None:
33
+ super().__init__(description or code)
34
+ self.code = code
35
+ self.description = description
36
+ self.state = state
37
+
38
+
39
+ class OneBusinessLocation(Protocol):
40
+ def assign(self, url: str) -> None: ...
41
+
42
+ def replace(self, url: str) -> None: ...
43
+
44
+
45
+ def build_one_business_grant_access_url(
46
+ *,
47
+ swig_pubkey: str,
48
+ authority_public_key: str,
49
+ actions: list[JsonObject] | None = None,
50
+ app_name: str | None = None,
51
+ one_business_url: str = DEFAULT_ONE_BUSINESS_URL,
52
+ redirect_uri: str | None = None,
53
+ state: str | None = None,
54
+ ) -> str:
55
+ base_url = one_business_url.strip()
56
+ if not base_url:
57
+ raise ValueError("one_business_url is required")
58
+ if not swig_pubkey.strip() or not authority_public_key.strip():
59
+ raise ValueError("swig_pubkey and authority_public_key are required")
60
+ query: dict[str, str] = {
61
+ "swig_pubkey": swig_pubkey.strip(),
62
+ "authority_public_key": authority_public_key.strip(),
63
+ }
64
+ if app_name is not None:
65
+ query["app_name"] = app_name
66
+ if redirect_uri is not None:
67
+ query["redirect_uri"] = redirect_uri
68
+ if state is not None:
69
+ query["state"] = state
70
+ if actions is not None:
71
+ encoded = base64.urlsafe_b64encode(
72
+ json.dumps(actions, separators=(",", ":")).encode("utf-8")
73
+ ).decode("ascii")
74
+ query["actions"] = encoded.rstrip("=")
75
+ normalized_base = base_url if base_url.endswith("/") else f"{base_url}/"
76
+ return f"{urljoin(normalized_base, 'authorize/grant-access')}?{urlencode(query)}"
77
+
78
+
79
+ def redirect_to_one_business_grant_access(
80
+ *,
81
+ location: OneBusinessLocation,
82
+ swig_pubkey: str,
83
+ authority_public_key: str,
84
+ actions: list[JsonObject] | None = None,
85
+ app_name: str | None = None,
86
+ one_business_url: str = DEFAULT_ONE_BUSINESS_URL,
87
+ redirect_uri: str | None = None,
88
+ state: str | None = None,
89
+ mode: Literal["assign", "replace"] = "assign",
90
+ ) -> None:
91
+ url = build_one_business_grant_access_url(
92
+ swig_pubkey=swig_pubkey,
93
+ authority_public_key=authority_public_key,
94
+ actions=actions,
95
+ app_name=app_name,
96
+ one_business_url=one_business_url,
97
+ redirect_uri=redirect_uri,
98
+ state=state,
99
+ )
100
+ if mode == "replace":
101
+ location.replace(url)
102
+ return
103
+ location.assign(url)
104
+
105
+
106
+ def complete_one_business_grant_access(
107
+ callback_url: str,
108
+ ) -> OneBusinessGrantAccessResult:
109
+ params = parse_qs(urlparse(callback_url).query, keep_blank_values=True)
110
+ error_code = _read_param(params, "error")
111
+ state = _read_param(params, "state")
112
+ if error_code:
113
+ raise OneBusinessGrantAccessCallbackError(
114
+ code=error_code,
115
+ description=_read_param(params, "error_description"),
116
+ state=state,
117
+ )
118
+ swig_pubkey = _required_param(params, "swig_pubkey")
119
+ wallet_address = _required_param(params, "wallet_address")
120
+ authority_public_key = _required_param(params, "authority_public_key")
121
+ role_id_value = _required_param(params, "role_id")
122
+ try:
123
+ role_id = int(role_id_value)
124
+ except ValueError as parse_error:
125
+ raise ValueError(
126
+ "Grant access callback has an invalid role_id"
127
+ ) from parse_error
128
+ if role_id < 0:
129
+ raise ValueError("Grant access callback has an invalid role_id")
130
+ return OneBusinessGrantAccessResult(
131
+ status=(
132
+ _read_param(params, "status")
133
+ or _read_param(params, "grant_status")
134
+ or "granted"
135
+ ),
136
+ swig_pubkey=swig_pubkey,
137
+ wallet_address=wallet_address,
138
+ role_id=role_id,
139
+ authority_public_key=authority_public_key,
140
+ signature=_read_param(params, "signature"),
141
+ state=state,
142
+ )
143
+
144
+
145
+ def _required_param(params: dict[str, list[str]], name: str) -> str:
146
+ value = _read_param(params, name)
147
+ if not value:
148
+ raise ValueError(f"Grant access callback is missing {name}")
149
+ return value
150
+
151
+
152
+ def _read_param(params: dict[str, list[str]], name: str) -> str | None:
153
+ camel = _snake_to_camel(name)
154
+ for key in (name, camel):
155
+ values = params.get(key)
156
+ if values and values[0].strip():
157
+ return values[0].strip()
158
+ return None
159
+
160
+
161
+ def _snake_to_camel(value: str) -> str:
162
+ head, *tail = value.split("_")
163
+ return head + "".join(part.capitalize() for part in tail)
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import inspect
5
+ import struct
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from typing import TypeAlias
9
+
10
+ from .signing import PasskeySigningFn, PasskeySigningResult
11
+
12
+ R1_AUTHENTICATION_TYPE_WEBAUTHN_RAW_CLIENT_DATA_JSON = 2
13
+ SECP256R1_SCALAR_SIZE = 32
14
+ P256_ORDER = int(
15
+ "ffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551",
16
+ 16,
17
+ )
18
+ P256_HALF_ORDER = P256_ORDER >> 1
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class WebAuthnAssertion:
23
+ authenticator_data: bytes
24
+ client_data_json: bytes
25
+ signature: bytes
26
+
27
+
28
+ WebAuthnAssertionFn: TypeAlias = Callable[
29
+ [bytes], WebAuthnAssertion | Awaitable[WebAuthnAssertion]
30
+ ]
31
+
32
+
33
+ def create_secp256r1_passkey_signing_fn(
34
+ get_assertion: WebAuthnAssertionFn,
35
+ ) -> PasskeySigningFn:
36
+ async def sign(message: bytes) -> PasskeySigningResult:
37
+ assertion = get_assertion(message)
38
+ if inspect.isawaitable(assertion):
39
+ assertion = await assertion
40
+ client_data_hash = hashlib.sha256(assertion.client_data_json).digest()
41
+ return PasskeySigningResult(
42
+ signature=secp256r1_der_to_raw_signature(assertion.signature),
43
+ prefix=_raw_webauthn_prefix(
44
+ assertion.client_data_json,
45
+ assertion.authenticator_data,
46
+ ),
47
+ message=assertion.authenticator_data + client_data_hash,
48
+ )
49
+
50
+ return sign
51
+
52
+
53
+ def secp256r1_der_to_raw_signature(der_signature: bytes) -> bytes:
54
+ r, s = _parse_der_signature(der_signature)
55
+ if s > P256_HALF_ORDER:
56
+ s = P256_ORDER - s
57
+ return r.to_bytes(SECP256R1_SCALAR_SIZE, "big") + s.to_bytes(
58
+ SECP256R1_SCALAR_SIZE, "big"
59
+ )
60
+
61
+
62
+ def _raw_webauthn_prefix(client_json: bytes, auth_data: bytes) -> bytes:
63
+ if len(auth_data) > 0xFFFF:
64
+ raise ValueError("WebAuthn authenticatorData is too large")
65
+ if len(client_json) > 0xFFFF:
66
+ raise ValueError("WebAuthn clientDataJSON is too large")
67
+ return b"".join(
68
+ (
69
+ struct.pack(
70
+ "<HH",
71
+ R1_AUTHENTICATION_TYPE_WEBAUTHN_RAW_CLIENT_DATA_JSON,
72
+ len(auth_data),
73
+ ),
74
+ auth_data,
75
+ struct.pack("<H", len(client_json)),
76
+ client_json,
77
+ )
78
+ )
79
+
80
+
81
+ def _parse_der_signature(value: bytes) -> tuple[int, int]:
82
+ offset = 0
83
+ if not value or value[offset] != 0x30:
84
+ raise ValueError("Invalid secp256r1 DER signature")
85
+ sequence_length, offset = _read_der_length(value, offset + 1)
86
+ if offset + sequence_length != len(value):
87
+ raise ValueError("Invalid secp256r1 DER signature length")
88
+ r_bytes, offset = _read_der_integer(value, offset)
89
+ s_bytes, offset = _read_der_integer(value, offset)
90
+ if offset != len(value):
91
+ raise ValueError("Invalid secp256r1 DER signature trailing data")
92
+ return int.from_bytes(r_bytes, "big"), int.from_bytes(s_bytes, "big")
93
+
94
+
95
+ def _read_der_integer(value: bytes, offset: int) -> tuple[bytes, int]:
96
+ if offset >= len(value) or value[offset] != 0x02:
97
+ raise ValueError("Invalid secp256r1 DER signature integer")
98
+ length, start = _read_der_length(value, offset + 1)
99
+ end = start + length
100
+ if length == 0 or end > len(value):
101
+ raise ValueError("Invalid secp256r1 DER signature integer length")
102
+ integer = value[start:end]
103
+ if integer[0] == 0 and len(integer) > 1:
104
+ integer = integer[1:]
105
+ if len(integer) > SECP256R1_SCALAR_SIZE:
106
+ raise ValueError("Secp256r1 signature integer is too large")
107
+ return integer, end
108
+
109
+
110
+ def _read_der_length(value: bytes, offset: int) -> tuple[int, int]:
111
+ if offset >= len(value):
112
+ raise ValueError("Invalid secp256r1 DER signature length")
113
+ first = value[offset]
114
+ if first < 0x80:
115
+ return first, offset + 1
116
+ length_bytes = first & 0x7F
117
+ if length_bytes == 0 or length_bytes > 2:
118
+ raise ValueError("Unsupported secp256r1 DER signature length")
119
+ end = offset + 1 + length_bytes
120
+ if end > len(value):
121
+ raise ValueError("Invalid secp256r1 DER signature length")
122
+ return int.from_bytes(value[offset + 1 : end], "big"), end
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Literal, TypeAlias
6
+ from urllib.parse import urlencode
7
+
8
+ from .common import Network
9
+ from .core import HttpClient
10
+
11
+ PaymasterKind: TypeAlias = Literal["api", "idp", "unspecified"]
12
+ PaymasterBalanceKind: TypeAlias = Literal["api", "idp"]
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class PaymasterBalance:
17
+ configured: bool
18
+ kind: PaymasterKind
19
+ id: str
20
+ address: str
21
+ label: str
22
+ balance_lamports: str
23
+ balance_sol: float
24
+
25
+
26
+ class PaymasterClient:
27
+ def __init__(
28
+ self,
29
+ http: HttpClient,
30
+ default_network: Network | None = None,
31
+ ) -> None:
32
+ self._http = http
33
+ self._default_network = default_network
34
+
35
+ async def get_balance(
36
+ self,
37
+ *,
38
+ network: Network | None = None,
39
+ kind: PaymasterBalanceKind | None = None,
40
+ ) -> PaymasterBalance:
41
+ query: dict[str, str] = {}
42
+ resolved_network = network or self._default_network
43
+ if resolved_network is not None:
44
+ query["network"] = resolved_network
45
+ if kind is not None:
46
+ query["kind"] = (
47
+ "PAYMASTER_KIND_API" if kind == "api" else "PAYMASTER_KIND_IDP"
48
+ )
49
+ suffix = f"?{urlencode(query)}" if query else ""
50
+ return normalize_paymaster_balance(
51
+ await self._http.get(f"/paymaster/balance{suffix}")
52
+ )
53
+
54
+ async def get_idp_balance(
55
+ self,
56
+ *,
57
+ network: Network | None = None,
58
+ ) -> PaymasterBalance:
59
+ return await self.get_balance(network=network, kind="idp")
60
+
61
+
62
+ def normalize_paymaster_balance(value: object) -> PaymasterBalance:
63
+ if not isinstance(value, Mapping):
64
+ raise ValueError("Paymaster balance response must be an object")
65
+ configured = value.get("configured", False)
66
+ if not isinstance(configured, bool):
67
+ raise ValueError("Paymaster balance response has invalid configured")
68
+ return PaymasterBalance(
69
+ configured=configured,
70
+ kind=_normalize_kind(value.get("kind")),
71
+ id=_optional_string(value.get("id")) or "",
72
+ address=_optional_string(value.get("address")) or "",
73
+ label=_optional_string(value.get("label")) or "",
74
+ balance_lamports=str(
75
+ value.get("balanceLamports", value.get("balance_lamports", "0"))
76
+ ),
77
+ balance_sol=_number(
78
+ value.get("balanceSol", value.get("balance_sol", 0)),
79
+ "balanceSol",
80
+ ),
81
+ )
82
+
83
+
84
+ def _normalize_kind(value: object) -> PaymasterKind:
85
+ if value in ("api", "PAYMASTER_KIND_API", 1):
86
+ return "api"
87
+ if value in ("idp", "PAYMASTER_KIND_IDP", 2):
88
+ return "idp"
89
+ if value in (None, "unspecified", "PAYMASTER_KIND_UNSPECIFIED", 0):
90
+ return "unspecified"
91
+ raise ValueError("Paymaster balance response has invalid kind")
92
+
93
+
94
+ def _number(value: object, field: str) -> float:
95
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
96
+ return float(value)
97
+ if isinstance(value, str):
98
+ try:
99
+ return float(value)
100
+ except ValueError:
101
+ pass
102
+ raise ValueError(f"Paymaster balance response is missing {field}")
103
+
104
+
105
+ def _optional_string(value: object) -> str | None:
106
+ return value if isinstance(value, str) else None