archerprotocol-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.
archer_sdk/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """Archer contribution SDK for Python: build, monetize, and publish an
2
+ Archer intent. Envelope builders, GUI embeds, caller context, effect
3
+ inference, and a one-call FastAPI handler (archer_sdk.fastapi). Mirrors the
4
+ Node @archerprotocol/sdk; the shared test-vectors enforce parity."""
5
+
6
+ from types import SimpleNamespace
7
+
8
+ from . import embed as _embed
9
+ from .builders import (
10
+ authorization,
11
+ cost,
12
+ data,
13
+ envelope,
14
+ is_invocation_envelope,
15
+ record,
16
+ )
17
+ from .caller import ArcherCaller, get_archer_caller, require_archer_caller
18
+ from .effects import infer_effects
19
+ from .errors import BadInputError, UpstreamError
20
+ from .handler import ArcherHandler, HandlerContext, run_invocation
21
+ from .money import to_micro, to_micro_optional
22
+
23
+ __version__ = "0.1.0"
24
+
25
+ # The one-object surface most handlers want: archer.envelope(...), archer.render(...).
26
+ archer = SimpleNamespace(
27
+ data=data,
28
+ cost=cost,
29
+ authorization=authorization,
30
+ record=record,
31
+ envelope=envelope,
32
+ embed=_embed,
33
+ render=_embed.render,
34
+ )
35
+
36
+ __all__ = [
37
+ "archer",
38
+ "authorization",
39
+ "cost",
40
+ "data",
41
+ "envelope",
42
+ "record",
43
+ "is_invocation_envelope",
44
+ "get_archer_caller",
45
+ "require_archer_caller",
46
+ "ArcherCaller",
47
+ "infer_effects",
48
+ "BadInputError",
49
+ "UpstreamError",
50
+ "ArcherHandler",
51
+ "HandlerContext",
52
+ "run_invocation",
53
+ "to_micro",
54
+ "to_micro_optional",
55
+ ]
archer_sdk/builders.py ADDED
@@ -0,0 +1,149 @@
1
+ """Envelope builders for the generic Archer invocation contract. Inputs take
2
+ pythonic snake_case keywords; outputs are plain dicts whose keys and shapes
3
+ are byte-identical to the Node SDK's JSON (the shared test-vectors enforce
4
+ this). Optional fields are omitted when absent, never set to null."""
5
+
6
+ from typing import Any, Dict, List, Optional
7
+
8
+ from .money import Money, to_micro, to_micro_optional
9
+
10
+ _COST_MODELS = {"flat", "percent", "x402", "embedded", "none"}
11
+
12
+
13
+ def _prune(obj: Dict[str, Any]) -> Dict[str, Any]:
14
+ return {k: v for k, v in obj.items() if v is not None}
15
+
16
+
17
+ def data(value: Any) -> Dict[str, Any]:
18
+ """The Render arm."""
19
+ return {"data": value}
20
+
21
+
22
+ def cost(
23
+ *,
24
+ model: str,
25
+ amount_micro: Optional[Money] = None,
26
+ value_micro: Optional[Money] = None,
27
+ bps: Optional[int] = None,
28
+ bob_fee_micro: Optional[Money] = None,
29
+ archer_fee_micro: Optional[Money] = None,
30
+ ) -> Dict[str, Any]:
31
+ """The Settle arm: a declared fee model plus its parameters."""
32
+ if model in ("flat", "x402"):
33
+ params = _prune(
34
+ {
35
+ "amountMicro": to_micro(amount_micro) if amount_micro is not None else None,
36
+ "archerFeeMicro": to_micro_optional(archer_fee_micro),
37
+ }
38
+ )
39
+ elif model == "percent":
40
+ params = _prune(
41
+ {
42
+ "valueMicro": to_micro(value_micro) if value_micro is not None else None,
43
+ "bps": bps,
44
+ "archerFeeMicro": to_micro_optional(archer_fee_micro),
45
+ }
46
+ )
47
+ elif model == "embedded":
48
+ params = _prune(
49
+ {
50
+ "bobFeeMicro": to_micro_optional(bob_fee_micro),
51
+ "archerFeeMicro": to_micro_optional(archer_fee_micro),
52
+ }
53
+ )
54
+ elif model == "none":
55
+ params = {}
56
+ else:
57
+ raise ValueError(f'unknown fee model "{model}" (expected one of {sorted(_COST_MODELS)})')
58
+ return {"model": model, "params": params}
59
+
60
+
61
+ def authorization(
62
+ *,
63
+ type: str,
64
+ label: str,
65
+ payload: Dict[str, Any],
66
+ namespace: Optional[str] = None,
67
+ ) -> Dict[str, Any]:
68
+ """One entry of the Authorize arm. The label is the human disclosure."""
69
+ if not label or not isinstance(label, str):
70
+ raise ValueError("authorization label is required (human-readable disclosure)")
71
+ return _prune(
72
+ {
73
+ "type": type,
74
+ "namespace": namespace,
75
+ "label": label,
76
+ "payload": payload if payload is not None else {},
77
+ }
78
+ )
79
+
80
+
81
+ def record(
82
+ *,
83
+ record_kind: str,
84
+ basis_micro: Optional[Money] = None,
85
+ value_micro: Optional[Money] = None,
86
+ bob_fee_micro: Optional[Money] = None,
87
+ archer_fee_micro: Optional[Money] = None,
88
+ value_read: Optional[Dict[str, Any]] = None,
89
+ exit_instructions: Optional[Dict[str, Any]] = None,
90
+ ref: Optional[str] = None,
91
+ group_key: Optional[str] = None,
92
+ view_url: Optional[str] = None,
93
+ disclosure: Optional[Dict[str, Any]] = None,
94
+ ) -> Dict[str, Any]:
95
+ """The Record arm: a durable, user-owned outcome (opaque to the engine)."""
96
+ if not record_kind or not isinstance(record_kind, str):
97
+ raise ValueError("record_kind is required")
98
+ return _prune(
99
+ {
100
+ "recordKind": record_kind,
101
+ "basisMicro": to_micro_optional(basis_micro),
102
+ "valueMicro": to_micro_optional(value_micro),
103
+ "bobFeeMicro": to_micro_optional(bob_fee_micro),
104
+ "archerFeeMicro": to_micro_optional(archer_fee_micro),
105
+ "valueRead": value_read,
106
+ "exitInstructions": exit_instructions,
107
+ "ref": ref,
108
+ "groupKey": group_key,
109
+ "viewUrl": view_url,
110
+ "disclosure": disclosure,
111
+ }
112
+ )
113
+
114
+
115
+ def envelope(
116
+ *,
117
+ data: Any = None,
118
+ cost: Optional[Dict[str, Any]] = None,
119
+ authorizations: Optional[List[Dict[str, Any]]] = None,
120
+ record: Optional[Dict[str, Any]] = None,
121
+ ) -> Dict[str, Any]:
122
+ """Assemble the arms into one invocation envelope, validated structurally."""
123
+ env = _prune(
124
+ {"data": data, "cost": cost, "authorizations": authorizations, "record": record}
125
+ )
126
+ if not is_invocation_envelope(env):
127
+ raise ValueError(
128
+ "envelope arms are malformed (cost needs a model, record needs a recordKind, "
129
+ "authorizations must be a list)"
130
+ )
131
+ return env
132
+
133
+
134
+ def is_invocation_envelope(value: Any) -> bool:
135
+ """Structural check mirroring the engine's isInvocationEnvelope."""
136
+ if value is None or not isinstance(value, dict):
137
+ return False
138
+ if "authorizations" in value and value["authorizations"] is not None:
139
+ if not isinstance(value["authorizations"], list):
140
+ return False
141
+ if "cost" in value and value["cost"] is not None:
142
+ c = value["cost"]
143
+ if not isinstance(c, dict) or not isinstance(c.get("model"), str):
144
+ return False
145
+ if "record" in value and value["record"] is not None:
146
+ r = value["record"]
147
+ if not isinstance(r, dict) or not isinstance(r.get("recordKind"), str):
148
+ return False
149
+ return True
archer_sdk/caller.py ADDED
@@ -0,0 +1,39 @@
1
+ """Caller context. Archer injects the invoking user's context inside the
2
+ signed args as args["archerCaller"], so it is verified as part of the
3
+ envelope. Read it here."""
4
+
5
+ from typing import Any, Dict, Optional, TypedDict
6
+
7
+
8
+ class ArcherCaller(TypedDict):
9
+ evmAddress: Optional[str]
10
+ svmAddress: Optional[str]
11
+ archerFeeRate: Optional[float]
12
+
13
+
14
+ def get_archer_caller(args: Dict[str, Any]) -> Optional[ArcherCaller]:
15
+ raw = args.get("archerCaller") if isinstance(args, dict) else None
16
+ if raw is None or not isinstance(raw, dict):
17
+ return None
18
+ evm = raw.get("evmAddress")
19
+ svm = raw.get("svmAddress")
20
+ rate = raw.get("archerFeeRate")
21
+ return {
22
+ "evmAddress": evm if isinstance(evm, str) else None,
23
+ "svmAddress": svm if isinstance(svm, str) else None,
24
+ "archerFeeRate": float(rate)
25
+ if isinstance(rate, (int, float)) and not isinstance(rate, bool)
26
+ else None,
27
+ }
28
+
29
+
30
+ def require_archer_caller(args: Dict[str, Any]) -> ArcherCaller:
31
+ """For intents that move user funds or cause side effects, caller context
32
+ is mandatory. A missing caller almost always means the intent was
33
+ published without requiresCaller: true."""
34
+ caller = get_archer_caller(args)
35
+ if caller is None:
36
+ raise ValueError(
37
+ "caller context is missing; declare requiresCaller: true when publishing this intent"
38
+ )
39
+ return caller
archer_sdk/effects.py ADDED
@@ -0,0 +1,306 @@
1
+ """infer_effects: derive a reviewable effect report from one authorization.
2
+
3
+ Pure and dependency-injected: descriptors are data rows, simulation and
4
+ pricing are injected async callables. The function branches on verdict
5
+ strategy only (simulate / decode / opaque), never on a venue. It never
6
+ raises; an unrecognizable or malformed payload renders as OPAQUE.
7
+
8
+ The shared effects vectors enforce byte-parity with the Node SDK and the
9
+ platform's server-side derivation."""
10
+
11
+ import math
12
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
13
+
14
+ _MAX_ROWS = 30
15
+ _MAX_VALUE_CHARS = 120
16
+ # JS Number.MAX_SAFE_INTEGER: a ceiling beyond any real economy is an
17
+ # unlimited-approval sentinel and reports as unmeasurable.
18
+ _MAX_SAFE = 9007199254740991
19
+
20
+ SimulateFn = Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]
21
+ PriceFn = Callable[[str], Awaitable[Optional[float]]]
22
+
23
+
24
+ async def infer_effects(
25
+ auth: Any,
26
+ *,
27
+ descriptors: List[Dict[str, Any]],
28
+ simulate: Optional[SimulateFn] = None,
29
+ price: Optional[PriceFn] = None,
30
+ ) -> Dict[str, Any]:
31
+ try:
32
+ return await _infer(auth, descriptors, simulate, price)
33
+ except Exception:
34
+ # The last-resort guarantee: whatever went wrong, render opaquely.
35
+ return _opaque_report(auth)
36
+
37
+
38
+ async def _infer(auth, descriptors, simulate, price):
39
+ auth_type = auth.get("type") if isinstance(auth, dict) else None
40
+ auth_type = auth_type if isinstance(auth_type, str) else "unknown"
41
+
42
+ if auth_type == "tx" and simulate is not None:
43
+ try:
44
+ outcome = await simulate(auth)
45
+ value = outcome.get("valueUsd")
46
+ measurable = value if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) else None
47
+ report = {
48
+ "verdict": "SIMULATED",
49
+ "authType": auth_type,
50
+ "namespace": _str_or_none(auth.get("namespace")),
51
+ "measurableValueUsd": measurable,
52
+ "simulation": outcome.get("raw"),
53
+ "display": {"title": _title_of(auth), "rows": outcome.get("rows") or []},
54
+ }
55
+ return _prune_report(report)
56
+ except Exception:
57
+ return _opaque_report(auth)
58
+
59
+ if auth_type == "sign":
60
+ decoded = _decode_against_descriptors(auth, descriptors or [])
61
+ if decoded is not None:
62
+ descriptor, bounded = decoded
63
+ report = {
64
+ "verdict": "DECODED_BOUNDED",
65
+ "authType": auth_type,
66
+ "namespace": _str_or_none(auth.get("namespace")),
67
+ "measurableValueUsd": await _value_of(descriptor, bounded, price),
68
+ "bounded": bounded,
69
+ "display": {
70
+ "title": descriptor["label"],
71
+ "rows": [{"label": k, "value": _clip(v)} for k, v in bounded.items()],
72
+ },
73
+ "descriptorRef": descriptor["id"],
74
+ }
75
+ return _prune_report(report)
76
+
77
+ return _opaque_report(auth)
78
+
79
+
80
+ # ── decoding ────────────────────────────────────────────────────────────────
81
+
82
+
83
+ def _decode_against_descriptors(auth, descriptors):
84
+ payload = _as_dict(auth.get("payload") if isinstance(auth, dict) else None)
85
+ if payload is None:
86
+ return None
87
+ domain = _as_dict(payload.get("domain"))
88
+ message = _as_dict(payload.get("message"))
89
+ primary_type = payload.get("primaryType")
90
+ if domain is None or message is None or not isinstance(primary_type, str):
91
+ return None
92
+
93
+ for descriptor in descriptors:
94
+ if not _matches(descriptor, primary_type, domain):
95
+ continue
96
+ if not _is_canonical_contract(descriptor, domain):
97
+ continue
98
+ bounded = {}
99
+ for entry in descriptor.get("extract", []):
100
+ value = _get_path(message, entry["path"])
101
+ if value is not None and not isinstance(value, (dict, list)):
102
+ bounded[entry["role"]] = _js_str(value)
103
+ return descriptor, bounded
104
+ return None
105
+
106
+
107
+ def _matches(descriptor, primary_type, domain):
108
+ match = descriptor.get("match", {})
109
+ if match.get("primaryType") != primary_type:
110
+ return False
111
+ want = match.get("domain")
112
+ if not want:
113
+ return True
114
+ if "name" in want and domain.get("name") != want["name"]:
115
+ return False
116
+ if "version" in want and domain.get("version") != want["version"]:
117
+ return False
118
+ if "chainId" in want:
119
+ try:
120
+ if float(domain.get("chainId")) != float(want["chainId"]):
121
+ return False
122
+ except (TypeError, ValueError):
123
+ return False
124
+ return True
125
+
126
+
127
+ def _is_canonical_contract(descriptor, domain):
128
+ """The canonical-contract pin: a domain/type match only earns
129
+ DECODED_BOUNDED when the payload's verifyingContract is one of the
130
+ descriptor's canonical addresses. A lookalike domain pointed at another
131
+ contract stays OPAQUE."""
132
+ vc = domain.get("verifyingContract")
133
+ if not isinstance(vc, str) or len(vc) == 0:
134
+ return False
135
+ needle = vc.lower()
136
+ contracts = descriptor.get("riskRules", {}).get("verifyingContracts", [])
137
+ return any(isinstance(c, str) and c.lower() == needle for c in contracts)
138
+
139
+
140
+ # ── valuation ───────────────────────────────────────────────────────────────
141
+
142
+
143
+ async def _value_of(descriptor, bounded, price):
144
+ """Priceability gates measurability: a missing amount, an unbounded
145
+ amount, an unknown asset, or an unpriceable asset all yield None, and the
146
+ policy layer treats None via its opaque-cap behavior."""
147
+ valuation = descriptor.get("valuation")
148
+ if not valuation or price is None:
149
+ return None
150
+
151
+ raw = bounded.get(valuation["source"])
152
+ if raw is None:
153
+ return None
154
+ try:
155
+ units = float(raw) / (10 ** valuation["decimals"])
156
+ except (TypeError, ValueError):
157
+ return None
158
+ if not math.isfinite(units):
159
+ return None
160
+ if units > _MAX_SAFE:
161
+ return None
162
+
163
+ asset = valuation.get("asset")
164
+ if asset is None and valuation.get("assetRole"):
165
+ asset = bounded.get(valuation["assetRole"])
166
+ if not asset:
167
+ return None
168
+
169
+ try:
170
+ unit_price = await price(asset)
171
+ except Exception:
172
+ return None
173
+ if unit_price is None or not math.isfinite(unit_price):
174
+ return None
175
+
176
+ value = units * unit_price
177
+ if not math.isfinite(value):
178
+ return None
179
+ return _js_number(value)
180
+
181
+
182
+ # ── opaque rendering ────────────────────────────────────────────────────────
183
+
184
+
185
+ def _opaque_report(auth):
186
+ namespace = _str_or_none(auth.get("namespace")) if isinstance(auth, dict) else None
187
+ report = {
188
+ "verdict": "OPAQUE",
189
+ "authType": (auth.get("type") if isinstance(auth, dict) and isinstance(auth.get("type"), str) else "unknown"),
190
+ "measurableValueUsd": None,
191
+ "display": {
192
+ "title": _title_of(auth),
193
+ "rows": _flatten_for_display(auth.get("payload") if isinstance(auth, dict) else None),
194
+ },
195
+ }
196
+ if namespace is not None:
197
+ report["namespace"] = namespace
198
+ return _order_report(report)
199
+
200
+
201
+ def _flatten_for_display(payload):
202
+ rows = []
203
+ try:
204
+ _walk(payload, "", rows)
205
+ except Exception:
206
+ rows.append({"label": "payload", "value": "unavailable"})
207
+ if not rows:
208
+ rows.append({"label": "payload", "value": "empty"})
209
+ return rows[:_MAX_ROWS]
210
+
211
+
212
+ def _walk(value, prefix, rows):
213
+ if len(rows) >= _MAX_ROWS:
214
+ return
215
+ if value is None:
216
+ if prefix:
217
+ rows.append({"label": prefix, "value": "None" if value is None else str(value)})
218
+ return
219
+ if not isinstance(value, (dict, list)):
220
+ rows.append({"label": prefix or "value", "value": _clip(_js_str(value))})
221
+ return
222
+ entries = (
223
+ [(str(i), v) for i, v in enumerate(value)]
224
+ if isinstance(value, list)
225
+ else list(value.items())
226
+ )
227
+ for key, child in entries:
228
+ _walk(child, f"{prefix}.{key}" if prefix else key, rows)
229
+ if len(rows) >= _MAX_ROWS:
230
+ return
231
+
232
+
233
+ # ── small helpers ───────────────────────────────────────────────────────────
234
+
235
+
236
+ def _title_of(auth):
237
+ label = auth.get("label") if isinstance(auth, dict) else None
238
+ return label if isinstance(label, str) and len(label) > 0 else "Authorization"
239
+
240
+
241
+ def _as_dict(value):
242
+ return value if isinstance(value, dict) else None
243
+
244
+
245
+ def _get_path(obj, path):
246
+ current = obj
247
+ for segment in path.split("."):
248
+ if not isinstance(current, dict):
249
+ return None
250
+ current = current.get(segment)
251
+ return current
252
+
253
+
254
+ def _str_or_none(value):
255
+ return value if isinstance(value, str) and len(value) > 0 else None
256
+
257
+
258
+ def _clip(value):
259
+ return value[:_MAX_VALUE_CHARS] + "…" if len(value) > _MAX_VALUE_CHARS else value
260
+
261
+
262
+ def _js_str(value):
263
+ """Mirror JS String() for the value types that ride typed-data messages."""
264
+ if isinstance(value, bool):
265
+ return "true" if value else "false"
266
+ if isinstance(value, float):
267
+ return _js_number_str(value)
268
+ return str(value)
269
+
270
+
271
+ def _js_number_str(f):
272
+ if math.isnan(f):
273
+ return "NaN"
274
+ if math.isinf(f):
275
+ return "Infinity" if f > 0 else "-Infinity"
276
+ if f.is_integer() and abs(f) < 1e21:
277
+ return str(int(f))
278
+ return repr(f)
279
+
280
+
281
+ def _js_number(value):
282
+ """Collapse float results that are exact integers to int so JSON matches
283
+ the Node output (12.5 stays 12.5; 5.0 becomes 5)."""
284
+ if isinstance(value, float) and value.is_integer() and abs(value) <= _MAX_SAFE:
285
+ return int(value)
286
+ return value
287
+
288
+
289
+ _REPORT_KEY_ORDER = [
290
+ "verdict",
291
+ "authType",
292
+ "namespace",
293
+ "measurableValueUsd",
294
+ "bounded",
295
+ "simulation",
296
+ "display",
297
+ "descriptorRef",
298
+ ]
299
+
300
+
301
+ def _prune_report(report):
302
+ return _order_report({k: v for k, v in report.items() if v is not None or k == "measurableValueUsd"})
303
+
304
+
305
+ def _order_report(report):
306
+ return {k: report[k] for k in _REPORT_KEY_ORDER if k in report}
archer_sdk/embed.py ADDED
@@ -0,0 +1,164 @@
1
+ """Typed builders for the archerEmbed GUI render contract. Mirrors the
2
+ engine's whitelisted discriminated union (by `kind`); anything off-contract
3
+ is dropped by the engine before rendering, so the builders validate the
4
+ load-bearing fields at author time. Output keys are byte-identical to the
5
+ Node SDK (the shared embeds vectors enforce this)."""
6
+
7
+ from typing import Any, Dict, List, Optional, Union
8
+
9
+ from .builders import _prune
10
+
11
+
12
+ def _forward_compat(
13
+ persistence: Optional[str],
14
+ live_source_url: Optional[str],
15
+ refresh_interval_ms: Optional[int],
16
+ ) -> Dict[str, Any]:
17
+ return _prune(
18
+ {
19
+ "persistence": persistence,
20
+ "liveSourceUrl": live_source_url,
21
+ "refreshIntervalMs": refresh_interval_ms,
22
+ }
23
+ )
24
+
25
+
26
+ def chart(
27
+ *,
28
+ chart_type: str,
29
+ data: List[Dict[str, Any]],
30
+ title: Optional[str] = None,
31
+ x_label: Optional[str] = None,
32
+ y_label: Optional[str] = None,
33
+ series: Optional[List[Dict[str, Any]]] = None,
34
+ y_axis_domain: Optional[Union[str, List[float]]] = None,
35
+ persistence: Optional[str] = None,
36
+ live_source_url: Optional[str] = None,
37
+ refresh_interval_ms: Optional[int] = None,
38
+ ) -> Dict[str, Any]:
39
+ if not isinstance(data, list) or len(data) == 0:
40
+ raise ValueError("chart embed: data must have at least one point")
41
+ return _prune(
42
+ {
43
+ "kind": "chart",
44
+ "chartType": chart_type,
45
+ "title": title,
46
+ "xLabel": x_label,
47
+ "yLabel": y_label,
48
+ "data": data,
49
+ "series": series,
50
+ "yAxisDomain": y_axis_domain,
51
+ **_forward_compat(persistence, live_source_url, refresh_interval_ms),
52
+ }
53
+ )
54
+
55
+
56
+ def metric(
57
+ *,
58
+ label: str,
59
+ value: str,
60
+ delta: Optional[Dict[str, str]] = None,
61
+ persistence: Optional[str] = None,
62
+ live_source_url: Optional[str] = None,
63
+ refresh_interval_ms: Optional[int] = None,
64
+ ) -> Dict[str, Any]:
65
+ if not label or not value:
66
+ raise ValueError("metric embed: label and value are required")
67
+ return _prune(
68
+ {
69
+ "kind": "metric",
70
+ "label": label,
71
+ "value": value,
72
+ "delta": delta,
73
+ **_forward_compat(persistence, live_source_url, refresh_interval_ms),
74
+ }
75
+ )
76
+
77
+
78
+ def table(
79
+ *,
80
+ columns: List[Dict[str, Any]],
81
+ rows: Optional[List[Dict[str, Any]]] = None,
82
+ persistence: Optional[str] = None,
83
+ live_source_url: Optional[str] = None,
84
+ refresh_interval_ms: Optional[int] = None,
85
+ ) -> Dict[str, Any]:
86
+ if not isinstance(columns, list) or len(columns) == 0:
87
+ raise ValueError("table embed: at least one column is required")
88
+ return _prune(
89
+ {
90
+ "kind": "table",
91
+ "columns": columns,
92
+ "rows": rows if rows is not None else [],
93
+ **_forward_compat(persistence, live_source_url, refresh_interval_ms),
94
+ }
95
+ )
96
+
97
+
98
+ def kv(
99
+ *,
100
+ pairs: List[Dict[str, str]],
101
+ persistence: Optional[str] = None,
102
+ live_source_url: Optional[str] = None,
103
+ refresh_interval_ms: Optional[int] = None,
104
+ ) -> Dict[str, Any]:
105
+ if not isinstance(pairs, list) or len(pairs) == 0:
106
+ raise ValueError("kv embed: at least one pair is required")
107
+ return _prune(
108
+ {
109
+ "kind": "kv",
110
+ "pairs": pairs,
111
+ **_forward_compat(persistence, live_source_url, refresh_interval_ms),
112
+ }
113
+ )
114
+
115
+
116
+ def alert(
117
+ *,
118
+ tone: str,
119
+ body: str,
120
+ title: Optional[str] = None,
121
+ persistence: Optional[str] = None,
122
+ live_source_url: Optional[str] = None,
123
+ refresh_interval_ms: Optional[int] = None,
124
+ ) -> Dict[str, Any]:
125
+ if not tone or not body:
126
+ raise ValueError("alert embed: tone and body are required")
127
+ return _prune(
128
+ {
129
+ "kind": "alert",
130
+ "tone": tone,
131
+ "title": title,
132
+ "body": body,
133
+ **_forward_compat(persistence, live_source_url, refresh_interval_ms),
134
+ }
135
+ )
136
+
137
+
138
+ def image(
139
+ *,
140
+ url: str,
141
+ alt: str,
142
+ width: Optional[int] = None,
143
+ height: Optional[int] = None,
144
+ persistence: Optional[str] = None,
145
+ live_source_url: Optional[str] = None,
146
+ refresh_interval_ms: Optional[int] = None,
147
+ ) -> Dict[str, Any]:
148
+ if not url or not alt:
149
+ raise ValueError("image embed: url and alt are required")
150
+ return _prune(
151
+ {
152
+ "kind": "image",
153
+ "url": url,
154
+ "alt": alt,
155
+ "width": width,
156
+ "height": height,
157
+ **_forward_compat(persistence, live_source_url, refresh_interval_ms),
158
+ }
159
+ )
160
+
161
+
162
+ def render(*, text: Optional[str] = None, embed: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
163
+ """The flat read payload the integrated engine reads: text + archerEmbed."""
164
+ return _prune({"text": text, "archerEmbed": embed})
archer_sdk/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Handler error types. The one-call handler maps these to the partner
2
+ response envelope: BadInputError -> 400, UpstreamError -> 503; anything else
3
+ -> 500 with code UNKNOWN."""
4
+
5
+
6
+ class BadInputError(ValueError):
7
+ status = 400
8
+
9
+ def __init__(self, message: str, code: str = "BAD_INPUT"):
10
+ super().__init__(message)
11
+ self.code = code
12
+
13
+
14
+ class UpstreamError(RuntimeError):
15
+ status = 503
16
+
17
+ def __init__(self, message: str, code: str = "UPSTREAM_ERROR"):
18
+ super().__init__(message)
19
+ self.code = code
archer_sdk/fastapi.py ADDED
@@ -0,0 +1,58 @@
1
+ """FastAPI adapter for the one-call handler. Import requires the fastapi
2
+ extra: pip install "archer-sdk[fastapi]"."""
3
+
4
+ from typing import Any, Dict, Optional
5
+
6
+ from archer_verify import create_verifier
7
+
8
+ from .handler import ArcherHandler, run_invocation
9
+
10
+
11
+ def create_archer_intent(
12
+ handler: ArcherHandler,
13
+ *,
14
+ verifier: Any = None,
15
+ verify: Optional[Dict[str, Any]] = None,
16
+ provider_cost_micro: Optional[int] = None,
17
+ ):
18
+ """Build a FastAPI route callable: verifies the signed envelope, invokes
19
+ your handler, returns the partner response envelope. Pass a prebuilt
20
+ `verifier`, or `verify` kwargs forwarded to archer_verify.create_verifier
21
+ (e.g. archer_base_url, public_key_pem)."""
22
+ from fastapi import Request
23
+ from fastapi.responses import JSONResponse
24
+
25
+ v = verifier or create_verifier(**(verify or {}))
26
+
27
+ async def route(request: Request) -> JSONResponse:
28
+ try:
29
+ body = await request.json()
30
+ except Exception:
31
+ body = None
32
+ status, payload = await run_invocation(
33
+ v,
34
+ handler,
35
+ body=body,
36
+ headers=dict(request.headers),
37
+ provider_cost_micro=provider_cost_micro,
38
+ )
39
+ return JSONResponse(status_code=status, content=payload)
40
+
41
+ return route
42
+
43
+
44
+ def mount_archer_tools(
45
+ router: Any,
46
+ tools: Dict[str, ArcherHandler],
47
+ *,
48
+ verifier: Any = None,
49
+ verify: Optional[Dict[str, Any]] = None,
50
+ provider_cost_micro: Optional[int] = None,
51
+ ) -> None:
52
+ """Mount several sub-tools on a router you own (APIRouter or FastAPI app):
53
+ one POST route per path."""
54
+ v = verifier or create_verifier(**(verify or {}))
55
+ for path, handler in tools.items():
56
+ router.post(path)(
57
+ create_archer_intent(handler, verifier=v, provider_cost_micro=provider_cost_micro)
58
+ )
archer_sdk/handler.py ADDED
@@ -0,0 +1,85 @@
1
+ """The one-call request handler, framework-free core. Verifies the signed
2
+ Archer envelope (via archer-verify), extracts the handler context, invokes
3
+ your handler, and wraps the return in the partner response envelope. The
4
+ FastAPI adapter in archer_sdk.fastapi builds routes on top of this."""
5
+
6
+ import inspect
7
+ from dataclasses import dataclass
8
+ from typing import Any, Awaitable, Callable, Dict, Optional, Tuple, Union
9
+
10
+ from .caller import ArcherCaller, get_archer_caller
11
+
12
+
13
+ @dataclass
14
+ class HandlerContext:
15
+ args: Dict[str, Any]
16
+ caller: Optional[ArcherCaller]
17
+ request_id: str
18
+ intent_definition_id: str
19
+ user_id: str
20
+
21
+
22
+ ArcherHandler = Callable[[HandlerContext], Union[Any, Awaitable[Any]]]
23
+
24
+
25
+ def _reject_message(status: int) -> str:
26
+ if status == 401:
27
+ return "Unauthorized: Archer signature missing, stale, or replayed."
28
+ if status == 403:
29
+ return "Forbidden: Archer signature invalid."
30
+ return "Service unavailable: cannot reach Archer to verify the signature."
31
+
32
+
33
+ def _error_status(err: Exception) -> int:
34
+ status = getattr(err, "status", None)
35
+ return status if status in (400, 403, 503) else 500
36
+
37
+
38
+ def _error_code(err: Exception, status: int) -> str:
39
+ code = getattr(err, "code", None)
40
+ if isinstance(code, str):
41
+ return code
42
+ return "UNKNOWN" if status == 500 else "ERROR"
43
+
44
+
45
+ async def run_invocation(
46
+ verifier: Any,
47
+ handler: ArcherHandler,
48
+ *,
49
+ body: Any,
50
+ headers: Dict[str, Any],
51
+ provider_cost_micro: Optional[int] = None,
52
+ ) -> Tuple[int, Dict[str, Any]]:
53
+ """Verify + dispatch one invocation. Returns (http_status, response_body)."""
54
+ result = verifier.verify(headers, body=body)
55
+ if not result.ok:
56
+ status = result.status or 401
57
+ return status, {
58
+ "success": False,
59
+ "error": {"code": result.reason, "message": _reject_message(status)},
60
+ }
61
+
62
+ env = result.envelope or {}
63
+ args = env.get("args") if isinstance(env.get("args"), dict) else {}
64
+ ctx = HandlerContext(
65
+ args=args,
66
+ caller=get_archer_caller(args),
67
+ request_id=env.get("requestId", ""),
68
+ intent_definition_id=env.get("intentDefinitionId", ""),
69
+ user_id=env.get("userId", ""),
70
+ )
71
+
72
+ try:
73
+ invocation = handler(ctx)
74
+ if inspect.isawaitable(invocation):
75
+ invocation = await invocation
76
+ response: Dict[str, Any] = {"success": True, "result": invocation}
77
+ if provider_cost_micro is not None:
78
+ response["providerCostMicro"] = provider_cost_micro
79
+ return 200, response
80
+ except Exception as err:
81
+ status = _error_status(err)
82
+ return status, {
83
+ "success": False,
84
+ "error": {"code": _error_code(err, status), "message": str(err)},
85
+ }
archer_sdk/money.py ADDED
@@ -0,0 +1,33 @@
1
+ """Money coercion: everything the engine bills parses as a BigInt, so every
2
+ micro amount travels as an integer decimal string. Mirrors the Node SDK's
3
+ coercion strictness exactly."""
4
+
5
+ import math
6
+ import re
7
+ from typing import Optional, Union
8
+
9
+ Money = Union[int, float, str]
10
+
11
+ _INT_STRING = re.compile(r"^-?\d+$")
12
+
13
+
14
+ def to_micro(value: Money) -> str:
15
+ """Coerce a money input to an integer decimal string, or raise."""
16
+ if isinstance(value, bool):
17
+ raise TypeError(f"micro amount must be a finite integer, received {value!r}")
18
+ if isinstance(value, int):
19
+ return str(value)
20
+ if isinstance(value, float):
21
+ if not math.isfinite(value) or not value.is_integer():
22
+ raise ValueError(f"micro amount must be a finite integer, received {value!r}")
23
+ return str(int(value))
24
+ if isinstance(value, str):
25
+ s = value.strip()
26
+ if not _INT_STRING.match(s):
27
+ raise ValueError(f'micro amount must be an integer decimal string, received "{value}"')
28
+ return s
29
+ raise TypeError(f"micro amount must be int, integral float, or digit string, received {type(value).__name__}")
30
+
31
+
32
+ def to_micro_optional(value: Optional[Money]) -> Optional[str]:
33
+ return None if value is None else to_micro(value)
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.4
2
+ Name: archerprotocol-sdk
3
+ Version: 0.1.0
4
+ Summary: Build and monetize an Archer intent from Python: typed envelope builders, embeds, caller context, effect inference, and a one-call FastAPI handler.
5
+ License: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/Archer-Laboratories/archer-sdk
7
+ Project-URL: Repository, https://github.com/Archer-Laboratories/archer-sdk
8
+ Project-URL: Issues, https://github.com/Archer-Laboratories/archer-sdk/issues
9
+ Keywords: archer,archer-protocol,intent,partner-sdk,fastapi,ed25519
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: archer-verify>=0.1.0
16
+ Provides-Extra: fastapi
17
+ Requires-Dist: fastapi>=0.110; extra == "fastapi"
18
+ Requires-Dist: starlette>=0.36; extra == "fastapi"
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == "dev"
21
+ Requires-Dist: httpx>=0.27; extra == "dev"
22
+ Requires-Dist: fastapi>=0.110; extra == "dev"
23
+
24
+ # archerprotocol-sdk
25
+
26
+ Build, monetize, and publish an Archer intent from Python. This package
27
+ mirrors the Node `@archerprotocol/sdk` builder surface; both are held to the
28
+ same shared test vectors, so an envelope built here is byte-identical to one
29
+ built in TypeScript.
30
+
31
+ ```bash
32
+ pip install "archerprotocol-sdk[fastapi]"
33
+ ```
34
+
35
+ ## One-call handler (FastAPI)
36
+
37
+ ```python
38
+ from fastapi import FastAPI
39
+ from archer_sdk import archer, require_archer_caller, BadInputError
40
+ from archer_sdk.fastapi import mount_archer_tools
41
+
42
+ app = FastAPI()
43
+
44
+ async def deposit(ctx):
45
+ caller = require_archer_caller(ctx.args)
46
+ amount = ctx.args.get("amount")
47
+ if not amount:
48
+ raise BadInputError('amount is required (e.g. "1.5")')
49
+ return archer.envelope(
50
+ cost=archer.cost(model="embedded", archer_fee_micro=200),
51
+ authorizations=[
52
+ archer.authorization(
53
+ type="tx",
54
+ namespace="evm",
55
+ label=f"Deposit {amount} USDC",
56
+ payload={"chainId": 8453, "to": "0x...", "data": "0x...", "value": "0x0"},
57
+ )
58
+ ],
59
+ record=archer.record(
60
+ record_kind="position",
61
+ basis_micro=1_000_000,
62
+ disclosure={"custody": "SELF", "exitModel": "PERMISSIONLESS"},
63
+ ),
64
+ )
65
+
66
+ mount_archer_tools(app, {"/deposit": deposit})
67
+ ```
68
+
69
+ The handler verifies the signed Archer envelope (via `archer-verify`),
70
+ hands you `ctx.args / ctx.caller / ctx.request_id / ctx.intent_definition_id /
71
+ ctx.user_id`, and wraps your return in the partner response envelope.
72
+ Raise `BadInputError` (400) or `UpstreamError` (503) for clean failures.
73
+
74
+ ## Reads
75
+
76
+ Return the flat render payload instead of an envelope:
77
+
78
+ ```python
79
+ from archer_sdk import archer
80
+
81
+ async def price_chart(ctx):
82
+ return archer.render(
83
+ text="ETH, last 30 days",
84
+ embed=archer.embed.chart(chart_type="line", data=[{"x": "2026-01", "y": 3000}]),
85
+ )
86
+ ```
87
+
88
+ ## Effect inference (pre-flight)
89
+
90
+ `infer_effects` is the same function the platform runs server-side to decode
91
+ a `sign` authorization into a verified, bounded confirmation. Run it locally
92
+ against your descriptors to see exactly what the user's confirmation screen
93
+ will derive; the platform always re-derives its own report.
94
+
95
+ ```python
96
+ from archer_sdk import infer_effects
97
+
98
+ report = await infer_effects(auth, descriptors=my_descriptors, price=my_price_fn)
99
+ assert report["verdict"] == "DECODED_BOUNDED"
100
+ ```
101
+
102
+ ## Publishing
103
+
104
+ Publishing and lifecycle management use the Node CLI, which works without a
105
+ Node project: `npx @archerprotocol/sdk` gives you `archer init / publish /
106
+ activate / stats`.
@@ -0,0 +1,13 @@
1
+ archer_sdk/__init__.py,sha256=7R_va7cFOSphoQFn4jJ5ZuAD2MJG8ir3yeVTEh0OTPA,1408
2
+ archer_sdk/builders.py,sha256=_QNUx0YDTAmLUY48doNzuW6HzkwPYNY80bJ9UAeJefI,5131
3
+ archer_sdk/caller.py,sha256=OJxIUtDOQnjIVeHFFhKo0_GTrDXwc7_cUHlQjhvGWUM,1406
4
+ archer_sdk/effects.py,sha256=KgNeB7yvvewPeWZP_fq5IavcdXx5yS7dAxvWEabSxIA,10491
5
+ archer_sdk/embed.py,sha256=Zos2oFXTb9rSrvbB3CqQKmXx4vNLJXpmN-YtYiDg9FY,4862
6
+ archer_sdk/errors.py,sha256=Q4NjGtWyzn4yVEtLBBc7XV-vLB7CfK28ZTjZDX2OYAk,534
7
+ archer_sdk/fastapi.py,sha256=L1MaeV-sT6v9N-r9g0mwYBiVGKaDNadCpuTYYsuqu1U,1854
8
+ archer_sdk/handler.py,sha256=hLRiapkb1ft4kpZ_cbEObcpB2A6_OuFbiOVMgqLvars,2776
9
+ archer_sdk/money.py,sha256=hJlK91LuSbLgnhaILyd3aiNLdGNEHY0Tu1J2yNhbhtM,1257
10
+ archerprotocol_sdk-0.1.0.dist-info/METADATA,sha256=yRHUohtotgSVxQV7dB7M7cuyONuxPQ_UrMJXTKsQBfU,3683
11
+ archerprotocol_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ archerprotocol_sdk-0.1.0.dist-info/top_level.txt,sha256=0baA18VzWTDojRlynkT01cYeuKlJO8Rm1dn97q0JZas,11
13
+ archerprotocol_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ archer_sdk