walley-sdk 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.
- walley/__init__.py +88 -0
- walley/_transport.py +45 -0
- walley/auth.py +115 -0
- walley/client.py +206 -0
- walley/enums.py +45 -0
- walley/errors.py +71 -0
- walley/models/__init__.py +53 -0
- walley/models/_base.py +5 -0
- walley/models/balance.py +47 -0
- walley/models/fee.py +51 -0
- walley/models/ledger.py +16 -0
- walley/models/party.py +9 -0
- walley/models/preapproval.py +61 -0
- walley/models/prepared.py +72 -0
- walley/models/transaction.py +61 -0
- walley/models/transfer.py +25 -0
- walley/resources/__init__.py +21 -0
- walley/resources/_base.py +7 -0
- walley/resources/_tokens.py +33 -0
- walley/resources/balances.py +60 -0
- walley/resources/fees.py +47 -0
- walley/resources/ledger.py +106 -0
- walley/resources/party.py +16 -0
- walley/resources/preapprovals.py +58 -0
- walley/resources/transactions.py +240 -0
- walley/resources/transfers.py +155 -0
- walley/signer.py +202 -0
- walley_sdk-0.1.0.dist-info/METADATA +57 -0
- walley_sdk-0.1.0.dist-info/RECORD +30 -0
- walley_sdk-0.1.0.dist-info/WHEEL +4 -0
walley/__init__.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from .client import Walley
|
|
2
|
+
from .enums import (
|
|
3
|
+
PreapprovalStatus,
|
|
4
|
+
TransactionSource,
|
|
5
|
+
TransferStatus,
|
|
6
|
+
TxKind,
|
|
7
|
+
TxKindFilter,
|
|
8
|
+
)
|
|
9
|
+
from .errors import (
|
|
10
|
+
AuthError,
|
|
11
|
+
FeeDueError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
ServerError,
|
|
15
|
+
UnprocessableError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
WalleyError,
|
|
18
|
+
)
|
|
19
|
+
from .models import (
|
|
20
|
+
ActiveContract,
|
|
21
|
+
Balance,
|
|
22
|
+
Fee,
|
|
23
|
+
FeeDue,
|
|
24
|
+
FreeSends,
|
|
25
|
+
Holding,
|
|
26
|
+
MergeDelegation,
|
|
27
|
+
MergeDelegationState,
|
|
28
|
+
MergeEvent,
|
|
29
|
+
Party,
|
|
30
|
+
PreparedSubmission,
|
|
31
|
+
PreparedTransaction,
|
|
32
|
+
SignedMessage,
|
|
33
|
+
SubmitResult,
|
|
34
|
+
SubmittedTransaction,
|
|
35
|
+
Token,
|
|
36
|
+
Transaction,
|
|
37
|
+
TransactionPage,
|
|
38
|
+
Transfer,
|
|
39
|
+
TransferEvent,
|
|
40
|
+
TransferPreapproval,
|
|
41
|
+
TransferPreapprovalState,
|
|
42
|
+
)
|
|
43
|
+
from .signer import Signer
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"Walley",
|
|
47
|
+
"Signer",
|
|
48
|
+
# enums
|
|
49
|
+
"TransferStatus",
|
|
50
|
+
"TransactionSource",
|
|
51
|
+
"TxKind",
|
|
52
|
+
"TxKindFilter",
|
|
53
|
+
"PreapprovalStatus",
|
|
54
|
+
# models
|
|
55
|
+
"ActiveContract",
|
|
56
|
+
"Balance",
|
|
57
|
+
"Holding",
|
|
58
|
+
"Token",
|
|
59
|
+
"Party",
|
|
60
|
+
"Transfer",
|
|
61
|
+
"Transaction",
|
|
62
|
+
"TransactionPage",
|
|
63
|
+
"TransferEvent",
|
|
64
|
+
"MergeEvent",
|
|
65
|
+
"Fee",
|
|
66
|
+
"FeeDue",
|
|
67
|
+
"FreeSends",
|
|
68
|
+
"PreparedTransaction",
|
|
69
|
+
"PreparedSubmission",
|
|
70
|
+
"SubmittedTransaction",
|
|
71
|
+
"SubmitResult",
|
|
72
|
+
"SignedMessage",
|
|
73
|
+
"TransferPreapproval",
|
|
74
|
+
"TransferPreapprovalState",
|
|
75
|
+
"MergeDelegation",
|
|
76
|
+
"MergeDelegationState",
|
|
77
|
+
# errors
|
|
78
|
+
"WalleyError",
|
|
79
|
+
"AuthError",
|
|
80
|
+
"ValidationError",
|
|
81
|
+
"FeeDueError",
|
|
82
|
+
"UnprocessableError",
|
|
83
|
+
"NotFoundError",
|
|
84
|
+
"RateLimitError",
|
|
85
|
+
"ServerError",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
__version__ = "0.1.0"
|
walley/_transport.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .errors import error_from_response
|
|
8
|
+
|
|
9
|
+
# Response-envelope metadata flattened into every body by the server. Stripped
|
|
10
|
+
# so payloads (e.g. Commands from /transfers/prepare) can be echoed back clean.
|
|
11
|
+
ENVELOPE_KEYS = ("trace_id", "request_id", "timestamp")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Transport:
|
|
15
|
+
"""Issues requests against a fixed base URL and returns the body.
|
|
16
|
+
|
|
17
|
+
Success bodies are flat: the payload fields sit at the top level next to
|
|
18
|
+
``trace_id``/``request_id``/``timestamp``. Errors carry a ``message`` and
|
|
19
|
+
the HTTP status.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: httpx.Client, base_url: str) -> None:
|
|
23
|
+
self._client = client
|
|
24
|
+
self._base = base_url.rstrip("/")
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def base_url(self) -> str:
|
|
28
|
+
return self._base
|
|
29
|
+
|
|
30
|
+
def request(self, method: str, path: str, **kwargs: Any) -> dict:
|
|
31
|
+
response = self._client.request(method, self._base + path, **kwargs)
|
|
32
|
+
body = response.json() if response.content else {}
|
|
33
|
+
if response.status_code >= 400:
|
|
34
|
+
raise error_from_response(response.status_code, body)
|
|
35
|
+
return body
|
|
36
|
+
|
|
37
|
+
def get(self, path: str, **kwargs: Any) -> dict:
|
|
38
|
+
return self.request("GET", path, **kwargs)
|
|
39
|
+
|
|
40
|
+
def post(self, path: str, **kwargs: Any) -> dict:
|
|
41
|
+
return self.request("POST", path, **kwargs)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def strip_envelope(body: dict) -> dict:
|
|
45
|
+
return {k: v for k, v in body.items() if k not in ENVELOPE_KEYS}
|
walley/auth.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Challenge/verify authentication.
|
|
2
|
+
|
|
3
|
+
Read endpoints (balances, holdings, transaction history) require a bearer
|
|
4
|
+
token. Walley issues one to whoever proves control of a party's signing key:
|
|
5
|
+
|
|
6
|
+
1. ``POST /v1/auth/challenge`` → ``{nonce, expires_at, token}``.
|
|
7
|
+
2. Sign the canonical message ``"{audience}|{origin}|{nonce}|{expires_at}"``
|
|
8
|
+
(UTF-8; the nonce is signed as its base64url string) with the party's
|
|
9
|
+
Ed25519 key. Must byte-match ``canonical_message`` in
|
|
10
|
+
``vendor/sherpa-api/src/auth/crypto.rs``.
|
|
11
|
+
3. ``POST /v1/auth/verify`` → ``{access_token, expires_at}``.
|
|
12
|
+
|
|
13
|
+
The party id itself is not part of the signed message — the server binds the
|
|
14
|
+
party via the key fingerprint (the party id's namespace).
|
|
15
|
+
|
|
16
|
+
Prepare/submit endpoints don't need the bearer; they are guarded by the
|
|
17
|
+
prepare token and the ledger's own signature check. The SDK still attaches
|
|
18
|
+
it everywhere — it's harmless and keeps the client uniform.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import time
|
|
25
|
+
from typing import Generator
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from .errors import AuthError
|
|
30
|
+
from .signer import Signer
|
|
31
|
+
|
|
32
|
+
# Re-authenticate this many seconds before the token's stated expiry.
|
|
33
|
+
_EXPIRY_SKEW_SECONDS = 30
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ChallengeAuth(httpx.Auth):
|
|
37
|
+
"""Proves key ownership via the challenge/verify flow, caches the issued
|
|
38
|
+
bearer token, and re-authenticates transparently on expiry or 401."""
|
|
39
|
+
|
|
40
|
+
requires_response_body = True
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
signer: Signer,
|
|
46
|
+
party_id: str,
|
|
47
|
+
challenge_url: str,
|
|
48
|
+
verify_url: str,
|
|
49
|
+
audience: str,
|
|
50
|
+
origin: str,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._signer = signer
|
|
53
|
+
self._party_id = party_id
|
|
54
|
+
self._challenge_url = challenge_url
|
|
55
|
+
self._verify_url = verify_url
|
|
56
|
+
self._audience = audience
|
|
57
|
+
self._origin = origin
|
|
58
|
+
self._token: str | None = None
|
|
59
|
+
self._expires_at: float = 0.0
|
|
60
|
+
|
|
61
|
+
def sync_auth_flow(
|
|
62
|
+
self, request: httpx.Request
|
|
63
|
+
) -> Generator[httpx.Request, httpx.Response, None]:
|
|
64
|
+
if not self._fresh():
|
|
65
|
+
yield from self._authenticate()
|
|
66
|
+
self._apply(request)
|
|
67
|
+
response = yield request
|
|
68
|
+
if response.status_code == 401:
|
|
69
|
+
yield from self._authenticate()
|
|
70
|
+
self._apply(request)
|
|
71
|
+
yield request
|
|
72
|
+
|
|
73
|
+
def _authenticate(self) -> Generator[httpx.Request, httpx.Response, None]:
|
|
74
|
+
response = yield httpx.Request("POST", self._challenge_url, json={})
|
|
75
|
+
response.read()
|
|
76
|
+
if response.status_code != 200:
|
|
77
|
+
raise AuthError("Auth challenge failed.", status=response.status_code)
|
|
78
|
+
challenge = response.json()
|
|
79
|
+
|
|
80
|
+
message = self.canonical_message(
|
|
81
|
+
self._audience, self._origin, challenge["nonce"], challenge["expires_at"]
|
|
82
|
+
)
|
|
83
|
+
signature = base64.b64encode(self._signer.sign(message)).decode()
|
|
84
|
+
|
|
85
|
+
response = yield httpx.Request(
|
|
86
|
+
"POST",
|
|
87
|
+
self._verify_url,
|
|
88
|
+
json={
|
|
89
|
+
"party_id": self._party_id,
|
|
90
|
+
"origin": self._origin,
|
|
91
|
+
"public_key": self._signer.public_key_base64,
|
|
92
|
+
"signature": signature,
|
|
93
|
+
"challenge": {
|
|
94
|
+
"nonce": challenge["nonce"],
|
|
95
|
+
"expires_at": challenge["expires_at"],
|
|
96
|
+
"token": challenge["token"],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
response.read()
|
|
101
|
+
if response.status_code != 200:
|
|
102
|
+
raise AuthError("Auth verify failed.", status=response.status_code)
|
|
103
|
+
body = response.json()
|
|
104
|
+
self._token = body["access_token"]
|
|
105
|
+
self._expires_at = float(body["expires_at"])
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def canonical_message(audience: str, origin: str, nonce: str, expires_at: int) -> bytes:
|
|
109
|
+
return f"{audience}|{origin}|{nonce}|{expires_at}".encode()
|
|
110
|
+
|
|
111
|
+
def _fresh(self) -> bool:
|
|
112
|
+
return self._token is not None and time.time() < self._expires_at - _EXPIRY_SKEW_SECONDS
|
|
113
|
+
|
|
114
|
+
def _apply(self, request: httpx.Request) -> None:
|
|
115
|
+
request.headers["Authorization"] = f"Bearer {self._token}"
|
walley/client.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from ._transport import Transport
|
|
11
|
+
from .auth import ChallengeAuth
|
|
12
|
+
from .models import SignedMessage
|
|
13
|
+
from .resources import (
|
|
14
|
+
Balances,
|
|
15
|
+
Fees,
|
|
16
|
+
Holdings,
|
|
17
|
+
Ledger,
|
|
18
|
+
MergeDelegations,
|
|
19
|
+
Party,
|
|
20
|
+
Tokens,
|
|
21
|
+
TransferPreapprovals,
|
|
22
|
+
Transactions,
|
|
23
|
+
Transfers,
|
|
24
|
+
)
|
|
25
|
+
from .signer import Signer
|
|
26
|
+
|
|
27
|
+
DEFAULT_API_BASE = "https://api.walley.cc"
|
|
28
|
+
DEFAULT_AUDIENCE = "https://walley.cc/dapp"
|
|
29
|
+
DEFAULT_ORIGIN = "walley-python-sdk"
|
|
30
|
+
|
|
31
|
+
_PARTY_HINT_RE = re.compile(r"^walley-[a-zA-Z0-9-]+$")
|
|
32
|
+
_PARTY_ID_RE = re.compile(r"^[a-zA-Z0-9-]+::[0-9a-f]+$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Walley:
|
|
36
|
+
"""Synchronous Walley client, signed in to one existing self-custodied
|
|
37
|
+
party.
|
|
38
|
+
|
|
39
|
+
The client holds the party's Ed25519 key and does all signing locally —
|
|
40
|
+
the API only ever sees public keys and signatures. Reads authenticate
|
|
41
|
+
automatically via the challenge/verify flow; writes follow Canton's
|
|
42
|
+
external-signing dance (prepare → sign → submit) behind one method call.
|
|
43
|
+
|
|
44
|
+
Parties are created in the Walley app; sign in here with the wallet's
|
|
45
|
+
recovery mnemonic (or exported key).
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
key_file: Path to a key file — a PKCS#8 PEM or the 24-word recovery
|
|
49
|
+
mnemonic (format detected). The recommended option: key material
|
|
50
|
+
stays out of source code. Falls back to ``$WALLEY_KEY_FILE``.
|
|
51
|
+
mnemonic: Alternatively, the 24-word mnemonic inline. Falls back to
|
|
52
|
+
``$WALLEY_MNEMONIC``.
|
|
53
|
+
private_key: Alternatively, the raw Ed25519 private key — bytes,
|
|
54
|
+
hex, base64, or PEM. Falls back to ``$WALLEY_PRIVATE_KEY``.
|
|
55
|
+
signer: Alternatively, a pre-built :class:`Signer`.
|
|
56
|
+
party_id: The full party id (``hint::fingerprint``). Falls back to
|
|
57
|
+
``$WALLEY_PARTY_ID``. Must match the key's fingerprint.
|
|
58
|
+
party_hint: Or just the hint (``walley-...``) — the id is derived
|
|
59
|
+
from the key. Falls back to ``$WALLEY_PARTY_HINT``.
|
|
60
|
+
api_base: Walley API base URL. Falls back to ``$WALLEY_API_URL``,
|
|
61
|
+
then ``https://api.walley.cc``.
|
|
62
|
+
audience: Auth audience string; must match the deployment's
|
|
63
|
+
``AUTH_PROVIDER__AUDIENCE``.
|
|
64
|
+
origin: Free-form caller identifier recorded in issued tokens.
|
|
65
|
+
timeout: Per-request timeout in seconds (submits use a longer one).
|
|
66
|
+
auto_settle_fees: Settle each deferred network fee immediately after
|
|
67
|
+
the transaction that incurred it (the settlement lands on the
|
|
68
|
+
result's ``fee_settlement``). Pass ``False`` to manage debts
|
|
69
|
+
yourself via ``client.fees`` — but note an unsettled debt blocks
|
|
70
|
+
the party's next prepare with :class:`FeeDueError`.
|
|
71
|
+
|
|
72
|
+
Example::
|
|
73
|
+
|
|
74
|
+
w = Walley(mnemonic="...", party_hint="walley-alice")
|
|
75
|
+
result = w.transfers.send(receiver="walley-bob::1220...", amount="5")
|
|
76
|
+
print(result.fee) # the network fee this send incurred
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
*,
|
|
82
|
+
key_file: Optional[str] = None,
|
|
83
|
+
mnemonic: Optional[str] = None,
|
|
84
|
+
private_key: Union[bytes, str, None] = None,
|
|
85
|
+
signer: Optional[Signer] = None,
|
|
86
|
+
party_id: Optional[str] = None,
|
|
87
|
+
party_hint: Optional[str] = None,
|
|
88
|
+
api_base: Optional[str] = None,
|
|
89
|
+
audience: str = DEFAULT_AUDIENCE,
|
|
90
|
+
origin: str = DEFAULT_ORIGIN,
|
|
91
|
+
timeout: float = 30.0,
|
|
92
|
+
auto_settle_fees: bool = True,
|
|
93
|
+
) -> None:
|
|
94
|
+
self.signer = _resolve_signer(key_file, mnemonic, private_key, signer)
|
|
95
|
+
self.party_id = _resolve_party_id(self.signer, party_id, party_hint)
|
|
96
|
+
api_base = (api_base or os.environ.get("WALLEY_API_URL") or DEFAULT_API_BASE).rstrip("/")
|
|
97
|
+
|
|
98
|
+
auth = ChallengeAuth(
|
|
99
|
+
signer=self.signer,
|
|
100
|
+
party_id=self.party_id,
|
|
101
|
+
challenge_url=f"{api_base}/v1/auth/challenge",
|
|
102
|
+
verify_url=f"{api_base}/v1/auth/verify",
|
|
103
|
+
audience=audience,
|
|
104
|
+
origin=origin,
|
|
105
|
+
)
|
|
106
|
+
self._http = httpx.Client(auth=auth, timeout=timeout)
|
|
107
|
+
transport = Transport(self._http, api_base)
|
|
108
|
+
|
|
109
|
+
self.tokens = Tokens(transport)
|
|
110
|
+
self.transactions = Transactions(
|
|
111
|
+
transport, self.party_id, self.signer, auto_settle_fees=auto_settle_fees
|
|
112
|
+
)
|
|
113
|
+
self.transfers = Transfers(transport, self.party_id, self.transactions, self.tokens)
|
|
114
|
+
self.fees = Fees(transport, self.party_id, self.transactions)
|
|
115
|
+
self.balances = Balances(transport, self.party_id)
|
|
116
|
+
self.holdings = Holdings(transport, self.party_id)
|
|
117
|
+
self.party = Party(transport, self.party_id)
|
|
118
|
+
self.ledger = Ledger(transport, self.party_id)
|
|
119
|
+
self.preapprovals = TransferPreapprovals(transport, self.party_id, self.transactions)
|
|
120
|
+
self.merge_delegations = MergeDelegations(transport, self.party_id, self.transactions)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def fingerprint(self) -> str:
|
|
124
|
+
"""The signing key's Canton fingerprint (the party id's namespace)."""
|
|
125
|
+
return self.signer.fingerprint
|
|
126
|
+
|
|
127
|
+
def sign(self, message: Union[bytes, str]) -> SignedMessage:
|
|
128
|
+
"""Sign an arbitrary message with the party's key.
|
|
129
|
+
|
|
130
|
+
Strings are signed as UTF-8. Returns a :class:`SignedMessage`
|
|
131
|
+
bundling the signature with the public key and fingerprint, so any
|
|
132
|
+
verifier can check it against the party id.
|
|
133
|
+
"""
|
|
134
|
+
data = message.encode() if isinstance(message, str) else message
|
|
135
|
+
return SignedMessage(
|
|
136
|
+
signature=base64.b64encode(self.signer.sign(data)).decode(),
|
|
137
|
+
signed_by=self.signer.fingerprint,
|
|
138
|
+
public_key=self.signer.public_key_base64,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def close(self) -> None:
|
|
142
|
+
self._http.close()
|
|
143
|
+
|
|
144
|
+
def __enter__(self) -> "Walley":
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
def __exit__(self, *_exc: object) -> None:
|
|
148
|
+
self.close()
|
|
149
|
+
|
|
150
|
+
def __repr__(self) -> str:
|
|
151
|
+
return f"Walley(party_id={self.party_id!r})"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _resolve_signer(
|
|
155
|
+
key_file: Optional[str],
|
|
156
|
+
mnemonic: Optional[str],
|
|
157
|
+
private_key: Union[bytes, str, None],
|
|
158
|
+
signer: Optional[Signer],
|
|
159
|
+
) -> Signer:
|
|
160
|
+
if signer is not None:
|
|
161
|
+
return signer
|
|
162
|
+
key_file = key_file or os.environ.get("WALLEY_KEY_FILE")
|
|
163
|
+
if key_file:
|
|
164
|
+
return Signer.from_file(key_file)
|
|
165
|
+
mnemonic = mnemonic or os.environ.get("WALLEY_MNEMONIC")
|
|
166
|
+
if mnemonic:
|
|
167
|
+
return Signer.from_mnemonic(mnemonic)
|
|
168
|
+
private_key = private_key or os.environ.get("WALLEY_PRIVATE_KEY")
|
|
169
|
+
if private_key:
|
|
170
|
+
return Signer.from_private_key(private_key)
|
|
171
|
+
raise ValueError(
|
|
172
|
+
"a signing key is required — pass key_file=..., mnemonic=..., private_key=..., "
|
|
173
|
+
"or signer=... (or set WALLEY_KEY_FILE / WALLEY_MNEMONIC / WALLEY_PRIVATE_KEY)"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _resolve_party_id(
|
|
178
|
+
signer: Signer,
|
|
179
|
+
party_id: Optional[str],
|
|
180
|
+
party_hint: Optional[str],
|
|
181
|
+
) -> str:
|
|
182
|
+
party_id = party_id or os.environ.get("WALLEY_PARTY_ID")
|
|
183
|
+
if party_id:
|
|
184
|
+
if not _PARTY_ID_RE.match(party_id):
|
|
185
|
+
raise ValueError(f"invalid party_id {party_id!r} (expected hint::fingerprint)")
|
|
186
|
+
namespace = party_id.rsplit("::", 1)[1]
|
|
187
|
+
if namespace.lower() != signer.fingerprint:
|
|
188
|
+
raise ValueError(
|
|
189
|
+
f"party_id namespace {namespace!r} does not match the signing key's "
|
|
190
|
+
f"fingerprint {signer.fingerprint!r} — wrong key for this party?"
|
|
191
|
+
)
|
|
192
|
+
return party_id
|
|
193
|
+
party_hint = party_hint or os.environ.get("WALLEY_PARTY_HINT")
|
|
194
|
+
if party_hint:
|
|
195
|
+
if not _PARTY_HINT_RE.match(party_hint) or not 8 <= len(party_hint) <= 64:
|
|
196
|
+
raise ValueError(
|
|
197
|
+
f"invalid party_hint {party_hint!r} (expected 'walley-' + 8-64 "
|
|
198
|
+
"alphanumeric/hyphen chars)"
|
|
199
|
+
)
|
|
200
|
+
return signer.party_id(party_hint)
|
|
201
|
+
raise ValueError(
|
|
202
|
+
"pass party_id=... or party_hint=... (or set WALLEY_PARTY_ID / WALLEY_PARTY_HINT)"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = ["Walley", "DEFAULT_API_BASE", "DEFAULT_AUDIENCE", "DEFAULT_ORIGIN"]
|
walley/enums.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TransferStatus(str, Enum):
|
|
5
|
+
"""Lifecycle of a transfer event in the transaction history."""
|
|
6
|
+
|
|
7
|
+
INITIATED = "INITIATED"
|
|
8
|
+
COMPLETED = "COMPLETED"
|
|
9
|
+
ACCEPTED = "ACCEPTED"
|
|
10
|
+
REJECTED = "REJECTED"
|
|
11
|
+
WITHDRAWN = "WITHDRAWN"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TransactionSource(str, Enum):
|
|
15
|
+
"""Which app initiated a transaction (``WALLEY`` for a plain wallet send)."""
|
|
16
|
+
|
|
17
|
+
WALLEY = "WALLEY"
|
|
18
|
+
NEXODE = "NEXODE"
|
|
19
|
+
ATOMIX = "ATOMIX"
|
|
20
|
+
VAULT = "VAULT"
|
|
21
|
+
CANTORY = "CANTORY"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TxKind(str, Enum):
|
|
25
|
+
TRANSFER = "transfer"
|
|
26
|
+
FEE = "fee"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TxKindFilter(str, Enum):
|
|
30
|
+
"""Filter for the transaction history: everything, only network-fee
|
|
31
|
+
transactions, or everything but fees."""
|
|
32
|
+
|
|
33
|
+
ALL = "all"
|
|
34
|
+
FEES = "fees"
|
|
35
|
+
NON_FEES = "non_fees"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PreapprovalStatus(str, Enum):
|
|
39
|
+
"""State of a transfer preapproval or merge delegation. ``EXPIRED``
|
|
40
|
+
applies to transfer preapprovals only."""
|
|
41
|
+
|
|
42
|
+
ENABLED = "ENABLED"
|
|
43
|
+
PENDING = "PENDING"
|
|
44
|
+
NOT_ENABLED = "NOT_ENABLED"
|
|
45
|
+
EXPIRED = "EXPIRED"
|
walley/errors.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional, Type
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WalleyError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status: Optional[int] = None,
|
|
12
|
+
trace_id: Optional[str] = None,
|
|
13
|
+
) -> None:
|
|
14
|
+
super().__init__(message)
|
|
15
|
+
self.message = message
|
|
16
|
+
self.status = status
|
|
17
|
+
self.trace_id = trace_id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AuthError(WalleyError):
|
|
21
|
+
"""Invalid or expired credentials (401/403)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationError(WalleyError):
|
|
25
|
+
"""The request was malformed (400)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FeeDueError(WalleyError):
|
|
29
|
+
"""The party owes an unpaid network fee (402). New transactions are
|
|
30
|
+
refused until it is settled — call ``client.fees.pay()``."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnprocessableError(WalleyError):
|
|
34
|
+
"""A well-formed request rejected by a business rule — insufficient
|
|
35
|
+
funds, expired transfer, party quota exceeded, etc. (422)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class NotFoundError(WalleyError):
|
|
39
|
+
"""The resource does not exist (404)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimitError(WalleyError):
|
|
43
|
+
"""Too many requests (429)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServerError(WalleyError):
|
|
47
|
+
"""The server failed to handle the request (5xx)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def error_from_response(status: int, body: Mapping[str, Any]) -> WalleyError:
|
|
51
|
+
match status:
|
|
52
|
+
case 400:
|
|
53
|
+
cls: Type[WalleyError] = ValidationError
|
|
54
|
+
case 401 | 403:
|
|
55
|
+
cls = AuthError
|
|
56
|
+
case 402:
|
|
57
|
+
cls = FeeDueError
|
|
58
|
+
case 404:
|
|
59
|
+
cls = NotFoundError
|
|
60
|
+
case 422:
|
|
61
|
+
cls = UnprocessableError
|
|
62
|
+
case 429:
|
|
63
|
+
cls = RateLimitError
|
|
64
|
+
case s if s >= 500:
|
|
65
|
+
cls = ServerError
|
|
66
|
+
case _:
|
|
67
|
+
cls = WalleyError
|
|
68
|
+
message = body.get("message") or f"HTTP {status}"
|
|
69
|
+
if cls is FeeDueError:
|
|
70
|
+
message += " Settle it with client.fees.pay(), then retry."
|
|
71
|
+
return cls(message, status=status, trace_id=body.get("trace_id"))
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from .balance import Balance, Holding, Token
|
|
2
|
+
from .fee import Fee, FeeDue, FreeSends
|
|
3
|
+
from .ledger import ActiveContract
|
|
4
|
+
from .party import Party
|
|
5
|
+
from .preapproval import (
|
|
6
|
+
MergeDelegation,
|
|
7
|
+
MergeDelegationProposal,
|
|
8
|
+
MergeDelegationState,
|
|
9
|
+
TransferPreapproval,
|
|
10
|
+
TransferPreapprovalProposal,
|
|
11
|
+
TransferPreapprovalState,
|
|
12
|
+
)
|
|
13
|
+
from .prepared import (
|
|
14
|
+
PreparedSubmission,
|
|
15
|
+
PreparedTransaction,
|
|
16
|
+
SignedMessage,
|
|
17
|
+
SubmitResult,
|
|
18
|
+
SubmittedTransaction,
|
|
19
|
+
)
|
|
20
|
+
from .transaction import (
|
|
21
|
+
MergeEvent,
|
|
22
|
+
Transaction,
|
|
23
|
+
TransactionPage,
|
|
24
|
+
TransferEvent,
|
|
25
|
+
)
|
|
26
|
+
from .transfer import Transfer
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"ActiveContract",
|
|
30
|
+
"Balance",
|
|
31
|
+
"Holding",
|
|
32
|
+
"Token",
|
|
33
|
+
"Party",
|
|
34
|
+
"Transfer",
|
|
35
|
+
"Transaction",
|
|
36
|
+
"TransactionPage",
|
|
37
|
+
"TransferEvent",
|
|
38
|
+
"MergeEvent",
|
|
39
|
+
"Fee",
|
|
40
|
+
"FeeDue",
|
|
41
|
+
"FreeSends",
|
|
42
|
+
"PreparedTransaction",
|
|
43
|
+
"PreparedSubmission",
|
|
44
|
+
"SubmittedTransaction",
|
|
45
|
+
"SubmitResult",
|
|
46
|
+
"SignedMessage",
|
|
47
|
+
"TransferPreapproval",
|
|
48
|
+
"TransferPreapprovalProposal",
|
|
49
|
+
"TransferPreapprovalState",
|
|
50
|
+
"MergeDelegation",
|
|
51
|
+
"MergeDelegationProposal",
|
|
52
|
+
"MergeDelegationState",
|
|
53
|
+
]
|
walley/models/_base.py
ADDED
walley/models/balance.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from ._base import Model
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Balance(Model):
|
|
11
|
+
"""A party's aggregate position in one instrument."""
|
|
12
|
+
|
|
13
|
+
owner: str
|
|
14
|
+
instrument_id: str
|
|
15
|
+
instrument_admin_id: str
|
|
16
|
+
total_balance: Decimal
|
|
17
|
+
unlocked_balance: Decimal
|
|
18
|
+
locked_balance: Decimal
|
|
19
|
+
holding_count: int
|
|
20
|
+
usd_value: Optional[Decimal] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Holding(Model):
|
|
24
|
+
"""A single on-ledger holding contract (a UTXO-like slice of a balance)."""
|
|
25
|
+
|
|
26
|
+
contract_id: str
|
|
27
|
+
owner: str
|
|
28
|
+
instrument_id: str
|
|
29
|
+
instrument_admin_id: str
|
|
30
|
+
amount: Decimal
|
|
31
|
+
is_locked: bool
|
|
32
|
+
lock_context: Optional[str] = None
|
|
33
|
+
lock_expires_at: Optional[str] = None
|
|
34
|
+
created_at: Optional[datetime] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Token(Model):
|
|
38
|
+
"""An instrument known to the registry (e.g. ``Amulet`` — Canton Coin)."""
|
|
39
|
+
|
|
40
|
+
id: str
|
|
41
|
+
name: str
|
|
42
|
+
symbol: str
|
|
43
|
+
decimals: int
|
|
44
|
+
admin_id: str
|
|
45
|
+
operator_id: Optional[str] = None
|
|
46
|
+
total_supply: Optional[str] = None
|
|
47
|
+
total_supply_as_of: Optional[datetime] = None
|