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 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
@@ -0,0 +1,5 @@
1
+ from pydantic import BaseModel, ConfigDict
2
+
3
+
4
+ class Model(BaseModel):
5
+ model_config = ConfigDict(extra="ignore", frozen=True)
@@ -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