Skip to content

Commit 382c251

Browse files
committed
fix(hosted): ESM signer loading + tokenId-based Opinion economics validation
Two bugs that each blocked all hosted writes for affected callers: 1. The ESM build lazy-loaded ethers via bare require(), which is undefined in ESM. The lazy-signer bridge swallowed the ReferenceError, silently dropping the caller's privateKey — every hosted write then failed with "hosted write requires a signer". loadEthers() now uses native require in CJS and process.getBuiltinModule('node:module').createRequire in ESM. 2. The Opinion economics validator required message.opinion_market_id, but the current trading-API message schema signs the outcome tokenId instead — every current-schema Opinion order died pre-sign with "economic mismatch: message.opinion_market_id missing". Both SDK validators now check message.tokenId against resolved.token_id; the opinion_market_id equality check applies only when the message carries the field. Verified live against trade.pmxt.dev from an ESM consumer (pmxt-arb). TS jest 47/47, Python typeddata suite 34/34.
1 parent a90dbe6 commit 382c251

5 files changed

Lines changed: 135 additions & 33 deletions

File tree

changelog.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.49.11] - 2026-06-12
6+
7+
Hosted trading works from ESM apps and Opinion orders pass pre-sign validation. Two independent bugs each blocked all hosted writes for affected callers: the ESM build could not lazy-load ethers (bare `require` is undefined in ESM, and the failure was silently swallowed — the signer was dropped and every write died with "hosted write requires a signer" even when a `privateKey` was passed), and the client-side economics validator demanded `message.opinion_market_id` from a trading-API message schema that no longer carries it (the signed economic identity is the outcome `tokenId`). Both verified live against `trade.pmxt.dev` from an ESM consumer.
8+
9+
### Fixed
10+
11+
- **TS `pmxt/signers.ts`**: New `loadEthers()` helper used by `EthersSigner` — native `require` in the CJS build, `process.getBuiltinModule("node:module").createRequire(...)` in the ESM build (Node >= 20.16). Previously the ESM build's bare `require("ethers")` threw `ReferenceError`, which the lazy-signer bridge in the Exchange constructor caught and swallowed, silently discarding the caller's `privateKey`.
12+
- **TS `pmxt/hosted-typed-data.ts`**: signature verification now loads ethers via the same helper instead of bare `require`.
13+
- **TS `pmxt/hosted-typed-data.ts` + Python `pmxt/_hosted_typeddata.py`**: `validateOpinionMarketId` / `_validate_opinion_market_id` now validate `message.tokenId` against `resolved.token_id` — the field that is actually signed. The `opinion_market_id` equality check only applies when the message carries the field (legacy schema); requiring it unconditionally rejected every current-schema Opinion order pre-sign with `economic mismatch: message.opinion_market_id missing`.
14+
- **Python `tests/test_hosted_typeddata.py`**: opinion economics tests updated to the tokenId-based contract (mismatch rejection on `resolved.token_id`, params-only quirks no longer block, legacy `opinion_market_id` mismatch still rejected when present in the message).
15+
516
## [2.49.10] - 2026-06-12
617

718
Hosted custody docs now link to the public contract explorer pages instead of private GitHub source paths, and explicitly disclose that explorer source verification/public source publication is still pending.

