arkhai-core 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.
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
market_core/__init__.py,sha256=R6tT0NXfJIf8bFresHue7oDw02Sgtz7bPPV10LvpIFc,78
|
|
2
|
+
market_core/schemas.py,sha256=1SidnJdKRZw3wH_MwiEuWmrPaVON-G-Dtwu3keeXyxM,32656
|
|
3
|
+
arkhai_core-0.1.0.dist-info/METADATA,sha256=lt6BKIBkDs4kkQ0HsgpUwh4ETTwo49aNpZQ6zWK3XbU,179
|
|
4
|
+
arkhai_core-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
arkhai_core-0.1.0.dist-info/RECORD,,
|
market_core/__init__.py
ADDED
market_core/schemas.py
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
"""Domain-agnostic negotiation/settlement wire carriers.
|
|
2
|
+
|
|
3
|
+
This is the protocol-carrier wheel: the shapes both sides of
|
|
4
|
+
``/negotiate/*`` must derive identically from the shared message
|
|
5
|
+
history (escrow proposals/terms, rate slots, provision-terms envelope).
|
|
6
|
+
Both role shells (``core_storefront.models``) and the domain adapters
|
|
7
|
+
on either side (buyer client, storefront runtime, provisioning terms)
|
|
8
|
+
import from here, so any change is a cross-package wire break.
|
|
9
|
+
|
|
10
|
+
Rules: zero domain vocabulary (explicitly marked legacy wire shims are
|
|
11
|
+
the only exception, and they leave with the client-wheel wire bump) and
|
|
12
|
+
no dependencies beyond pydantic — the wheel must stay importable by
|
|
13
|
+
every role without dragging in role or kit code.
|
|
14
|
+
|
|
15
|
+
Settlement mechanisms: the negotiated outcome is carried as a
|
|
16
|
+
``SettlementPlan`` — per obligation, lifecycle universals
|
|
17
|
+
(payer/claimant, amount/asset, expiration, conditions) as typed fields
|
|
18
|
+
and everything mechanism-specific behind a ``{mechanism, params}``
|
|
19
|
+
envelope whose deterministic interpretation lives in kit codecs
|
|
20
|
+
(``kit/alkahest`` first; fiat providers later). The flat alkahest
|
|
21
|
+
shapes (``EscrowTerms``; proposals keyed on chain + contract address)
|
|
22
|
+
predate the envelope: ``EscrowTerms`` survives as the typed params
|
|
23
|
+
shape of the ``alkahest.v1`` mechanism and as a marked legacy wire
|
|
24
|
+
coercion into the envelope (work item I.1 of
|
|
25
|
+
``docs/development/ARCHITECTURE.md, "Settlement Lifecycle"``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any, Literal
|
|
31
|
+
|
|
32
|
+
from pydantic import (
|
|
33
|
+
BaseModel,
|
|
34
|
+
Field,
|
|
35
|
+
SerializeAsAny,
|
|
36
|
+
field_serializer,
|
|
37
|
+
field_validator,
|
|
38
|
+
model_validator,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ERC20TokenMetadata(BaseModel):
|
|
43
|
+
"""Resolved ERC-20 token metadata used by token-priced listings."""
|
|
44
|
+
|
|
45
|
+
symbol: str
|
|
46
|
+
name: str | None = None
|
|
47
|
+
contract_address: str
|
|
48
|
+
decimals: int
|
|
49
|
+
chain_id: int | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# uint256 wire helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# Amounts (token amount, price-per-hour) live in the uint256 domain on chain
|
|
56
|
+
# and routinely exceed 2^53 for 18-decimal tokens. JSON numbers can't carry
|
|
57
|
+
# uint256 safely (most non-Python parsers treat them as IEEE-754 doubles),
|
|
58
|
+
# and SQLite INTEGER is int64 — both lossy past ~9.2e18 base units.
|
|
59
|
+
# Pattern: keep Python ``int`` internally (arbitrary precision) and emit
|
|
60
|
+
# decimal-digit strings on serialization. Validators accept either form so
|
|
61
|
+
# already-stored Python-int data round-trips without a migration.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _parse_uint256_str(v: Any, field_name: str) -> int | None:
|
|
65
|
+
"""Coerce a wire value (int|str|None) into a Python int.
|
|
66
|
+
|
|
67
|
+
Accepts:
|
|
68
|
+
* ``None`` — passes through (used for tristate "no advertised value").
|
|
69
|
+
* ``int`` — passes through (back-compat with existing Python callers).
|
|
70
|
+
* decimal-digit ``str`` — parses to int (the canonical wire form).
|
|
71
|
+
|
|
72
|
+
Rejects floats, negative values, and anything that doesn't look like a
|
|
73
|
+
non-negative decimal integer.
|
|
74
|
+
"""
|
|
75
|
+
if v is None:
|
|
76
|
+
return None
|
|
77
|
+
if isinstance(v, bool): # bool is a subclass of int — exclude explicitly
|
|
78
|
+
raise ValueError(f"{field_name}: expected non-negative decimal, got bool")
|
|
79
|
+
if isinstance(v, int):
|
|
80
|
+
if v < 0:
|
|
81
|
+
raise ValueError(f"{field_name}: must be non-negative, got {v}")
|
|
82
|
+
return v
|
|
83
|
+
if isinstance(v, str):
|
|
84
|
+
s = v.strip()
|
|
85
|
+
if not s:
|
|
86
|
+
return None
|
|
87
|
+
if not s.isdigit():
|
|
88
|
+
raise ValueError(
|
|
89
|
+
f"{field_name}: must be a non-negative decimal-digit string, "
|
|
90
|
+
f"got {v!r}"
|
|
91
|
+
)
|
|
92
|
+
return int(s)
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"{field_name}: must be int, decimal string, or None — got "
|
|
95
|
+
f"{type(v).__name__}"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _serialize_uint256_str(v: int | None) -> str | None:
|
|
100
|
+
return None if v is None else str(v)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Resource(BaseModel):
|
|
104
|
+
"""Domain-agnostic base resource model."""
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def parse_from_dict(cls, data: Any) -> "Resource":
|
|
108
|
+
"""Parse core-known resource shapes.
|
|
109
|
+
|
|
110
|
+
Core only understands universally valid resources. Domain-specific
|
|
111
|
+
resources should be parsed by domain adapters that extend this method.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(data, Resource):
|
|
114
|
+
return data
|
|
115
|
+
if not isinstance(data, dict):
|
|
116
|
+
return data
|
|
117
|
+
if "token" in data:
|
|
118
|
+
return TokenResource(**data)
|
|
119
|
+
raise ValueError("Unsupported resource payload for core Resource parser")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TokenResource(Resource):
|
|
123
|
+
"""Describes a given value and amount of a token used for trade/payment.
|
|
124
|
+
|
|
125
|
+
``amount`` is tristate:
|
|
126
|
+
* positive integer — the public price (the seller advertises this
|
|
127
|
+
floor and uses it as the negotiation anchor).
|
|
128
|
+
* ``0`` — free / public-test offering (the seller advertises zero
|
|
129
|
+
cost; strategy accepts any non-negative offer).
|
|
130
|
+
* ``None`` — hidden reserve (the seller publishes the listing without
|
|
131
|
+
advertising a price; the negotiation strategy falls back to
|
|
132
|
+
``[seller.pricing].default_min_price`` for the floor; buyer must
|
|
133
|
+
propose ``--initial-price`` and ``--max-price`` explicitly).
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
token: SerializeAsAny[ERC20TokenMetadata] = Field(
|
|
137
|
+
description="Token metadata resolved from registry"
|
|
138
|
+
)
|
|
139
|
+
amount: int | None = Field(
|
|
140
|
+
default=None,
|
|
141
|
+
description=(
|
|
142
|
+
"Non-negative amount in base units (token amount × 10**decimals). "
|
|
143
|
+
"0 = free; null = hidden reserve (negotiate); >0 = public price. "
|
|
144
|
+
"On the wire as a decimal-digit string (uint256-safe); Python int "
|
|
145
|
+
"internally."
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@field_validator("amount", mode="before")
|
|
150
|
+
@classmethod
|
|
151
|
+
def _parse_amount(cls, v: Any) -> int | None:
|
|
152
|
+
return _parse_uint256_str(v, "amount")
|
|
153
|
+
|
|
154
|
+
@field_serializer("amount")
|
|
155
|
+
def _serialize_amount(self, v: int | None) -> str | None:
|
|
156
|
+
return _serialize_uint256_str(v)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class ProvisionTerms(BaseModel):
|
|
160
|
+
"""Opaque off-chain delivery terms negotiated alongside escrow terms.
|
|
161
|
+
|
|
162
|
+
The core protocol treats provision terms as a schema-tagged payload:
|
|
163
|
+
``kind`` identifies the market/schema-specific interpreter and
|
|
164
|
+
``payload`` carries that interpreter's data. Core code treats
|
|
165
|
+
``payload`` as opaque; interpreting it (and constructing it) is the
|
|
166
|
+
job of domain adapters — e.g. ``domains.vms.provisioning.terms`` for
|
|
167
|
+
the ``compute.v1`` payload shape.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
kind: str = Field(
|
|
171
|
+
description="Schema/interpreter for this provision payload.",
|
|
172
|
+
)
|
|
173
|
+
payload: dict[str, Any] = Field(
|
|
174
|
+
default_factory=dict,
|
|
175
|
+
description="Schema-specific provision terms. Opaque to market core.",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
@model_validator(mode="before")
|
|
179
|
+
@classmethod
|
|
180
|
+
def _coerce_legacy_wire_shapes(cls, value: Any) -> Any:
|
|
181
|
+
"""LEGACY wire compatibility — not part of the carrier contract.
|
|
182
|
+
|
|
183
|
+
Early compute clients (including the current ``storefront-client``
|
|
184
|
+
wheel) send a flat ``{duration_seconds, ssh_public_key,
|
|
185
|
+
compute_resource}`` dict with no ``kind``/``payload`` envelope,
|
|
186
|
+
and some transitional payloads used ``schema``/``terms`` key
|
|
187
|
+
names. Normalize all of those into the envelope here so old wire
|
|
188
|
+
data and run logs keep parsing. The ``compute.v1`` literal below
|
|
189
|
+
is a legacy wire tag, not a core interpretation of the payload.
|
|
190
|
+
Remove once no deployed client sends the flat shape.
|
|
191
|
+
"""
|
|
192
|
+
if not isinstance(value, dict):
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
data = dict(value)
|
|
196
|
+
if "schema" in data and "kind" not in data:
|
|
197
|
+
data["kind"] = data.pop("schema")
|
|
198
|
+
if "terms" in data and "payload" not in data:
|
|
199
|
+
data["payload"] = data.pop("terms")
|
|
200
|
+
|
|
201
|
+
legacy_keys = {"duration_seconds", "ssh_public_key", "compute_resource"}
|
|
202
|
+
if "payload" not in data and legacy_keys.intersection(data):
|
|
203
|
+
payload: dict[str, Any] = {}
|
|
204
|
+
for key in legacy_keys:
|
|
205
|
+
if key in data:
|
|
206
|
+
raw = data.pop(key)
|
|
207
|
+
if key == "duration_seconds" and raw is not None:
|
|
208
|
+
try:
|
|
209
|
+
raw = int(raw)
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
pass
|
|
212
|
+
payload[key] = raw
|
|
213
|
+
data["kind"] = data.get("kind") or "compute.v1"
|
|
214
|
+
data["payload"] = payload
|
|
215
|
+
return data
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class EscrowTerms(BaseModel):
|
|
219
|
+
"""One on-chain escrow obligation in flat, self-describing form.
|
|
220
|
+
|
|
221
|
+
Mirrors the call shape of any alkahest escrow contract's
|
|
222
|
+
``doObligation(data, expirationTime)`` entry point. The
|
|
223
|
+
``obligation_data`` dict is literally the ``ObligationData`` struct
|
|
224
|
+
for whichever contract ``escrow_contract`` points to — different
|
|
225
|
+
contracts have different shapes, but every one begins with
|
|
226
|
+
``(address arbiter, bytes demand, …)`` followed by payment fields
|
|
227
|
+
specific to that contract (token+amount for ERC20, tokenId for
|
|
228
|
+
ERC721, native amount, bundle arrays, attestation refs, etc.).
|
|
229
|
+
|
|
230
|
+
Readers extract universal fields by key: ``obligation_data["arbiter"]``
|
|
231
|
+
and ``obligation_data["demand"]`` are present on every escrow kind.
|
|
232
|
+
The rest is contract-specific; consumers that need typed access
|
|
233
|
+
parse the dict against whichever ``ObligationData`` shape goes with
|
|
234
|
+
``escrow_contract``.
|
|
235
|
+
|
|
236
|
+
Stored flat (not wrapped in a kind+params discriminator) so that:
|
|
237
|
+
* settlement verification is a byte-compare against the chain-read
|
|
238
|
+
obligation, with no codec dispatch needed on the read path.
|
|
239
|
+
* adding new escrow kinds (ERC721, native, bundle, attestation)
|
|
240
|
+
does not change this type — only the keys present in
|
|
241
|
+
``obligation_data`` differ.
|
|
242
|
+
|
|
243
|
+
Role since the settlement-plan generalization: the negotiated
|
|
244
|
+
outcome travels as a ``SettlementPlan`` whose obligations carry a
|
|
245
|
+
``{mechanism, params}`` envelope; this type is the *typed params
|
|
246
|
+
shape* of the ``alkahest.v1`` mechanism (its fields minus ``maker``
|
|
247
|
+
are exactly that envelope's params) and the source shape of the
|
|
248
|
+
marked legacy coercions on ``SettlementObligation`` /
|
|
249
|
+
``SettlementPlan``, which keep pre-plan artifacts (flat terms dicts
|
|
250
|
+
and bare terms lists) parsing.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
maker: Literal["buyer", "seller"] = Field(
|
|
254
|
+
description=(
|
|
255
|
+
"Which side calls ``doObligation`` for this escrow. ``buyer`` "
|
|
256
|
+
"for the standard payment escrow; ``seller`` for cases like "
|
|
257
|
+
"penalty deposits the seller posts as bond."
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
chain_name: str | None = Field(
|
|
261
|
+
default=None,
|
|
262
|
+
description=(
|
|
263
|
+
"Alkahest chain identifier for this escrow. New negotiated "
|
|
264
|
+
"artifacts carry it explicitly; old run logs may omit it and "
|
|
265
|
+
"fall back to the caller's configured chain."
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
escrow_contract: str = Field(
|
|
269
|
+
description=(
|
|
270
|
+
"Address of the on-chain escrow obligation contract — e.g. "
|
|
271
|
+
"ERC20EscrowObligation, ERC721EscrowObligation, "
|
|
272
|
+
"NativeTokenEscrowObligation, TokenBundleEscrowObligation, "
|
|
273
|
+
"AttestationEscrowObligation. The address determines the "
|
|
274
|
+
"expected shape of ``obligation_data``."
|
|
275
|
+
),
|
|
276
|
+
)
|
|
277
|
+
obligation_data: dict[str, Any] = Field(
|
|
278
|
+
description=(
|
|
279
|
+
"The literal ``ObligationData`` struct passed to "
|
|
280
|
+
"``escrow_contract.doObligation``. Always contains at least "
|
|
281
|
+
"``arbiter`` (address) and ``demand`` (bytes, hex-encoded for "
|
|
282
|
+
"transport); the remaining keys are payment fields specific "
|
|
283
|
+
"to the escrow kind."
|
|
284
|
+
),
|
|
285
|
+
)
|
|
286
|
+
expiration_unix: int = Field(
|
|
287
|
+
gt=0,
|
|
288
|
+
description=(
|
|
289
|
+
"Absolute UTC unix-time at which the escrow expires on-chain. "
|
|
290
|
+
"Buyer commits to creating the escrow before this moment; "
|
|
291
|
+
"seller verifies the on-chain attestation's ``expirationTime`` "
|
|
292
|
+
"equals this value. Absolute (not relative-to-creation) so "
|
|
293
|
+
"both sides have a single agreed timestamp with no clock-drift "
|
|
294
|
+
"tolerance window."
|
|
295
|
+
),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
_ALKAHEST_MECHANISM = "alkahest.v1"
|
|
300
|
+
"""Mechanism tag for the legacy flat-``EscrowTerms`` wire coercions below.
|
|
301
|
+
|
|
302
|
+
A legacy wire tag, not a core interpretation of the params — core never
|
|
303
|
+
dispatches on it. The matching codec lives in ``kit/alkahest``.
|
|
304
|
+
"""
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class SettlementObligation(BaseModel):
|
|
308
|
+
"""One obligation in a settlement plan.
|
|
309
|
+
|
|
310
|
+
The lifecycle universals every settlement mechanism shares are typed
|
|
311
|
+
fields: who funds it, who collects it, how much of what, when the
|
|
312
|
+
collect-vs-reclaim boundary falls, and what conditions gate
|
|
313
|
+
collection. Everything needed to *materialize and verify* the
|
|
314
|
+
obligation under a particular mechanism — chain + contract address
|
|
315
|
+
and the ``ObligationData`` struct for alkahest; provider + payment
|
|
316
|
+
refs for a fiat escrow — rides in ``params``, interpreted by the kit
|
|
317
|
+
codec registered for ``mechanism``. Core code drives obligations
|
|
318
|
+
through injected per-mechanism hooks and never reads ``params``.
|
|
319
|
+
|
|
320
|
+
``amount``/``asset`` are the *display/lifecycle* view of the
|
|
321
|
+
obligation's value (None when the mechanism's value isn't a scalar,
|
|
322
|
+
e.g. a token-bundle escrow); the mechanism params remain the
|
|
323
|
+
authoritative materialization input, and the codec's verification is
|
|
324
|
+
responsible for their consistency.
|
|
325
|
+
|
|
326
|
+
``conditions`` carries declared condition descriptors for the deal
|
|
327
|
+
servicing engine (lifecycle doc work item I.3). Opaque to core;
|
|
328
|
+
empty means "whatever the mechanism params already encode" — for
|
|
329
|
+
alkahest the arbiter demand tree inside ``params`` is authoritative
|
|
330
|
+
and is decoded by the kit codec on demand.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
payer: Literal["buyer", "seller"] = Field(
|
|
334
|
+
description=(
|
|
335
|
+
"Which side funds/materializes this obligation (calls "
|
|
336
|
+
"``doObligation`` for alkahest; creates the hold/intent for a "
|
|
337
|
+
"fiat mechanism)."
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
claimant: Literal["buyer", "seller"] = Field(
|
|
341
|
+
description=(
|
|
342
|
+
"Which side collects when the condition set passes. The payer "
|
|
343
|
+
"reclaims after ``expiration_unix`` if the claimant never "
|
|
344
|
+
"collected. Payment escrow: buyer pays, seller claims; penalty "
|
|
345
|
+
"bond: seller posts, buyer claims."
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
amount: int | None = Field(
|
|
349
|
+
default=None,
|
|
350
|
+
description=(
|
|
351
|
+
"Obligation value in the asset's base units (uint256-domain; "
|
|
352
|
+
"decimal-digit string on the wire, Python int internally). "
|
|
353
|
+
"None when the mechanism's value isn't a single scalar."
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
asset: str | None = Field(
|
|
357
|
+
default=None,
|
|
358
|
+
description=(
|
|
359
|
+
"Mechanism-scoped asset identifier — ERC-20 contract address "
|
|
360
|
+
"for alkahest token escrows, a currency code for fiat. None "
|
|
361
|
+
"when not applicable (e.g. bundle escrows)."
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
expiration_unix: int = Field(
|
|
365
|
+
gt=0,
|
|
366
|
+
description=(
|
|
367
|
+
"Absolute UTC unix-time collect-vs-reclaim boundary. Absolute "
|
|
368
|
+
"(not relative-to-creation) so both sides share one agreed "
|
|
369
|
+
"timestamp with no clock-drift window."
|
|
370
|
+
),
|
|
371
|
+
)
|
|
372
|
+
conditions: list[dict[str, Any]] = Field(
|
|
373
|
+
default_factory=list,
|
|
374
|
+
description=(
|
|
375
|
+
"Declared condition descriptors gating collection. Opaque to "
|
|
376
|
+
"core; interpreted by the mechanism codec / domain policy."
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
mechanism: str = Field(
|
|
380
|
+
description=(
|
|
381
|
+
"Settlement mechanism codec identifier (e.g. ``alkahest.v1``). "
|
|
382
|
+
"Selects the kit codec that interprets ``params``."
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
params: dict[str, Any] = Field(
|
|
386
|
+
default_factory=dict,
|
|
387
|
+
description=(
|
|
388
|
+
"Mechanism-specific materialization/verification params. "
|
|
389
|
+
"Opaque to market core."
|
|
390
|
+
),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
@field_validator("amount", mode="before")
|
|
394
|
+
@classmethod
|
|
395
|
+
def _parse_obligation_amount(cls, v: Any) -> int | None:
|
|
396
|
+
return _parse_uint256_str(v, "amount")
|
|
397
|
+
|
|
398
|
+
@field_serializer("amount")
|
|
399
|
+
def _serialize_obligation_amount(self, v: int | None) -> str | None:
|
|
400
|
+
return _serialize_uint256_str(v)
|
|
401
|
+
|
|
402
|
+
@model_validator(mode="before")
|
|
403
|
+
@classmethod
|
|
404
|
+
def _coerce_legacy_escrow_terms(cls, value: Any) -> Any:
|
|
405
|
+
"""LEGACY wire compatibility — not part of the carrier contract.
|
|
406
|
+
|
|
407
|
+
Pre-plan artifacts (run logs, negotiation threads, the current
|
|
408
|
+
client wheels) carry flat ``EscrowTerms`` dicts: ``{maker,
|
|
409
|
+
chain_name?, escrow_contract, obligation_data, expiration_unix}``.
|
|
410
|
+
Normalize that shape into the mechanism envelope here so old
|
|
411
|
+
artifacts keep parsing. Remove once no deployed client sends the
|
|
412
|
+
flat shape (leaves with the client-wheel wire bump).
|
|
413
|
+
"""
|
|
414
|
+
if not isinstance(value, dict):
|
|
415
|
+
return value
|
|
416
|
+
if "mechanism" in value or "escrow_contract" not in value:
|
|
417
|
+
return value
|
|
418
|
+
|
|
419
|
+
data = dict(value)
|
|
420
|
+
maker = data.pop("maker", "buyer")
|
|
421
|
+
obligation_data = data.pop("obligation_data", {}) or {}
|
|
422
|
+
params: dict[str, Any] = {
|
|
423
|
+
"escrow_contract": data.pop("escrow_contract"),
|
|
424
|
+
"obligation_data": obligation_data,
|
|
425
|
+
}
|
|
426
|
+
if data.get("chain_name") is not None:
|
|
427
|
+
params["chain_name"] = data.pop("chain_name")
|
|
428
|
+
else:
|
|
429
|
+
data.pop("chain_name", None)
|
|
430
|
+
|
|
431
|
+
amount = obligation_data.get("amount")
|
|
432
|
+
return {
|
|
433
|
+
"payer": maker,
|
|
434
|
+
"claimant": "seller" if maker == "buyer" else "buyer",
|
|
435
|
+
"amount": amount if isinstance(amount, (int, str)) else None,
|
|
436
|
+
"asset": obligation_data.get("token"),
|
|
437
|
+
"expiration_unix": data.pop("expiration_unix", None),
|
|
438
|
+
"mechanism": _ALKAHEST_MECHANISM,
|
|
439
|
+
"params": params,
|
|
440
|
+
**data,
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class SettlementPlan(BaseModel):
|
|
445
|
+
"""The negotiated settlement plan both sides derive identically.
|
|
446
|
+
|
|
447
|
+
Generalizes the single accepted-escrow handoff: a plan is N
|
|
448
|
+
obligations (payment escrows, interval escrows, penalty bonds —
|
|
449
|
+
possibly under different mechanisms) plus the off-chain duties each
|
|
450
|
+
party takes on while servicing the deal. The determinism contract
|
|
451
|
+
extends unchanged in kind: both sides must derive the same plan from
|
|
452
|
+
the shared message history; for mechanisms whose materialization is
|
|
453
|
+
not independently derivable (fiat), determinism covers the agreed
|
|
454
|
+
terms and the codec verifies the materialized object against them.
|
|
455
|
+
|
|
456
|
+
``service_terms`` is the attachment point for heartbeat cadence and
|
|
457
|
+
schema, oracle identity, evidence format, and interval boundaries
|
|
458
|
+
(lifecycle doc work items I.4/I.5). Opaque to core; empty for plans
|
|
459
|
+
with no off-chain duties beyond the mechanism defaults.
|
|
460
|
+
"""
|
|
461
|
+
|
|
462
|
+
obligations: list[SettlementObligation] = Field(
|
|
463
|
+
description="Every obligation the deal materializes.",
|
|
464
|
+
)
|
|
465
|
+
service_terms: dict[str, Any] = Field(
|
|
466
|
+
default_factory=dict,
|
|
467
|
+
description=(
|
|
468
|
+
"Off-chain servicing duties (heartbeat cadence/schema, oracle "
|
|
469
|
+
"identity, interval boundaries). Opaque to market core."
|
|
470
|
+
),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
@model_validator(mode="before")
|
|
474
|
+
@classmethod
|
|
475
|
+
def _coerce_legacy_terms_list(cls, value: Any) -> Any:
|
|
476
|
+
"""LEGACY wire compatibility — not part of the carrier contract.
|
|
477
|
+
|
|
478
|
+
Pre-plan artifacts carry a bare ``list[EscrowTerms]`` where the
|
|
479
|
+
plan now travels. Wrap it; the per-obligation legacy coercion
|
|
480
|
+
handles each entry. Remove with the client-wheel wire bump.
|
|
481
|
+
"""
|
|
482
|
+
if isinstance(value, list):
|
|
483
|
+
return {"obligations": value}
|
|
484
|
+
return value
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
# Rate slots: per-unit-time (or per-unit-anything) rates on obligation fields.
|
|
489
|
+
# A rate carries the field path on the obligation data plus the unit it
|
|
490
|
+
# scales by; the value is in the payment token's base units (uint256 domain).
|
|
491
|
+
# At settlement time both sides compute the final field value as
|
|
492
|
+
# ``value * duration_quantum // PER_UNIT_QUANTUM[per]`` (integer division;
|
|
493
|
+
# sub-unit fractions truncate, matching the prior single-rate semantics).
|
|
494
|
+
# ---------------------------------------------------------------------------
|
|
495
|
+
|
|
496
|
+
# Allowed values for ``RateValue.per`` and the seconds-per-unit divisor used
|
|
497
|
+
# at settlement. ``hour`` is the only one wired end-to-end today;
|
|
498
|
+
# ``request`` / ``kWh`` etc. are reserved for the design's "non-time rate
|
|
499
|
+
# units" open question — adding one is a single dict entry plus negotiated
|
|
500
|
+
# quantity (request_count, energy_kwh) carried on the deal.
|
|
501
|
+
PER_UNIT_SECONDS: dict[str, int] = {
|
|
502
|
+
"hour": 3600,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
class RateValue(BaseModel):
|
|
507
|
+
"""One rate-bearing slot on an accepted escrow / proposal.
|
|
508
|
+
|
|
509
|
+
``field`` is the dotted/indexed path into the obligation data
|
|
510
|
+
(``"amount"`` for ERC-20 escrows; ``"erc20Amounts[0]"``,
|
|
511
|
+
``"nativeAmount"`` etc. for TokenBundle). ``per`` is the unit the
|
|
512
|
+
rate scales by — ``"hour"`` for the time-rate case. ``value`` is
|
|
513
|
+
the rate itself in base units, uint256-domain (decimal-digit string
|
|
514
|
+
on the wire, Python int internally).
|
|
515
|
+
|
|
516
|
+
Negotiation pressure points at ``value``; ``field`` and ``per``
|
|
517
|
+
come from the seller's escrow template and are non-negotiable.
|
|
518
|
+
"""
|
|
519
|
+
|
|
520
|
+
field: str = Field(description="Obligation-data field path the rate populates.")
|
|
521
|
+
per: str = Field(default="hour", description="Unit the rate scales by.")
|
|
522
|
+
value: int = Field(description="Rate amount in payment-token base units (uint256).")
|
|
523
|
+
|
|
524
|
+
@field_validator("value", mode="before")
|
|
525
|
+
@classmethod
|
|
526
|
+
def _parse_value(cls, v: Any) -> int:
|
|
527
|
+
parsed = _parse_uint256_str(v, "value")
|
|
528
|
+
if parsed is None:
|
|
529
|
+
raise ValueError("RateValue.value must not be null")
|
|
530
|
+
return parsed
|
|
531
|
+
|
|
532
|
+
@field_serializer("value")
|
|
533
|
+
def _serialize_value(self, v: int) -> str:
|
|
534
|
+
return _serialize_uint256_str(v) or "0"
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def compute_rate_total(rate: RateValue, duration_seconds: int) -> int:
|
|
538
|
+
"""Multiply a rate by the negotiated duration.
|
|
539
|
+
|
|
540
|
+
Returns the final on-chain field value for ``rate.field``. Uses
|
|
541
|
+
integer division so sub-unit fractions truncate (i.e. for ERC20:
|
|
542
|
+
``rate × duration_seconds // 3600``).
|
|
543
|
+
"""
|
|
544
|
+
divisor = PER_UNIT_SECONDS.get(rate.per)
|
|
545
|
+
if divisor is None:
|
|
546
|
+
raise ValueError(f"unknown rate.per unit: {rate.per!r}")
|
|
547
|
+
return rate.value * duration_seconds // divisor
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# ---------------------------------------------------------------------------
|
|
551
|
+
# AcceptedEscrow / EscrowProposal accessors
|
|
552
|
+
# ---------------------------------------------------------------------------
|
|
553
|
+
# Most consumers treat an accepted_escrows entry as a JSON dict (after a
|
|
554
|
+
# round-trip through SQLite or the wire); these accessors operate on
|
|
555
|
+
# either a Pydantic model OR a dict so the only change at the call site
|
|
556
|
+
# is the accessor name.
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def primary_rate_value(accepted_or_proposal: Any) -> int | None:
|
|
560
|
+
"""Return the headline rate's value for negotiation/display.
|
|
561
|
+
|
|
562
|
+
Today's negotiation engine treats every escrow as having a single
|
|
563
|
+
rate — that's true for ERC-20 (the ``amount`` rate) and ``None`` for
|
|
564
|
+
pure-attestation escrows. Multi-rate templates (TokenBundle) return
|
|
565
|
+
the *first* rate; vector negotiation is a deferred extension.
|
|
566
|
+
|
|
567
|
+
Returns ``None`` when the escrow advertises no rates (hidden-reserve
|
|
568
|
+
listings, attestation one-shots). Callers translate ``None`` into
|
|
569
|
+
either a hidden-reserve fallback (negotiation strategies) or a
|
|
570
|
+
"no scaling" path (settlement of pure-literal escrows).
|
|
571
|
+
"""
|
|
572
|
+
rates = _rates_of(accepted_or_proposal)
|
|
573
|
+
if not rates:
|
|
574
|
+
return None
|
|
575
|
+
first = rates[0]
|
|
576
|
+
if isinstance(first, dict):
|
|
577
|
+
v = first.get("value")
|
|
578
|
+
if isinstance(v, str) and v.strip().isdigit():
|
|
579
|
+
return int(v.strip())
|
|
580
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
581
|
+
return v
|
|
582
|
+
return None
|
|
583
|
+
v = getattr(first, "value", None)
|
|
584
|
+
if isinstance(v, int) and not isinstance(v, bool):
|
|
585
|
+
return v
|
|
586
|
+
return None
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def accepted_token_address(accepted_or_proposal: Any) -> str | None:
|
|
590
|
+
"""Return the payment-token address from an ERC-20-style entry.
|
|
591
|
+
|
|
592
|
+
Reads ``literal_fields["token"]`` — the canonical place ERC-20 and
|
|
593
|
+
ERC-1155 escrows pin their payment-token. Returns ``None`` for
|
|
594
|
+
escrow kinds (NativeToken, TokenBundle, attestation) that don't
|
|
595
|
+
have a single ``token`` literal.
|
|
596
|
+
"""
|
|
597
|
+
literals = _literal_fields_of(accepted_or_proposal)
|
|
598
|
+
val = literals.get("token") if isinstance(literals, dict) else None
|
|
599
|
+
return val if isinstance(val, str) and val else None
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def accepted_recipient_address(accepted_or_proposal: Any) -> str | None:
|
|
603
|
+
"""Return the escrow demand recipient from an accepted/proposed escrow.
|
|
604
|
+
|
|
605
|
+
Preferred source is ``demands[].demand_data.recipient`` on the
|
|
606
|
+
RecipientArbiter demand. ``literal_fields["recipient"]`` remains a
|
|
607
|
+
legacy fallback for transitional data.
|
|
608
|
+
"""
|
|
609
|
+
for demand in accepted_demands(accepted_or_proposal):
|
|
610
|
+
data = demand.get("demand_data")
|
|
611
|
+
if isinstance(data, dict):
|
|
612
|
+
val = data.get("recipient")
|
|
613
|
+
if isinstance(val, str) and val:
|
|
614
|
+
return val
|
|
615
|
+
literals = _literal_fields_of(accepted_or_proposal)
|
|
616
|
+
val = literals.get("recipient") if isinstance(literals, dict) else None
|
|
617
|
+
return val if isinstance(val, str) and val else None
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def accepted_demands(accepted_or_proposal: Any) -> list[dict[str, Any]]:
|
|
621
|
+
"""Return arbiter demands advertised/negotiated for this escrow shape."""
|
|
622
|
+
if isinstance(accepted_or_proposal, dict):
|
|
623
|
+
raw = accepted_or_proposal.get("demands")
|
|
624
|
+
else:
|
|
625
|
+
raw = getattr(accepted_or_proposal, "demands", None)
|
|
626
|
+
if not isinstance(raw, list):
|
|
627
|
+
return []
|
|
628
|
+
out: list[dict[str, Any]] = []
|
|
629
|
+
for item in raw:
|
|
630
|
+
if isinstance(item, dict):
|
|
631
|
+
out.append(dict(item))
|
|
632
|
+
else:
|
|
633
|
+
dumped = item.model_dump() if hasattr(item, "model_dump") else None
|
|
634
|
+
if isinstance(dumped, dict):
|
|
635
|
+
out.append(dumped)
|
|
636
|
+
return out
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _rates_of(entry: Any) -> list[Any]:
|
|
640
|
+
if isinstance(entry, dict):
|
|
641
|
+
out = entry.get("rates")
|
|
642
|
+
return out if isinstance(out, list) else []
|
|
643
|
+
return list(getattr(entry, "rates", []) or [])
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _literal_fields_of(entry: Any) -> dict[str, Any]:
|
|
647
|
+
if isinstance(entry, dict):
|
|
648
|
+
out = entry.get("literal_fields")
|
|
649
|
+
return out if isinstance(out, dict) else {}
|
|
650
|
+
return dict(getattr(entry, "literal_fields", {}) or {})
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class EscrowDemand(BaseModel):
|
|
654
|
+
"""One arbiter demand paired with an escrow obligation.
|
|
655
|
+
|
|
656
|
+
``arbiter`` is the deployed arbiter contract address. ``demand_data``
|
|
657
|
+
is the JSON-shaped input for that arbiter's codec; settlement codecs
|
|
658
|
+
own encoding it into on-chain demand bytes.
|
|
659
|
+
"""
|
|
660
|
+
|
|
661
|
+
chain_name: str | None = Field(
|
|
662
|
+
default=None,
|
|
663
|
+
description=(
|
|
664
|
+
"Optional chain identifier for listings that advertise demands "
|
|
665
|
+
"across multiple chains."
|
|
666
|
+
),
|
|
667
|
+
)
|
|
668
|
+
arbiter: str = Field(
|
|
669
|
+
description="Deployed arbiter contract address for this demand.",
|
|
670
|
+
)
|
|
671
|
+
demand_data: dict[str, Any] = Field(
|
|
672
|
+
default_factory=dict,
|
|
673
|
+
description="Codec-specific arbiter demand data.",
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
class AcceptedEscrow(BaseModel):
|
|
678
|
+
"""One escrow shape the seller will accept for this listing.
|
|
679
|
+
|
|
680
|
+
Each entry pins the (chain, escrow contract) tuple plus the
|
|
681
|
+
obligation literals + rates the seller has fixed. The buyer's
|
|
682
|
+
proposal references one of these entries by
|
|
683
|
+
``(chain_name, escrow_address)`` and inherits its ``literal_fields``.
|
|
684
|
+
|
|
685
|
+
``literal_fields`` is escrow-data-only: keys present advertise a seller-
|
|
686
|
+
preferred value; keys absent are open. Never includes a rate-bearing
|
|
687
|
+
field directly — those live in ``rates`` so duration scaling stays
|
|
688
|
+
explicit. Arbiter release criteria live in the listing/proposal-level
|
|
689
|
+
``demands`` list.
|
|
690
|
+
|
|
691
|
+
``rates`` carries every rate-bearing field on the obligation. For
|
|
692
|
+
ERC20 escrows that's a single ``{"field": "amount", "per": "hour",
|
|
693
|
+
"value": …}`` entry; multi-rate templates (TokenBundle) carry one
|
|
694
|
+
per rate-bearing field. Empty list = hidden reserve (seller did not
|
|
695
|
+
publish a rate; negotiation establishes one via the strategy's
|
|
696
|
+
``default_min_price``). Readers use the ``primary_rate_value`` /
|
|
697
|
+
``accepted_token_address`` helpers in this module.
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
chain_name: str = Field(
|
|
701
|
+
description=(
|
|
702
|
+
"Alkahest chain identifier (e.g. ``base_sepolia``, ``anvil``). "
|
|
703
|
+
"Combined with ``escrow_address`` to look up the SDK codec via "
|
|
704
|
+
"``market_alkahest.alkahest.get_escrow_codec_for``."
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
escrow_address: str = Field(
|
|
708
|
+
description=(
|
|
709
|
+
"Deployed escrow obligation contract address on ``chain_name``. "
|
|
710
|
+
"The (chain, address) pair determines the EscrowData ABI."
|
|
711
|
+
),
|
|
712
|
+
)
|
|
713
|
+
literal_fields: dict[str, Any] = Field(
|
|
714
|
+
default_factory=dict,
|
|
715
|
+
description=(
|
|
716
|
+
"Literal obligation-data keys the seller has fixed (e.g. "
|
|
717
|
+
"``token``). Keys present = seller-preferred "
|
|
718
|
+
"value; keys absent = open. Never includes a rate-bearing "
|
|
719
|
+
"field — those live in ``rates``. Arbiter demand criteria "
|
|
720
|
+
"live in ``demands``."
|
|
721
|
+
),
|
|
722
|
+
)
|
|
723
|
+
rates: list[RateValue] = Field(
|
|
724
|
+
default_factory=list,
|
|
725
|
+
description=(
|
|
726
|
+
"Rate-bearing obligation fields. Each entry pins one field "
|
|
727
|
+
"(``amount`` for ERC20, ``erc20Amounts[i]`` / ``nativeAmount`` "
|
|
728
|
+
"for TokenBundle, …) plus its ``per`` unit and the rate "
|
|
729
|
+
"``value`` in base units. Empty list = hidden reserve. "
|
|
730
|
+
"Readers use ``primary_rate_value`` for the headline rate."
|
|
731
|
+
),
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
class EscrowProposal(BaseModel):
|
|
736
|
+
"""Buyer's escrow proposal at negotiation round 0.
|
|
737
|
+
|
|
738
|
+
References one of the listing's ``accepted_escrows`` entries by
|
|
739
|
+
``(chain_name, escrow_address)`` and supplies the buyer-committable
|
|
740
|
+
EscrowData fields. ``amount`` is intentionally not on the proposal —
|
|
741
|
+
it's derived at settlement from the agreed price + duration. The
|
|
742
|
+
seller echoes back the accepted proposal verbatim on the negotiation
|
|
743
|
+
outcome so settlement code reconstructs the same on-chain
|
|
744
|
+
obligation_data on both sides.
|
|
745
|
+
"""
|
|
746
|
+
|
|
747
|
+
chain_name: str = Field(
|
|
748
|
+
description=(
|
|
749
|
+
"Chain identifier; must match the picked accepted_escrows entry."
|
|
750
|
+
),
|
|
751
|
+
)
|
|
752
|
+
escrow_address: str = Field(
|
|
753
|
+
description=(
|
|
754
|
+
"Escrow contract address; must match the picked "
|
|
755
|
+
"accepted_escrows entry."
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
fields: dict[str, Any] = Field(
|
|
759
|
+
default_factory=dict,
|
|
760
|
+
description=(
|
|
761
|
+
"Complete buyer-committable EscrowData fields (arbiter, "
|
|
762
|
+
"token, …). Excludes ``amount`` and arbiter demand bytes, "
|
|
763
|
+
"which are derived at settlement."
|
|
764
|
+
),
|
|
765
|
+
)
|
|
766
|
+
literal_fields: dict[str, Any] | None = Field(
|
|
767
|
+
default=None,
|
|
768
|
+
description=(
|
|
769
|
+
"Forward-looking sibling of ``fields`` for the generic-escrow "
|
|
770
|
+
"migration. When set, mirrors ``fields`` (literal obligation-"
|
|
771
|
+
"data keys the buyer commits to). Readers should prefer the "
|
|
772
|
+
"canonical accessor ``accepted_token_address`` which handles "
|
|
773
|
+
"either shape."
|
|
774
|
+
),
|
|
775
|
+
)
|
|
776
|
+
rates: list[RateValue] | None = Field(
|
|
777
|
+
default=None,
|
|
778
|
+
description=(
|
|
779
|
+
"Forward-looking sibling for multi-rate templates. Today "
|
|
780
|
+
"negotiation produces a single scalar amount that becomes the "
|
|
781
|
+
"primary rate-bearing field at settlement; future TokenBundle "
|
|
782
|
+
"proposals carry one ``RateValue`` per rate-bearing field."
|
|
783
|
+
),
|
|
784
|
+
)
|
|
785
|
+
demands: list[EscrowDemand] | None = Field(
|
|
786
|
+
default=None,
|
|
787
|
+
description=(
|
|
788
|
+
"Arbiter demand criteria inherited from the accepted escrow "
|
|
789
|
+
"entry and committed during negotiation."
|
|
790
|
+
),
|
|
791
|
+
)
|
|
792
|
+
expiration_unix: int = Field(
|
|
793
|
+
gt=0,
|
|
794
|
+
description=(
|
|
795
|
+
"Absolute UTC unix-time the on-chain escrow attestation "
|
|
796
|
+
"expires. Both sides commit to this single timestamp; no "
|
|
797
|
+
"clock-drift tolerance window."
|
|
798
|
+
),
|
|
799
|
+
)
|