chimerakit 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.
- chimerakit/__init__.py +6 -0
- chimerakit/client.py +358 -0
- chimerakit/errors.py +40 -0
- chimerakit/sandbox.py +328 -0
- chimerakit-0.1.0.dist-info/METADATA +144 -0
- chimerakit-0.1.0.dist-info/RECORD +8 -0
- chimerakit-0.1.0.dist-info/WHEEL +5 -0
- chimerakit-0.1.0.dist-info/top_level.txt +1 -0
chimerakit/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""chimerakit: the Python client for the Chimera partner bank API. See README.md."""
|
|
2
|
+
from .client import AsyncChimera, Chimera, Line, new_request_id
|
|
3
|
+
from .errors import AccessDenied, ChimeraError, Conflict, InvalidRequest, LimitExceeded, NotFound, RateLimited, Unavailable
|
|
4
|
+
|
|
5
|
+
__version__ = "0.1.0"
|
|
6
|
+
__all__ = ["AccessDenied", "AsyncChimera", "Chimera", "ChimeraError", "Conflict", "InvalidRequest", "LimitExceeded", "Line", "NotFound", "RateLimited", "Unavailable", "new_request_id"]
|
chimerakit/client.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The clients. `Chimera` and `AsyncChimera` have the same methods. Everything that shapes a request or reads an answer is in `_Calls`, so the two cannot drift apart.
|
|
3
|
+
|
|
4
|
+
Three promises:
|
|
5
|
+
|
|
6
|
+
Amounts are exact. They go out as text and come back as Decimal. A float is refused, because it has already been rounded.
|
|
7
|
+
A retry is safe. A call that creates something carries a request ID, and every retry of it carries the same one, so Chimera acts once. Other calls are reads, or harmless to repeat.
|
|
8
|
+
A secret goes only over HTTPS. Plain HTTP is allowed to this computer only, for the sandbox.
|
|
9
|
+
"""
|
|
10
|
+
import asyncio
|
|
11
|
+
import random
|
|
12
|
+
import re
|
|
13
|
+
import secrets
|
|
14
|
+
import time
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from decimal import Decimal, InvalidOperation
|
|
18
|
+
from typing import Dict, List, Mapping, Optional, Sequence, Union
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from .errors import BY_STATUS, ChimeraError, Unavailable
|
|
24
|
+
|
|
25
|
+
RESOURCES = ("money", "food", "coal", "oil", "uranium", "lead", "iron", "bauxite", "gasoline", "munitions", "steel", "aluminum")
|
|
26
|
+
RETRY_STATUSES = (429, 500, 502, 503, 504)
|
|
27
|
+
TIMEOUT_SECONDS = 15.0
|
|
28
|
+
MAX_ATTEMPTS = 4
|
|
29
|
+
LOCAL_HOSTS = ("localhost", "127.0.0.1", "::1", "testserver")
|
|
30
|
+
CENTS = re.compile(r"[0-9]{1,28}(\.[0-9]{1,2})?")
|
|
31
|
+
|
|
32
|
+
Number = Union[Decimal, int, str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def new_request_id():
|
|
36
|
+
"""A fresh ID for a call that creates something. Save it before you make the call, so that after a crash you can repeat the call and not act twice."""
|
|
37
|
+
return "ck-" + secrets.token_urlsafe(24)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _amount(name, value):
|
|
41
|
+
if name not in RESOURCES:
|
|
42
|
+
raise ValueError("%r is not something Chimera holds." % (name,))
|
|
43
|
+
if isinstance(value, bool) or isinstance(value, float):
|
|
44
|
+
raise TypeError("%s must be a Decimal, int or str: a float has already been rounded." % name)
|
|
45
|
+
try:
|
|
46
|
+
number = Decimal(str(value).strip())
|
|
47
|
+
except InvalidOperation:
|
|
48
|
+
raise ValueError("%s is not a number." % name)
|
|
49
|
+
if not number.is_finite():
|
|
50
|
+
raise ValueError("%s is not a number." % name)
|
|
51
|
+
text = format(number, "f")
|
|
52
|
+
if not CENTS.fullmatch(text):
|
|
53
|
+
raise ValueError("%s must be positive, with at most two decimal places." % name)
|
|
54
|
+
if not number:
|
|
55
|
+
raise ValueError("%s is zero. Leave it out." % name)
|
|
56
|
+
return text
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _amounts(amounts):
|
|
60
|
+
if not amounts:
|
|
61
|
+
raise ValueError("Give at least one amount.")
|
|
62
|
+
return {k: _amount(k, v) for k, v in amounts.items()}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _discord(discord_id):
|
|
66
|
+
text = str(discord_id)
|
|
67
|
+
if not re.fullmatch(r"[0-9]{1,20}", text):
|
|
68
|
+
raise ValueError("A Discord ID is digits only.")
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _money_map(raw):
|
|
73
|
+
return {k: Decimal(str(v)) for k, v in raw.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _when(text):
|
|
77
|
+
return datetime.fromisoformat(text) if text else None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Line:
|
|
82
|
+
"""One client's share of a handoff."""
|
|
83
|
+
discord_id: Union[int, str]
|
|
84
|
+
nation_id: int
|
|
85
|
+
amounts: Mapping[str, Number]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class PendingDeposit:
|
|
90
|
+
deposit_id: int
|
|
91
|
+
amounts: Dict[str, Decimal]
|
|
92
|
+
expires_at: Optional[datetime]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class Account:
|
|
97
|
+
accountid: int
|
|
98
|
+
discord_id: int
|
|
99
|
+
nation_id: int
|
|
100
|
+
custody: str
|
|
101
|
+
frozen: bool
|
|
102
|
+
balances: Dict[str, Decimal]
|
|
103
|
+
pending_deposits: List[PendingDeposit]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Deposit:
|
|
108
|
+
"""Send exactly `amounts` from your registered alliance to Chimera, with `code` as the WHOLE note, nothing around it, before `expires_at`. `status` is waiting, credited, expired or under_review."""
|
|
109
|
+
deposit_id: int
|
|
110
|
+
accountid: int
|
|
111
|
+
discord_id: int
|
|
112
|
+
nation_id: int
|
|
113
|
+
alliance_id: int
|
|
114
|
+
code: str
|
|
115
|
+
amounts: Dict[str, Decimal]
|
|
116
|
+
status: str
|
|
117
|
+
created_at: datetime
|
|
118
|
+
expires_at: datetime
|
|
119
|
+
repeat: bool
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class HandoffLine:
|
|
124
|
+
accountid: int
|
|
125
|
+
discord_id: int
|
|
126
|
+
nation_id: int
|
|
127
|
+
amounts: Dict[str, Decimal]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class Handoff:
|
|
132
|
+
"""One transfer for many clients. Send exactly `amounts`, the total, with `code` as the whole note. It credits every line or none. `status` is as for a Deposit."""
|
|
133
|
+
handoff_id: int
|
|
134
|
+
alliance_id: int
|
|
135
|
+
code: str
|
|
136
|
+
amounts: Dict[str, Decimal]
|
|
137
|
+
lines: List[HandoffLine]
|
|
138
|
+
status: str
|
|
139
|
+
created_at: datetime
|
|
140
|
+
expires_at: datetime
|
|
141
|
+
repeat: bool
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class Withdrawal:
|
|
146
|
+
"""`status` is processing, sent, completed, failed or under_review. When it is sent, check the in-game record against `code`, pay your client, then call confirm_withdrawal."""
|
|
147
|
+
withdrawal_id: int
|
|
148
|
+
accountid: int
|
|
149
|
+
discord_id: int
|
|
150
|
+
nation_id: int
|
|
151
|
+
alliance_id: int
|
|
152
|
+
code: str
|
|
153
|
+
amounts: Dict[str, Decimal]
|
|
154
|
+
status: str
|
|
155
|
+
created_at: datetime
|
|
156
|
+
repeat: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _account(r):
|
|
160
|
+
pending = [PendingDeposit(p["deposit_id"], _money_map(p["amounts"]), _when(p["expires_at"])) for p in r.get("pending_deposits", [])]
|
|
161
|
+
return Account(int(r["accountid"]), int(r["discord_id"]), r["nation_id"], r["custody"], r["frozen"], _money_map(r["balances"]), pending)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _deposit(r):
|
|
165
|
+
return Deposit(r["deposit_id"], int(r["accountid"]), int(r["discord_id"]), r["nation_id"], r["alliance_id"], r["code"], _money_map(r["amounts"]), r["status"], _when(r["created_at"]), _when(r["expires_at"]), r["repeat"])
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _handoff(r):
|
|
169
|
+
lines = [HandoffLine(int(x["accountid"]), int(x["discord_id"]), x["nation_id"], _money_map(x["amounts"])) for x in r["lines"]]
|
|
170
|
+
return Handoff(r["handoff_id"], r["alliance_id"], r["code"], _money_map(r["amounts"]), lines, r["status"], _when(r["created_at"]), _when(r["expires_at"]), r["repeat"])
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _withdrawal(r):
|
|
174
|
+
return Withdrawal(r["withdrawal_id"], int(r["accountid"]), int(r["discord_id"]), r["nation_id"], r["alliance_id"], r["code"], _money_map(r["amounts"]), r["status"], _when(r["created_at"]), r["repeat"])
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _Call:
|
|
178
|
+
"""One API call: where it goes, what it carries, and how to read a good answer."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, method, path, read, body=None, params=None, request_id=None):
|
|
181
|
+
self.method, self.path, self.read, self.body, self.params, self.request_id = method, path, read, body, params, request_id
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _Calls:
|
|
185
|
+
"""Every method of both clients, as a description of the call. Each subclass supplies `_do`, which runs one call with its retries."""
|
|
186
|
+
|
|
187
|
+
def _do(self, call):
|
|
188
|
+
raise NotImplementedError
|
|
189
|
+
|
|
190
|
+
def health(self):
|
|
191
|
+
return self._do(_Call("GET", "/health", lambda r: r["status"] == "ok"))
|
|
192
|
+
|
|
193
|
+
def open_account(self, discord_id, nation_id):
|
|
194
|
+
"""The client's account, opened if it has none. Calling it twice is harmless. An account starts empty."""
|
|
195
|
+
return self._do(_Call("POST", "/v1/accounts", _account, {"discord_id": _discord(discord_id), "nation_id": int(nation_id)}))
|
|
196
|
+
|
|
197
|
+
def accounts(self, after=None, limit=100):
|
|
198
|
+
"""One page of your clients' accounts, oldest first, as (accounts, next_after). Pass next_after for the next page. It is None on the last page."""
|
|
199
|
+
params = {"limit": int(limit)}
|
|
200
|
+
if after is not None:
|
|
201
|
+
params["after"] = int(after)
|
|
202
|
+
return self._do(_Call("GET", "/v1/accounts", lambda r: ([_account(a) for a in r["accounts"]], r["next_after"]), params=params))
|
|
203
|
+
|
|
204
|
+
def create_deposit(self, discord_id, nation_id, amounts, request_id=None):
|
|
205
|
+
"""Says you are about to send these funds, and gets the code for the note. Nothing is credited until Chimera sees the transfer in the game's records."""
|
|
206
|
+
request_id = request_id or new_request_id()
|
|
207
|
+
body = {"request_id": request_id, "discord_id": _discord(discord_id), "nation_id": int(nation_id), "amounts": _amounts(amounts)}
|
|
208
|
+
return self._do(_Call("POST", "/v1/deposits", _deposit, body, request_id=request_id))
|
|
209
|
+
|
|
210
|
+
def get_deposit(self, deposit_id):
|
|
211
|
+
return self._do(_Call("GET", "/v1/deposits/%d" % int(deposit_id), _deposit))
|
|
212
|
+
|
|
213
|
+
def create_handoff(self, lines: Sequence[Line], request_id=None):
|
|
214
|
+
"""One transfer split across many clients, each with an account already. This is the raid tool. Up to 500 lines."""
|
|
215
|
+
request_id = request_id or new_request_id()
|
|
216
|
+
body = {"request_id": request_id, "lines": [{"discord_id": _discord(x.discord_id), "nation_id": int(x.nation_id), "amounts": _amounts(x.amounts)} for x in lines]}
|
|
217
|
+
return self._do(_Call("POST", "/v1/handoffs", _handoff, body, request_id=request_id))
|
|
218
|
+
|
|
219
|
+
def get_handoff(self, handoff_id):
|
|
220
|
+
return self._do(_Call("GET", "/v1/handoffs/%d" % int(handoff_id), _handoff))
|
|
221
|
+
|
|
222
|
+
def create_withdrawal(self, discord_id, nation_id, amounts, request_id=None):
|
|
223
|
+
"""Takes the amounts from the client's balance at once. Chimera sends them to your registered alliance, never to a client."""
|
|
224
|
+
request_id = request_id or new_request_id()
|
|
225
|
+
body = {"request_id": request_id, "discord_id": _discord(discord_id), "nation_id": int(nation_id), "amounts": _amounts(amounts)}
|
|
226
|
+
return self._do(_Call("POST", "/v1/withdrawals", _withdrawal, body, request_id=request_id))
|
|
227
|
+
|
|
228
|
+
def get_withdrawal(self, withdrawal_id):
|
|
229
|
+
return self._do(_Call("GET", "/v1/withdrawals/%d" % int(withdrawal_id), _withdrawal))
|
|
230
|
+
|
|
231
|
+
def confirm_withdrawal(self, withdrawal_id, code):
|
|
232
|
+
"""Says the funds reached your alliance. Saying it twice is harmless."""
|
|
233
|
+
return self._do(_Call("POST", "/v1/withdrawals/%d/confirm" % int(withdrawal_id), _withdrawal, {"code": code}))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _base_url(url):
|
|
237
|
+
parts = urlsplit(url)
|
|
238
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and parts.hostname in LOCAL_HOSTS):
|
|
239
|
+
raise ValueError("Chimera secrets only go over HTTPS.")
|
|
240
|
+
return url.rstrip("/")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _headers(secret):
|
|
244
|
+
if not secret:
|
|
245
|
+
raise ValueError("Give your Chimera secret.")
|
|
246
|
+
return {"Authorization": "Bearer %s" % secret, "User-Agent": "chimerakit/0.1"}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _result(call, response):
|
|
250
|
+
"""The answer read, or the right error. None means try again."""
|
|
251
|
+
if response.status_code in (200, 201):
|
|
252
|
+
return call.read(response.json())
|
|
253
|
+
if response.status_code in RETRY_STATUSES:
|
|
254
|
+
return None
|
|
255
|
+
raise _refusal(call, response)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _refusal(call, response):
|
|
259
|
+
try:
|
|
260
|
+
message = response.json().get("detail") or "Refused."
|
|
261
|
+
except ValueError:
|
|
262
|
+
message = "Refused."
|
|
263
|
+
kind = BY_STATUS.get(response.status_code, Unavailable if response.status_code >= 500 else ChimeraError)
|
|
264
|
+
return kind(str(message), status=response.status_code, request_id=call.request_id)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _gave_up(call, response, error):
|
|
268
|
+
if response is not None and response.status_code == 429:
|
|
269
|
+
return _refusal(call, response)
|
|
270
|
+
what = "Chimera answered %s." % response.status_code if response is not None else "No answer from Chimera: %s." % type(error).__name__
|
|
271
|
+
return Unavailable(what + " The call may have worked. Repeat it with the same request ID.", status=getattr(response, "status_code", None), request_id=call.request_id)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _pause(attempt, base):
|
|
275
|
+
return base * (2 ** attempt) * (1 + random.random() / 2)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class Chimera(_Calls):
|
|
279
|
+
"""For a blocking program. Use one for the life of your program, and close it, or use `with`. `transport` is for tests."""
|
|
280
|
+
|
|
281
|
+
def __init__(self, secret, base_url, timeout=TIMEOUT_SECONDS, attempts=MAX_ATTEMPTS, pause=0.5, transport=None):
|
|
282
|
+
self.attempts, self.pause = attempts, pause
|
|
283
|
+
self._http = httpx.Client(base_url=_base_url(base_url), headers=_headers(secret), timeout=timeout, transport=transport, follow_redirects=False)
|
|
284
|
+
|
|
285
|
+
def _do(self, call):
|
|
286
|
+
response = error = None
|
|
287
|
+
for attempt in range(self.attempts):
|
|
288
|
+
if attempt:
|
|
289
|
+
time.sleep(_pause(attempt - 1, self.pause))
|
|
290
|
+
try:
|
|
291
|
+
response, error = self._http.request(call.method, call.path, json=call.body, params=call.params), None
|
|
292
|
+
except httpx.TransportError as e:
|
|
293
|
+
response, error = None, e
|
|
294
|
+
continue
|
|
295
|
+
got = _result(call, response)
|
|
296
|
+
if got is not None:
|
|
297
|
+
return got
|
|
298
|
+
raise _gave_up(call, response, error)
|
|
299
|
+
|
|
300
|
+
def iter_accounts(self, page=100):
|
|
301
|
+
"""Every account, a page at a time."""
|
|
302
|
+
after = None
|
|
303
|
+
while True:
|
|
304
|
+
accounts, after = self.accounts(after, page)
|
|
305
|
+
yield from accounts
|
|
306
|
+
if after is None:
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
def close(self):
|
|
310
|
+
self._http.close()
|
|
311
|
+
|
|
312
|
+
def __enter__(self):
|
|
313
|
+
return self
|
|
314
|
+
|
|
315
|
+
def __exit__(self, *_):
|
|
316
|
+
self.close()
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class AsyncChimera(_Calls):
|
|
320
|
+
"""The same, for a Discord bot or any asyncio program. Every method but iter_accounts and aclose returns something to await."""
|
|
321
|
+
|
|
322
|
+
def __init__(self, secret, base_url, timeout=TIMEOUT_SECONDS, attempts=MAX_ATTEMPTS, pause=0.5, transport=None):
|
|
323
|
+
self.attempts, self.pause = attempts, pause
|
|
324
|
+
self._http = httpx.AsyncClient(base_url=_base_url(base_url), headers=_headers(secret), timeout=timeout, transport=transport, follow_redirects=False)
|
|
325
|
+
|
|
326
|
+
async def _do(self, call):
|
|
327
|
+
response = error = None
|
|
328
|
+
for attempt in range(self.attempts):
|
|
329
|
+
if attempt:
|
|
330
|
+
await asyncio.sleep(_pause(attempt - 1, self.pause))
|
|
331
|
+
try:
|
|
332
|
+
response, error = await self._http.request(call.method, call.path, json=call.body, params=call.params), None
|
|
333
|
+
except httpx.TransportError as e:
|
|
334
|
+
response, error = None, e
|
|
335
|
+
continue
|
|
336
|
+
got = _result(call, response)
|
|
337
|
+
if got is not None:
|
|
338
|
+
return got
|
|
339
|
+
raise _gave_up(call, response, error)
|
|
340
|
+
|
|
341
|
+
async def iter_accounts(self, page=100):
|
|
342
|
+
"""Every account, a page at a time."""
|
|
343
|
+
after = None
|
|
344
|
+
while True:
|
|
345
|
+
accounts, after = await self.accounts(after, page)
|
|
346
|
+
for account in accounts:
|
|
347
|
+
yield account
|
|
348
|
+
if after is None:
|
|
349
|
+
return
|
|
350
|
+
|
|
351
|
+
async def aclose(self):
|
|
352
|
+
await self._http.aclose()
|
|
353
|
+
|
|
354
|
+
async def __aenter__(self):
|
|
355
|
+
return self
|
|
356
|
+
|
|
357
|
+
async def __aexit__(self, *_):
|
|
358
|
+
await self.aclose()
|
chimerakit/errors.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""What can go wrong, as one exception per kind of answer."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ChimeraError(Exception):
|
|
5
|
+
"""Anything the API refused or could not answer. `status` is the HTTP status, or None if no answer came. `request_id` is set on a call that made one: retry with it, never a new one."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message, status=None, request_id=None):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status, self.request_id = status, request_id
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AccessDenied(ChimeraError):
|
|
13
|
+
"""401: a wrong, revoked or expired secret, a secret without this permission, or a bank that is not switched on. The answer does not say which, on purpose."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LimitExceeded(ChimeraError):
|
|
17
|
+
"""403: over the bank's limit for one transfer or for the day."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotFound(ChimeraError):
|
|
21
|
+
"""404: missing, or another bank's. The answer does not say which, on purpose."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Conflict(ChimeraError):
|
|
25
|
+
"""409: it cannot go ahead as things stand. A frozen account, not enough funds, a deposit already in progress, or a request ID used for something else."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InvalidRequest(ChimeraError):
|
|
29
|
+
"""422: the request itself is wrong, such as an amount that is not a positive number."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RateLimited(ChimeraError):
|
|
33
|
+
"""429, after the retries ran out."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Unavailable(ChimeraError):
|
|
37
|
+
"""No clear answer, after the retries ran out. THE CALL MAY HAVE WORKED. Retry it with `request_id` from this error, and it cannot happen twice."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
BY_STATUS = {401: AccessDenied, 403: LimitExceeded, 404: NotFound, 409: Conflict, 422: InvalidRequest, 429: RateLimited}
|
chimerakit/sandbox.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A pretend Chimera on your own computer. It holds no real funds, reaches nothing outside your computer, and forgets everything when it stops.
|
|
3
|
+
|
|
4
|
+
python -m chimerakit.sandbox
|
|
5
|
+
|
|
6
|
+
Chimera(SANDBOX_SECRET, base_url="http://127.0.0.1:8099")
|
|
7
|
+
|
|
8
|
+
In real use the funds move in the game. Here you act that out by hand:
|
|
9
|
+
|
|
10
|
+
POST /sandbox/arrive {"code": "...", "money": "100.00"} the in-game transfer reaches Chimera. Add "note" to send a wrong note, and "alliance_id" to send from the wrong alliance
|
|
11
|
+
POST /sandbox/send {"withdrawal_id": 1} Chimera sends a withdrawal to your alliance
|
|
12
|
+
|
|
13
|
+
In Python, use the same two steps directly: `sandbox.arrive(code, money="100.00")` and `sandbox.send(withdrawal_id)`.
|
|
14
|
+
|
|
15
|
+
Every answer has the same shape and status as the real API, and a test in Chimera's own repository holds the two together. Set `transfer_limit` and `daily_limit` to try your limit errors.
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import secrets
|
|
20
|
+
import threading
|
|
21
|
+
from datetime import datetime, timedelta, timezone
|
|
22
|
+
from decimal import Decimal
|
|
23
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
24
|
+
from urllib.parse import parse_qs, urlsplit
|
|
25
|
+
|
|
26
|
+
from .client import CENTS, RESOURCES
|
|
27
|
+
|
|
28
|
+
SANDBOX_SECRET = "chim_sandbox_not-a-real-secret"
|
|
29
|
+
ALLIANCE_ID = 1000
|
|
30
|
+
CODE_LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
|
31
|
+
WINDOW_MINUTES = 30
|
|
32
|
+
MAX_PAGE = 500
|
|
33
|
+
MAX_LINES = 500
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Refused(Exception):
|
|
37
|
+
def __init__(self, status, detail):
|
|
38
|
+
super().__init__(detail)
|
|
39
|
+
self.status, self.detail = status, detail
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _text(amounts):
|
|
43
|
+
return {k: str(v) for k, v in amounts.items()}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _parse(amounts):
|
|
47
|
+
if not isinstance(amounts, dict) or not amounts:
|
|
48
|
+
raise _Refused(422, "Enter an amount.")
|
|
49
|
+
out = {}
|
|
50
|
+
for k, v in amounts.items():
|
|
51
|
+
if k not in RESOURCES or not isinstance(v, str) or not CENTS.fullmatch(v) or not Decimal(v):
|
|
52
|
+
raise _Refused(422, "Invalid request.")
|
|
53
|
+
out[k] = Decimal(v)
|
|
54
|
+
return out
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Sandbox:
|
|
58
|
+
"""The pretend bank and its web server. Use `with Sandbox() as s:` and give a client s.url."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, port=0, alliance_id=ALLIANCE_ID, transfer_limit=Decimal(10) ** 12, daily_limit=Decimal(10) ** 13):
|
|
61
|
+
self.alliance_id, self.transfer_limit, self.daily_limit = alliance_id, Decimal(transfer_limit), Decimal(daily_limit)
|
|
62
|
+
self.lock = threading.RLock()
|
|
63
|
+
self.accounts, self.requests, self.used, self.next_id = {}, {}, {}, 1
|
|
64
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", port), _handler(self))
|
|
65
|
+
self.url = "http://127.0.0.1:%d" % self.server.server_address[1]
|
|
66
|
+
|
|
67
|
+
def __enter__(self):
|
|
68
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
69
|
+
return self
|
|
70
|
+
|
|
71
|
+
def __exit__(self, *_):
|
|
72
|
+
self.server.shutdown()
|
|
73
|
+
self.server.server_close()
|
|
74
|
+
|
|
75
|
+
def _id(self):
|
|
76
|
+
self.next_id += 1
|
|
77
|
+
return self.next_id - 1
|
|
78
|
+
|
|
79
|
+
def _code(self):
|
|
80
|
+
return "".join(secrets.choice(CODE_LETTERS) for _ in range(16))
|
|
81
|
+
|
|
82
|
+
def _account_of(self, discord_id, nation_id, message="That account was not found."):
|
|
83
|
+
a = self.accounts.get(int(discord_id))
|
|
84
|
+
if a is None or a["nation_id"] != nation_id:
|
|
85
|
+
raise _Refused(404, message)
|
|
86
|
+
return a
|
|
87
|
+
|
|
88
|
+
def _limits(self, kind, amounts):
|
|
89
|
+
if any(v > self.transfer_limit for v in amounts.values()):
|
|
90
|
+
raise _Refused(403, "That is over this bank's limit for one transfer.")
|
|
91
|
+
if any(sum((r["amounts"].get(k, 0) for r in self.requests.values() if r["group"] == kind), Decimal(0)) + v > self.daily_limit for k, v in amounts.items()):
|
|
92
|
+
raise _Refused(403, "That is over this bank's limit for the day.")
|
|
93
|
+
|
|
94
|
+
def _status(self, r):
|
|
95
|
+
return r["status"]
|
|
96
|
+
|
|
97
|
+
def _account_view(self, a, with_pending=False):
|
|
98
|
+
view = {"accountid": str(a["accountid"]), "discord_id": str(a["discord_id"]), "nation_id": a["nation_id"], "custody": "chimera", "frozen": a["frozen"], "balances": {k: str(a["balances"][k]) for k in RESOURCES}}
|
|
99
|
+
if with_pending:
|
|
100
|
+
view["pending_deposits"] = [{"deposit_id": r["id"], "amounts": _text(amounts), "expires_at": r["expires_at"].isoformat()} for r in self.requests.values() if r["status"] == "waiting"
|
|
101
|
+
for acct, amounts in r["lines"] if acct is a]
|
|
102
|
+
return view
|
|
103
|
+
|
|
104
|
+
def _deposit_view(self, r, repeat=False):
|
|
105
|
+
a = r["lines"][0][0]
|
|
106
|
+
return {"deposit_id": r["id"], "accountid": str(a["accountid"]), "discord_id": str(a["discord_id"]), "nation_id": a["nation_id"], "alliance_id": self.alliance_id, "code": r["code"],
|
|
107
|
+
"amounts": _text(r["amounts"]), "status": r["status"], "created_at": r["created_at"].isoformat(), "expires_at": r["expires_at"].isoformat(), "repeat": repeat}
|
|
108
|
+
|
|
109
|
+
def _handoff_view(self, r, repeat=False):
|
|
110
|
+
lines = [{"accountid": str(a["accountid"]), "discord_id": str(a["discord_id"]), "nation_id": a["nation_id"], "amounts": _text(m)} for a, m in r["lines"]]
|
|
111
|
+
return {"handoff_id": r["id"], "alliance_id": self.alliance_id, "code": r["code"], "amounts": _text(r["amounts"]), "lines": lines, "status": r["status"],
|
|
112
|
+
"created_at": r["created_at"].isoformat(), "expires_at": r["expires_at"].isoformat(), "repeat": repeat}
|
|
113
|
+
|
|
114
|
+
def _withdrawal_view(self, r, repeat=False):
|
|
115
|
+
a = r["lines"][0][0]
|
|
116
|
+
return {"withdrawal_id": r["id"], "accountid": str(a["accountid"]), "discord_id": str(a["discord_id"]), "nation_id": a["nation_id"], "alliance_id": self.alliance_id, "code": r["code"],
|
|
117
|
+
"amounts": _text(r["amounts"]), "status": r["status"], "created_at": r["created_at"].isoformat(), "repeat": repeat}
|
|
118
|
+
|
|
119
|
+
def _new(self, kind, group, lines, amounts, key):
|
|
120
|
+
now = datetime.now(timezone.utc)
|
|
121
|
+
r = {"id": self._id(), "kind": kind, "group": group, "lines": lines, "amounts": amounts, "code": self._code(), "status": "waiting" if group == "deposit" else "processing", "key": key,
|
|
122
|
+
"created_at": now, "expires_at": now + timedelta(minutes=WINDOW_MINUTES), "confirmed": False}
|
|
123
|
+
self.requests[r["id"]] = r
|
|
124
|
+
return r
|
|
125
|
+
|
|
126
|
+
def _repeat(self, key):
|
|
127
|
+
return next((r for r in self.requests.values() if r["key"] == key), None)
|
|
128
|
+
|
|
129
|
+
def _find(self, kind, request_id, message):
|
|
130
|
+
r = self.requests.get(request_id)
|
|
131
|
+
if r is None or r["kind"] != kind:
|
|
132
|
+
raise _Refused(404, message)
|
|
133
|
+
return r
|
|
134
|
+
|
|
135
|
+
# -- the API --------------------------------------------------------------
|
|
136
|
+
def open_account(self, body):
|
|
137
|
+
d, nation = int(body["discord_id"]), body["nation_id"]
|
|
138
|
+
a = self.accounts.get(d)
|
|
139
|
+
if a is not None and a["nation_id"] != nation or any(x["nation_id"] == nation and x["discord_id"] != d for x in self.accounts.values()):
|
|
140
|
+
raise _Refused(422, "That Discord ID and nation do not match this bank's records.")
|
|
141
|
+
if a is None:
|
|
142
|
+
a = self.accounts[d] = {"accountid": 1000000 + len(self.accounts), "discord_id": d, "nation_id": nation, "frozen": False, "balances": {k: Decimal("0.00") for k in RESOURCES}}
|
|
143
|
+
return self._account_view(a)
|
|
144
|
+
|
|
145
|
+
def list_accounts(self, after, limit):
|
|
146
|
+
rows = sorted((a for a in self.accounts.values() if a["accountid"] > after), key=lambda a: a["accountid"])[:limit]
|
|
147
|
+
return {"accounts": [self._account_view(a, True) for a in rows], "next_after": str(rows[-1]["accountid"]) if len(rows) == limit else None}
|
|
148
|
+
|
|
149
|
+
def create_deposit(self, body):
|
|
150
|
+
amounts, key = _parse(body["amounts"]), "d:" + body["request_id"]
|
|
151
|
+
again = self._repeat(key)
|
|
152
|
+
if again:
|
|
153
|
+
a = again["lines"][0][0]
|
|
154
|
+
if (str(a["discord_id"]), a["nation_id"]) != (body["discord_id"], body["nation_id"]) or again["amounts"] != amounts:
|
|
155
|
+
raise _Refused(409, "That request ID was already used for a different deposit.")
|
|
156
|
+
return self._deposit_view(again, True)
|
|
157
|
+
a = self._account_of(body["discord_id"], body["nation_id"])
|
|
158
|
+
if a["frozen"]:
|
|
159
|
+
raise _Refused(409, "That account is frozen.")
|
|
160
|
+
self._limits("deposit", amounts)
|
|
161
|
+
if any(r["kind"] == "deposit" and r["status"] in ("waiting", "under_review") and r["lines"][0][0] is a for r in self.requests.values()):
|
|
162
|
+
raise _Refused(409, "That client already has a deposit in progress.")
|
|
163
|
+
return self._deposit_view(self._new("deposit", "deposit", [(a, amounts)], amounts, key))
|
|
164
|
+
|
|
165
|
+
def create_handoff(self, body):
|
|
166
|
+
wanted, total = [], {}
|
|
167
|
+
lines = body["lines"]
|
|
168
|
+
if len({x["discord_id"] for x in lines}) != len(lines):
|
|
169
|
+
raise _Refused(422, "A client can only appear once in a handoff.")
|
|
170
|
+
for x in lines:
|
|
171
|
+
m = _parse(x["amounts"])
|
|
172
|
+
wanted.append((x, m))
|
|
173
|
+
for k, v in m.items():
|
|
174
|
+
total[k] = total.get(k, Decimal(0)) + v
|
|
175
|
+
key = "h:" + body["request_id"]
|
|
176
|
+
again = self._repeat(key)
|
|
177
|
+
if again:
|
|
178
|
+
if {(a["discord_id"], tuple(sorted(m.items()))) for a, m in again["lines"]} != {(int(x["discord_id"]), tuple(sorted(m.items()))) for x, m in wanted}:
|
|
179
|
+
raise _Refused(409, "That request ID was already used for a different handoff.")
|
|
180
|
+
return self._handoff_view(again, True)
|
|
181
|
+
accounts = []
|
|
182
|
+
for x, m in wanted:
|
|
183
|
+
a = self._account_of(x["discord_id"], x["nation_id"], "The account for client %s was not found." % x["discord_id"])
|
|
184
|
+
if a["frozen"]:
|
|
185
|
+
raise _Refused(409, "The account for client %s is frozen." % x["discord_id"])
|
|
186
|
+
accounts.append((a, m))
|
|
187
|
+
self._limits("deposit", total)
|
|
188
|
+
return self._handoff_view(self._new("handoff", "deposit", accounts, total, key))
|
|
189
|
+
|
|
190
|
+
def create_withdrawal(self, body):
|
|
191
|
+
amounts, key = _parse(body["amounts"]), "w:" + body["request_id"]
|
|
192
|
+
again = self._repeat(key)
|
|
193
|
+
if again:
|
|
194
|
+
a = again["lines"][0][0]
|
|
195
|
+
if (str(a["discord_id"]), a["nation_id"]) != (body["discord_id"], body["nation_id"]) or again["amounts"] != amounts:
|
|
196
|
+
raise _Refused(409, "That request ID was already used for a different withdrawal.")
|
|
197
|
+
return self._withdrawal_view(again, True)
|
|
198
|
+
a = self._account_of(body["discord_id"], body["nation_id"])
|
|
199
|
+
if a["frozen"]:
|
|
200
|
+
raise _Refused(409, "That account is frozen.")
|
|
201
|
+
self._limits("withdraw", amounts)
|
|
202
|
+
if any(a["balances"][k] < v for k, v in amounts.items()):
|
|
203
|
+
raise _Refused(409, "That account does not hold enough.")
|
|
204
|
+
for k, v in amounts.items():
|
|
205
|
+
a["balances"][k] -= v
|
|
206
|
+
return self._withdrawal_view(self._new("withdrawal", "withdraw", [(a, amounts)], amounts, key))
|
|
207
|
+
|
|
208
|
+
def confirm_withdrawal(self, withdrawal_id, code):
|
|
209
|
+
r = self._find("withdrawal", withdrawal_id, "That withdrawal was not found.")
|
|
210
|
+
if r["code"] != code:
|
|
211
|
+
raise _Refused(409, "That code does not match this withdrawal.")
|
|
212
|
+
if r["status"] not in ("sent", "completed"):
|
|
213
|
+
raise _Refused(409, "That withdrawal has not been sent.")
|
|
214
|
+
view = self._withdrawal_view(r) # as on the real API: this answer shows what was true when the bank called
|
|
215
|
+
r["status"] = "completed"
|
|
216
|
+
return view
|
|
217
|
+
|
|
218
|
+
# -- acting out the game ----------------------------------------------------
|
|
219
|
+
def arrive(self, code, note=None, alliance_id=None, **amounts):
|
|
220
|
+
"""The in-game transfer reaches Chimera. Returns what happened: credited, or under_review with the reason the real worker would give."""
|
|
221
|
+
with self.lock:
|
|
222
|
+
r = next((x for x in self.requests.values() if x["code"] == code and x["group"] == "deposit"), None)
|
|
223
|
+
if r is None:
|
|
224
|
+
raise _Refused(404, "No deposit has that code.")
|
|
225
|
+
sent = {k: Decimal(str(v)) for k, v in amounts.items()}
|
|
226
|
+
why = ("The funds came from another alliance." if alliance_id not in (None, self.alliance_id) else "The note is not exactly the code." if (code if note is None else note) != code
|
|
227
|
+
else "This code was already used." if r["status"] == "credited" else "The amounts sent do not match the request." if sent != r["amounts"] else None)
|
|
228
|
+
if why:
|
|
229
|
+
r["status"] = "under_review" if r["status"] != "credited" else r["status"]
|
|
230
|
+
return {"status": "under_review", "reason": why}
|
|
231
|
+
for a, m in r["lines"]:
|
|
232
|
+
for k, v in m.items():
|
|
233
|
+
a["balances"][k] += v
|
|
234
|
+
r["status"] = "credited"
|
|
235
|
+
return {"status": "credited", "reason": None}
|
|
236
|
+
|
|
237
|
+
def send(self, withdrawal_id):
|
|
238
|
+
"""Chimera sends the withdrawal. Check `code` against your in-game record, then confirm."""
|
|
239
|
+
with self.lock:
|
|
240
|
+
r = self._find("withdrawal", withdrawal_id, "That withdrawal was not found.")
|
|
241
|
+
r["status"] = "sent"
|
|
242
|
+
return {"status": "sent", "code": r["code"]}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _handler(box):
|
|
246
|
+
class Handler(BaseHTTPRequestHandler):
|
|
247
|
+
def log_message(self, *_):
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
def _answer(self, status, body):
|
|
251
|
+
data = json.dumps(body).encode()
|
|
252
|
+
self.send_response(status)
|
|
253
|
+
self.send_header("Content-Type", "application/json")
|
|
254
|
+
self.send_header("Content-Length", str(len(data)))
|
|
255
|
+
self.end_headers()
|
|
256
|
+
self.wfile.write(data)
|
|
257
|
+
|
|
258
|
+
def _route(self, method, path, query, body):
|
|
259
|
+
if path == "/health":
|
|
260
|
+
return 200, {"status": "ok"}
|
|
261
|
+
if path == "/sandbox/arrive":
|
|
262
|
+
kw = dict(body)
|
|
263
|
+
return 200, box.arrive(kw.pop("code"), **kw)
|
|
264
|
+
if path == "/sandbox/send":
|
|
265
|
+
return 200, box.send(body["withdrawal_id"])
|
|
266
|
+
if self.headers.get("Authorization") != "Bearer " + SANDBOX_SECRET:
|
|
267
|
+
raise _Refused(401, "Not authorized.")
|
|
268
|
+
if (method, path) == ("POST", "/v1/accounts"):
|
|
269
|
+
return 201, box.open_account(body)
|
|
270
|
+
if (method, path) == ("GET", "/v1/accounts"):
|
|
271
|
+
limit = int(query.get("limit", ["100"])[0])
|
|
272
|
+
if not 1 <= limit <= MAX_PAGE:
|
|
273
|
+
raise _Refused(422, "Invalid request.")
|
|
274
|
+
return 200, box.list_accounts(int(query.get("after", ["0"])[0]), limit)
|
|
275
|
+
if (method, path) == ("POST", "/v1/deposits"):
|
|
276
|
+
return 201, box.create_deposit(body)
|
|
277
|
+
if (method, path) == ("POST", "/v1/handoffs"):
|
|
278
|
+
if not 1 <= len(body.get("lines", [])) <= MAX_LINES:
|
|
279
|
+
raise _Refused(422, "Invalid request.")
|
|
280
|
+
return 201, box.create_handoff(body)
|
|
281
|
+
if (method, path) == ("POST", "/v1/withdrawals"):
|
|
282
|
+
return 201, box.create_withdrawal(body)
|
|
283
|
+
parts = path.strip("/").split("/")
|
|
284
|
+
if method == "GET" and len(parts) == 3 and parts[0] == "v1" and parts[2].isdigit():
|
|
285
|
+
found = {"deposits": ("deposit", box._deposit_view, "That deposit was not found."), "handoffs": ("handoff", box._handoff_view, "That handoff was not found."),
|
|
286
|
+
"withdrawals": ("withdrawal", box._withdrawal_view, "That withdrawal was not found.")}.get(parts[1])
|
|
287
|
+
if found:
|
|
288
|
+
return 200, found[1](box._find(found[0], int(parts[2]), found[2]))
|
|
289
|
+
if method == "POST" and len(parts) == 4 and parts[:2] == ["v1", "withdrawals"] and parts[3] == "confirm" and parts[2].isdigit():
|
|
290
|
+
return 200, box.confirm_withdrawal(int(parts[2]), body.get("code"))
|
|
291
|
+
raise _Refused(404, "Not Found")
|
|
292
|
+
|
|
293
|
+
def _serve(self, method):
|
|
294
|
+
url = urlsplit(self.path)
|
|
295
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
296
|
+
try:
|
|
297
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
298
|
+
with box.lock:
|
|
299
|
+
status, answer = self._route(method, url.path, parse_qs(url.query), body)
|
|
300
|
+
except _Refused as e:
|
|
301
|
+
status, answer = e.status, {"detail": e.detail}
|
|
302
|
+
except (KeyError, TypeError, ValueError):
|
|
303
|
+
status, answer = 422, {"detail": "Invalid request."}
|
|
304
|
+
self._answer(status, answer)
|
|
305
|
+
|
|
306
|
+
def do_GET(self):
|
|
307
|
+
self._serve("GET")
|
|
308
|
+
|
|
309
|
+
def do_POST(self):
|
|
310
|
+
self._serve("POST")
|
|
311
|
+
|
|
312
|
+
return Handler
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def main():
|
|
316
|
+
parser = argparse.ArgumentParser(description="A pretend Chimera on your own computer. No real funds.")
|
|
317
|
+
parser.add_argument("--port", type=int, default=8099)
|
|
318
|
+
args = parser.parse_args()
|
|
319
|
+
box = Sandbox(args.port)
|
|
320
|
+
print("Sandbox at %s\nTest secret: %s\nPress Ctrl+C to stop. Everything is forgotten when it stops." % (box.url, SANDBOX_SECRET), flush=True)
|
|
321
|
+
try:
|
|
322
|
+
box.server.serve_forever()
|
|
323
|
+
except KeyboardInterrupt:
|
|
324
|
+
pass
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
if __name__ == "__main__":
|
|
328
|
+
main()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: chimerakit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python client for the Chimera partner bank API
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
|
|
9
|
+
# chimerakit
|
|
10
|
+
|
|
11
|
+
The Python client for the Chimera partner bank API. Your bank keeps its own bot and its own clients. Chimera holds the funds and the ledger.
|
|
12
|
+
|
|
13
|
+
pip install ./chimerakit
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from chimerakit import Chimera
|
|
17
|
+
|
|
18
|
+
chimera = Chimera(os.environ["CHIMERA_SECRET"], base_url="https://api.projectimagite.com")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**The pilot is deposits only.** Deposits, handoffs and balances work. **Withdrawals are not available yet** — your secret will not carry that scope, so `create_withdrawal` is refused with `AccessDenied` until Chimera turns it on. Everything about withdrawals below is written for when it does; build against it if you like, but you cannot exercise it outside the sandbox. Getting funds back out during the pilot is arranged with Chimera directly, and the terms say how.
|
|
22
|
+
|
|
23
|
+
## Try it first, with no real funds
|
|
24
|
+
|
|
25
|
+
The sandbox is a pretend Chimera on your own computer. It holds no real funds and needs no account. Start it with:
|
|
26
|
+
|
|
27
|
+
python -m chimerakit.sandbox
|
|
28
|
+
|
|
29
|
+
Then point your own bot at it:
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from chimerakit import Chimera
|
|
33
|
+
from chimerakit.sandbox import SANDBOX_SECRET
|
|
34
|
+
|
|
35
|
+
chimera = Chimera(SANDBOX_SECRET, base_url="http://127.0.0.1:8099")
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The sandbox gives the same answers as the real API, and a test in Chimera's repository holds the two together. It has no `under_review` queue with staff behind it, and it never expires a code.
|
|
39
|
+
|
|
40
|
+
## Using it
|
|
41
|
+
|
|
42
|
+
`Chimera` is for a blocking program. `AsyncChimera` has the same methods for a Discord bot or anything on asyncio. Worked examples for both — `quickstart.py` and `discord_bot.py` — come with the source rather than the installed package; ask Chimera for them if you do not have the repository.
|
|
43
|
+
|
|
44
|
+
| Method | What it does |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `open_account(discord_id, nation_id)` | Opens an empty account. Harmless to repeat |
|
|
47
|
+
| `accounts(after, limit)`, `iter_accounts()` | Your clients' balances, each with any deposits on the way |
|
|
48
|
+
| `create_deposit(discord_id, nation_id, amounts, request_id)` | Says funds are coming, and gets the code |
|
|
49
|
+
| `get_deposit(deposit_id)` | `waiting`, `credited`, `expired` or `under_review` |
|
|
50
|
+
| `create_handoff(lines, request_id)` | One transfer for up to 500 clients |
|
|
51
|
+
| `get_handoff(handoff_id)` | The same four statuses |
|
|
52
|
+
| `create_withdrawal(discord_id, nation_id, amounts, request_id)` | Takes the amounts off the balance at once |
|
|
53
|
+
| `get_withdrawal(withdrawal_id)` | `processing`, `sent`, `completed`, `failed` or `under_review` |
|
|
54
|
+
| `confirm_withdrawal(withdrawal_id, code)` | Says the funds reached your alliance |
|
|
55
|
+
|
|
56
|
+
### A deposit
|
|
57
|
+
|
|
58
|
+
1. `create_deposit` gives you a `code`, an `alliance_id` and `amounts`.
|
|
59
|
+
2. Send exactly those amounts, in the game, from your registered alliance to Chimera (alliance 15065).
|
|
60
|
+
3. The code must be the **whole note**, with nothing around it. A note like `deposit ABCD...` is not credited.
|
|
61
|
+
4. Do it before `expires_at`. Funds that arrive late are not lost, but a person has to settle them.
|
|
62
|
+
5. Watch `get_deposit` until it says `credited`.
|
|
63
|
+
|
|
64
|
+
Only a transfer from your registered alliance counts. Sends from a nation, by trade or from another alliance are never credited automatically.
|
|
65
|
+
|
|
66
|
+
A client can have one deposit in progress at a time.
|
|
67
|
+
|
|
68
|
+
### A handoff
|
|
69
|
+
|
|
70
|
+
The tool for a raid: everything leaves your alliance in one transfer, and each client is credited their share.
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from chimerakit import Line
|
|
74
|
+
|
|
75
|
+
handoff = chimera.create_handoff([
|
|
76
|
+
Line(discord_id="1100000000000000001", nation_id=111, amounts={"money": "250.00"}),
|
|
77
|
+
Line(discord_id="1100000000000000002", nation_id=222, amounts={"money": "750.00", "steel": "10"}),
|
|
78
|
+
])
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Send `handoff.amounts`, the total, with `handoff.code` as the whole note. It credits every line or none. Every client needs an account first, and a client frozen or unknown refuses the whole handoff. If the amounts differ by a cent, nobody is credited and a person settles it, so send the total exactly. Limits count the total.
|
|
82
|
+
|
|
83
|
+
A handoff doesn't stop a client depositing, and a client can be in more than one.
|
|
84
|
+
|
|
85
|
+
### A withdrawal
|
|
86
|
+
|
|
87
|
+
**Not available in the pilot** — your secret has no `withdrawals` scope, so these three calls return `AccessDenied`. The sandbox supports them, so you can still build and test against it.
|
|
88
|
+
|
|
89
|
+
1. `create_withdrawal` takes the funds off the client's balance at once.
|
|
90
|
+
2. Chimera sends them to your registered alliance, never to a client. The note is the withdrawal's `code`.
|
|
91
|
+
3. When the status is `sent`, find the transfer in your alliance's bank record and check that its note equals `code`.
|
|
92
|
+
4. Pay your client, then call `confirm_withdrawal`.
|
|
93
|
+
5. It becomes `completed` when your confirmation and the game's record agree.
|
|
94
|
+
|
|
95
|
+
A large withdrawal may wait for a person, and it stays `processing` or shows `under_review` while it does. Keep checking, and don't send a second one.
|
|
96
|
+
|
|
97
|
+
## Amounts
|
|
98
|
+
|
|
99
|
+
Give amounts as `Decimal`, `int` or `str`, with at most two decimal places. A `float` is refused, because it has already been rounded. Amounts come back as `Decimal`.
|
|
100
|
+
|
|
101
|
+
The names are `money`, `food`, `coal`, `oil`, `uranium`, `lead`, `iron`, `bauxite`, `gasoline`, `munitions`, `steel` and `aluminum`.
|
|
102
|
+
|
|
103
|
+
## Retries
|
|
104
|
+
|
|
105
|
+
Failed calls are tried again automatically, up to four tries in all, and a retry cannot act twice. Each deposit, handoff and withdrawal carries a request ID, and Chimera acts on an ID once.
|
|
106
|
+
|
|
107
|
+
If you let the client make the ID, it still protects its own retries. To be safe across a crash, make the ID yourself with `new_request_id()`, save it, and only then make the call.
|
|
108
|
+
|
|
109
|
+
If the tries run out you get `Unavailable`. **That doesn't mean the call failed.** It means there was no clear answer. Repeat the call with `e.request_id`, and never with a new ID: a new ID on a withdrawal could take the funds twice.
|
|
110
|
+
|
|
111
|
+
Using an ID again with different details is refused with `Conflict`.
|
|
112
|
+
|
|
113
|
+
## Errors
|
|
114
|
+
|
|
115
|
+
Each one has `status` and `request_id`, and they all come from `ChimeraError`.
|
|
116
|
+
|
|
117
|
+
| Error | Means | What to do |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `AccessDenied` | Wrong or revoked secret, or your bank isn't switched on | Don't retry. Talk to Chimera |
|
|
120
|
+
| `LimitExceeded` | Over your bank's limit for one transfer or the day | Don't retry. Limits are set by Chimera |
|
|
121
|
+
| `NotFound` | Missing, or not yours | Don't retry |
|
|
122
|
+
| `Conflict` | Frozen, not enough funds, a deposit in progress, an ID reused | Read the message |
|
|
123
|
+
| `InvalidRequest` | The request itself is wrong | Fix it |
|
|
124
|
+
| `RateLimited` | Over 300 calls a minute, after the retries | Slow down |
|
|
125
|
+
| `Unavailable` | No clear answer, after the retries | Repeat with the same ID |
|
|
126
|
+
|
|
127
|
+
## Your secret
|
|
128
|
+
|
|
129
|
+
Treat it like a key to your clients' funds, because it is one. Keep it in an environment variable. Never commit it or log it. If it leaks, tell Chimera at once: it can be replaced in seconds, and your clients' balances don't change. The client refuses to send it anywhere but HTTPS, apart from this computer for the sandbox, and it doesn't follow redirects.
|
|
130
|
+
|
|
131
|
+
Your secret begins `chim_`. Chimera stores only a hash of it, so nobody there can read it back to you — if it is lost, it is replaced rather than recovered.
|
|
132
|
+
|
|
133
|
+
### What your secret is allowed to do
|
|
134
|
+
|
|
135
|
+
A secret carries **scopes**, and each call needs the right one. Chimera decides which yours has; you cannot change them.
|
|
136
|
+
|
|
137
|
+
| Scope | Lets you call | In the pilot |
|
|
138
|
+
|---|---|---|
|
|
139
|
+
| `balances` | `open_account`, `accounts`, `iter_accounts` | **yes** |
|
|
140
|
+
| `deposits` | `create_deposit`, `get_deposit`, `create_handoff`, `get_handoff` | **yes** |
|
|
141
|
+
| `withdrawals` | `create_withdrawal`, `get_withdrawal`, `confirm_withdrawal` | no — see the note at the top |
|
|
142
|
+
| `transfers` | reserved for moving funds between clients. **No methods use it yet** | no |
|
|
143
|
+
|
|
144
|
+
A call outside your scopes fails with `AccessDenied`, and so does a wrong secret, a revoked one, an expired one, or a bank that is switched off. **They are deliberately the same error** — it tells you nothing about which, so that a stolen secret learns nothing by probing. If you get `AccessDenied` and believe you should not have, ask Chimera rather than retrying; retrying never helps.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
chimerakit/__init__.py,sha256=941xBfReZ_bVimP32SPgYQajWK5FcUmhLuPIj9ej768,487
|
|
2
|
+
chimerakit/client.py,sha256=iC1GBxZaFN7g8c-TxWRfjjRba07uGNqU06iLNHwk_uM,14415
|
|
3
|
+
chimerakit/errors.py,sha256=ivN4cgtKyZ7wuFaF-H9YD6BnfUXwPyXgYfeuYFq1NIs,1613
|
|
4
|
+
chimerakit/sandbox.py,sha256=AmTN04d844JIrkvYT5_5hF6x3VhiKWnFYJyRzf85Xx4,17193
|
|
5
|
+
chimerakit-0.1.0.dist-info/METADATA,sha256=W5BI4SN8FLnlLv4iMHKJ4kxPe_Mg9xuenw6bWtG_6Nw,8373
|
|
6
|
+
chimerakit-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
chimerakit-0.1.0.dist-info/top_level.txt,sha256=Ybv8scEO7gb9kiEdLf0TrU681Q8SI0EAWlDNu0mk9m0,11
|
|
8
|
+
chimerakit-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chimerakit
|