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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: arkhai-core
3
+ Version: 0.1.0
4
+ Summary: Core market wire models and orchestration-neutral helpers
5
+ Requires-Python: <3.14,>=3.10
6
+ Requires-Dist: pydantic>=2
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ """Core market models shared by buyers, storefronts, and settlement kits."""
2
+
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
+ )