sdks/python/pmxt/_hosted_typeddata.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,35 @@ def _validate_worst_price(
480480

481481

482482
def _validate_opinion_market_id(message: Mapping[str, Any], build_response: Any) -> None:
483+
# The current trading-API Opinion message schema does not embed
484+
# opinion_market_id — the signed economic identity is the outcome
485+
# tokenId. Validate that against the build response's resolved token.
486+
# The legacy opinion_market_id check only applies when the message
487+
# actually carries the field (older API versions).
488+
expected_token = _first_present(
489+
_path(build_response, "resolved", "token_id"),
490+
_path(build_response, "resolved", "tokenId"),
491+
_value(build_response, "token_id"),
492+
_value(build_response, "tokenId"),
493+
)
494+
actual_token = _first_present(
495+
_value(message, "tokenId"),
496+
_value(message, "token_id"),
497+
)
498+
if actual_token is _MISSING:
499+
_economic_fail("message.tokenId missing")
500+
if expected_token is not _MISSING and _id_value(
501+
actual_token, "message.tokenId"
502+
) != _id_value(expected_token, "resolved.token_id"):
503+
_economic_fail(f"tokenId expected {expected_token} got {actual_token}")
504+
505+
actual = _first_present(
506+
_value(message, "opinion_market_id"),
507+
_value(message, "opinionMarketId"),
508+
)
509+
if actual is _MISSING:
510+
return # current schema: tokenId-only message
511+
483512
expected = _first_present(
484513
_path(build_response, "resolved", "opinion_market_id"),
485514
_path(build_response, "resolved", "opinionMarketId"),
@@ -491,15 +520,6 @@ def _validate_opinion_market_id(message: Mapping[str, Any], build_response: Any)
491520
if expected is _MISSING:
492521
_economic_fail("resolved.opinion_market_id missing")
493522

494-
actual = _first_present(
495-
_value(message, "opinion_market_id"),
496-
_value(message, "opinionMarketId"),
497-
_path(build_response, "params", "opinion_market_id"),
498-
_path(build_response, "params", "opinionMarketId"),
499-
)
500-
if actual is _MISSING:
501-
_economic_fail("message.opinion_market_id missing")
502-
503523
if _id_value(actual, "message.opinion_market_id") != _id_value(
504524
expected,
505525
"resolved.opinion_market_id",

sdks/python/tests/test_hosted_typeddata.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -525,10 +525,42 @@ def test_validate_economics_rejects_market_worst_price_outside_domain(pinned: in
525525
)
526526

527527

528-
def test_validate_economics_rejects_opinion_market_id_mismatch() -> None:
528+
def test_validate_economics_rejects_opinion_token_id_mismatch() -> None:
529+
# The signed economic identity on Opinion is the outcome tokenId — a
530+
# build response resolving a different token than the message must fail.
531+
fixture = _fixture("opinion_buy")
532+
response = _replace_path(fixture["build_response"], ("resolved", "token_id"), "7777")
533+
534+
with pytest.raises(InvalidSignature):
535+
validate_economics(
536+
route=fixture["route"],
537+
build_request=_copy(fixture["build_request"]),
538+
build_response=response,
539+
)
540+
541+
542+
def test_validate_economics_ignores_params_market_id_when_message_has_no_field() -> None:
543+
# Current API schema: the signed message carries tokenId only. A
544+
# params/resolved opinion_market_id quirk must NOT block the order —
545+
# requiring message.opinion_market_id blocked every real Opinion order.
529546
fixture = _fixture("opinion_buy")
530547
response = _replace_path(fixture["build_response"], ("params", "opinion_market_id"), 7_777)
531548

549+
validate_economics(
550+
route=fixture["route"],
551+
build_request=_copy(fixture["build_request"]),
552+
build_response=response,
553+
)
554+
555+
556+
def test_validate_economics_rejects_legacy_message_market_id_mismatch() -> None:
557+
# Legacy schema: when the message DOES carry opinion_market_id it must
558+
# still match the build response.
559+
fixture = _fixture("opinion_buy")
560+
response = _copy(fixture["build_response"])
561+
response["typed_data"]["message"]["opinion_market_id"] = 1234
562+
response["resolved"]["opinion_market_id"] = 7_777
563+
532564
with pytest.raises(InvalidSignature):
533565
validate_economics(
534566
route=fixture["route"],

sdks/typescript/pmxt/hosted-typed-data.ts

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import { InvalidSignature } from "./hosted-errors";
1313
import { to6dec } from "./hosted-mappers";
14-
import { TypedData } from "./signers";
14+
import { TypedData, loadEthers } from "./signers";
1515

1616
// The constants module is updated in a parallel-agent change to add these
1717
// allowlists. We import them at runtime so we don't hard-fail if the change
@@ -508,24 +508,43 @@ function validateOpinionMarketId(
508508
message: Record<string, unknown>,
509509
buildResponse: any,
510510
): void {
511-
const expected = firstPresent(
511+
// The current trading-API Opinion message schema does not embed
512+
// opinion_market_id — the signed economic identity is the outcome
513+
// tokenId. Validate that against the build response's resolved token.
514+
// The legacy opinion_market_id check only applies when the message
515+
// actually carries the field (older API versions).
516+
const expectedToken = firstPresent(
517+
getPath(buildResponse, "resolved", "token_id"),
518+
getPath(buildResponse, "resolved", "tokenId"),
519+
getField(buildResponse, "token_id"),
520+
getField(buildResponse, "tokenId"),
521+
);
522+
const actualToken = firstPresent(
523+
getField(message, "tokenId"),
524+
getField(message, "token_id"),
525+
);
526+
if (actualToken === MISSING) economicFail("message.tokenId missing");
527+
if (expectedToken !== MISSING && idValue(actualToken) !== idValue(expectedToken)) {
528+
economicFail(`tokenId expected ${expectedToken} got ${actualToken}`);
529+
}
530+
531+
const actualMarketId = firstPresent(
532+
getField(message, "opinion_market_id"),
533+
getField(message, "opinionMarketId"),
534+
);
535+
if (actualMarketId === MISSING) return; // current schema: tokenId-only message
536+
537+
const expectedMarketId = firstPresent(
512538
getPath(buildResponse, "resolved", "opinion_market_id"),
513539
getPath(buildResponse, "resolved", "opinionMarketId"),
514540
getField(buildResponse, "opinion_market_id"),
515541
getField(buildResponse, "opinionMarketId"),
516542
getPath(buildResponse, "params", "opinion_market_id"),
517543
getPath(buildResponse, "params", "opinionMarketId"),
518544
);
519-
if (expected === MISSING) economicFail("resolved.opinion_market_id missing");
520-
521-
const actual = firstPresent(
522-
getField(message, "opinion_market_id"),
523-
getField(message, "opinionMarketId"),
524-
);
525-
if (actual === MISSING) economicFail("message.opinion_market_id missing");
526-
527-
if (idValue(actual) !== idValue(expected)) {
528-
economicFail(`opinion_market_id expected ${expected} got ${actual}`);
545+
if (expectedMarketId === MISSING) economicFail("resolved.opinion_market_id missing");
546+
if (idValue(actualMarketId) !== idValue(expectedMarketId)) {
547+
economicFail(`opinion_market_id expected ${expectedMarketId} got ${actualMarketId}`);
529548
}
530549
}
531550

@@ -575,12 +594,11 @@ export function verifySignature(
575594

576595
let ethers: any;
577596
try {
578-
// eslint-disable-next-line @typescript-eslint/no-require-imports
579-
ethers = require("ethers");
580-
} catch {
597+
ethers = loadEthers("ethers is required for hosted signature verification");
598+
} catch (err) {
581599
throw new InvalidSignature(
582600
0,
583-
"ethers is required for hosted signature verification",
601+
err instanceof Error ? err.message : "ethers is required for hosted signature verification",
584602
);
585603
}
586604

sdks/typescript/pmxt/signers.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,33 @@ export interface Signer {
3838
const ETHERS_INSTALL_HINT =
3939
"hosted trading requires the optional 'ethers' peer dependency. Install with: npm install ethers";
4040

41+
/**
42+
* Load ethers lazily in BOTH module systems. The CJS build has native
43+
* `require`; the ESM build does not — bare `require("ethers")` there threw
44+
* ReferenceError, which callers swallowed, silently dropping the signer and
45+
* killing every hosted write with "hosted write requires a signer".
46+
*/
47+
export function loadEthers(installHint: string = ETHERS_INSTALL_HINT): any {
48+
if (typeof require === "function") {
49+
try {
50+
// eslint-disable-next-line @typescript-eslint/no-require-imports
51+
return require("ethers");
52+
} catch {
53+
throw new Error(installHint);
54+
}
55+
}
56+
// ESM: synthesize a require. getBuiltinModule is sync-safe in both
57+
// module systems (Node >= 20.16).
58+
const nodeModule = (globalThis as any).process?.getBuiltinModule?.("node:module");
59+
const req = nodeModule?.createRequire?.(`${process.cwd()}/__pmxt_resolver__.js`);
60+
if (!req) throw new Error(installHint);
61+
try {
62+
return req("ethers");
63+
} catch {
64+
throw new Error(installHint);
65+
}
66+
}
67+
4168
/**
4269
* Built-in signer backed by an ethers `Wallet`.
4370
*
@@ -49,13 +76,7 @@ export class EthersSigner implements Signer {
4976
readonly address: string;
5077

5178
constructor(privateKey: string) {
52-
let ethers: any;
53-
try {
54-
// eslint-disable-next-line @typescript-eslint/no-require-imports
55-
ethers = require("ethers");
56-
} catch {
57-
throw new Error(ETHERS_INSTALL_HINT);
58-
}
79+
const ethers = loadEthers();
5980
this._wallet = new ethers.Wallet(privateKey);
6081
this.address = this._wallet.address;
6182
}

0 commit comments

Comments
 (0)