@playmos/sdk 0.3.1 → 0.3.2

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.
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { InvalidAmountError, ConfigError, NothingToWithdrawError, PaymentFailedError, ApiError, MissingFieldError, WalletConnectionError, InsufficientGasError, AuthError } from './chunk-TMBBEGIF.js';
1
+ import { InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError, AuthError } from './chunk-TMBBEGIF.js';
2
2
  export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-TMBBEGIF.js';
3
3
  import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
4
4
 
@@ -205,15 +205,36 @@ function createHttpClient(baseUrl, apiKey, retry) {
205
205
  );
206
206
  }
207
207
  }
208
+ function hasIdempotencyKey(init) {
209
+ const h = init.headers;
210
+ if (!h) return false;
211
+ if (h instanceof Headers) {
212
+ return Boolean(h.get("idempotency-key") || h.get("Idempotency-Key"));
213
+ }
214
+ if (Array.isArray(h)) {
215
+ return h.some(([k]) => String(k).toLowerCase() === "idempotency-key");
216
+ }
217
+ const rec = h;
218
+ return Object.keys(rec).some((k) => k.toLowerCase() === "idempotency-key" && !!rec[k]);
219
+ }
220
+ function isRetryable(res, init) {
221
+ if (res.status === 429) return true;
222
+ if (res.status === 502 || res.status === 503 || res.status === 504) {
223
+ const method = (init.method ?? "GET").toUpperCase();
224
+ if (method === "GET" || method === "HEAD") return true;
225
+ return hasIdempotencyKey(init);
226
+ }
227
+ return false;
228
+ }
208
229
  async function sendWithRetry(url, init) {
209
230
  let res = await send(url, init);
210
- for (let attempt = 0; res.status === 429 && attempt < cfg.maxRetries; attempt++) {
231
+ for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
211
232
  await sleep(backoffMs(res, attempt, cfg));
212
233
  res = await send(url, init);
213
234
  }
214
235
  return res;
215
236
  }
216
- async function handle(res) {
237
+ async function handle(res, acceptStatuses) {
217
238
  let text;
218
239
  try {
219
240
  text = await res.text();
@@ -228,9 +249,13 @@ function createHttpClient(baseUrl, apiKey, retry) {
228
249
  try {
229
250
  json = text ? JSON.parse(text) : {};
230
251
  } catch {
231
- throw new ApiError(`Non-JSON response (${res.status}) from ${res.url}`, { status: res.status, body: text });
252
+ const gateway = res.status === 502 || res.status === 503 || res.status === 504;
253
+ const msg = gateway ? `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while settling on-chain. Retry with the same idempotency key (safe); prefer a dedicated Base Sepolia RPC on the service (BB-GATEB-002).` : `Non-JSON response (${res.status}) from ${res.url}`;
254
+ throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
255
+ }
256
+ if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
257
+ return json;
232
258
  }
233
- if (res.ok) return json;
234
259
  const errBody = json;
235
260
  const message = errBody?.error?.message ?? `Request failed with ${res.status}`;
236
261
  if (res.status === 401 || res.status === 403 || errBody?.error?.code === "auth") {
@@ -246,7 +271,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
246
271
  };
247
272
  if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
248
273
  const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
249
- return handle(res);
274
+ return handle(res, opts?.acceptStatuses);
250
275
  },
251
276
  async get(path) {
252
277
  const res = await sendWithRetry(`${base}${path}`, {
@@ -462,7 +487,23 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
462
487
  function encodeApprove(spender, amountUnits) {
463
488
  return encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
464
489
  }
465
- async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
490
+ var GAS_FLOOR_WEI_DEFAULT = 200000000000000n;
491
+ var GAS_FLOOR_WEI_BASE_SEPOLIA = 50000000000000n;
492
+ var CHAIN_ID_BASE_SEPOLIA = 84532;
493
+ function gasFloorWei(chainId) {
494
+ return chainId === CHAIN_ID_BASE_SEPOLIA ? GAS_FLOOR_WEI_BASE_SEPOLIA : GAS_FLOOR_WEI_DEFAULT;
495
+ }
496
+ async function assertEnoughGas(provider, from, minWei) {
497
+ let floor = minWei;
498
+ if (floor === void 0) {
499
+ let chainId = 0;
500
+ try {
501
+ const hex = await provider.request({ method: "eth_chainId", params: [] });
502
+ chainId = Number(BigInt(hex));
503
+ } catch {
504
+ }
505
+ floor = gasFloorWei(chainId);
506
+ }
466
507
  let balance;
467
508
  try {
468
509
  const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
@@ -470,8 +511,11 @@ async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
470
511
  } catch {
471
512
  return;
472
513
  }
473
- if (balance < minWei) {
474
- throw new InsufficientGasError({ balanceWei: balance.toString(), minWei: minWei.toString() });
514
+ if (balance < floor) {
515
+ throw new InsufficientGasError({
516
+ balanceWei: balance.toString(),
517
+ minWei: floor.toString()
518
+ });
475
519
  }
476
520
  }
477
521
  var toBytes32 = (s) => keccak256(toBytes(s));
@@ -533,6 +577,7 @@ function mockEntryPayment(args) {
533
577
  args.seedBps,
534
578
  args.rakeBps
535
579
  );
580
+ const roundKey = args.roundKey && args.roundKey.trim() !== "" ? args.roundKey : args.roundId;
536
581
  return {
537
582
  id: prefixedId("entry"),
538
583
  status: "confirmed",
@@ -549,7 +594,8 @@ function mockEntryPayment(args) {
549
594
  },
550
595
  gameId: args.gameId,
551
596
  roundId: args.roundId,
552
- roundKey: args.roundId,
597
+ roundKey,
598
+ identity: args.identity,
553
599
  // Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
554
600
  prizePoolAddress: "0x0000000000000000000000000000000000000001",
555
601
  playerId: args.playerId,
@@ -560,14 +606,160 @@ function mockEntryPayment(args) {
560
606
  mock: true
561
607
  };
562
608
  }
609
+ function mockVerifyResult(payment) {
610
+ return { ...payment, mock: true };
611
+ }
563
612
 
564
- // src/client.ts
613
+ // src/x402.ts
565
614
  var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
566
- function requireAddressField(value, field) {
615
+ function requireAddress(value, field) {
567
616
  if (typeof value !== "string" || value.trim() === "") {
568
617
  throw new MissingFieldError(field);
569
618
  }
570
619
  if (!ADDRESS_RE.test(value)) {
620
+ throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
621
+ field,
622
+ value
623
+ });
624
+ }
625
+ return value.toLowerCase();
626
+ }
627
+ function networkToCaip2(network) {
628
+ if (network === "base") return "eip155:8453";
629
+ if (network === "base-sepolia") return "eip155:84532";
630
+ throw new ConfigError(`Unknown network for x402: ${JSON.stringify(network)}`, { network });
631
+ }
632
+ function toX402PaymentRequired(req, extras) {
633
+ const amountMicro = parseUsdToMicro(req.amount);
634
+ const out = {
635
+ scheme: "exact",
636
+ network: networkToCaip2(req.network),
637
+ maxAmountRequired: amountMicro.toString(),
638
+ // Service V1 emits the asset symbol; address is implied by network + config.
639
+ asset: "USDC",
640
+ payTo: req.payTo,
641
+ playmosRequirementId: req.id,
642
+ amount: req.amount,
643
+ playmosNetwork: req.network,
644
+ expiresAt: req.expiresAt,
645
+ terms: req.terms ?? null,
646
+ feeBps: extras?.feeBps ?? null,
647
+ feeSink: extras?.feeSink ?? null
648
+ };
649
+ return out;
650
+ }
651
+ function encodePaymentHeader(obj) {
652
+ const json = JSON.stringify(obj);
653
+ if (typeof Buffer !== "undefined") {
654
+ return Buffer.from(json, "utf8").toString("base64");
655
+ }
656
+ const bytes = new TextEncoder().encode(json);
657
+ let bin = "";
658
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
659
+ return btoa(bin);
660
+ }
661
+ function decodePaymentHeader(header) {
662
+ if (typeof header !== "string" || header.trim() === "") {
663
+ throw new ConfigError("payment header must be a non-empty base64 JSON string");
664
+ }
665
+ try {
666
+ let json;
667
+ if (typeof Buffer !== "undefined") {
668
+ json = Buffer.from(header, "base64").toString("utf8");
669
+ } else {
670
+ const bin = atob(header);
671
+ const bytes = new Uint8Array(bin.length);
672
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
673
+ json = new TextDecoder().decode(bytes);
674
+ }
675
+ return JSON.parse(json);
676
+ } catch (err) {
677
+ throw new ConfigError("invalid base64 JSON payment header", {
678
+ cause: err instanceof Error ? err.message : String(err)
679
+ });
680
+ }
681
+ }
682
+ function createX402Challenge(requirement, extras) {
683
+ if (!requirement || typeof requirement !== "object") {
684
+ throw new ConfigError("createX402Challenge requires a PaymentRequirement");
685
+ }
686
+ if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
687
+ throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
688
+ }
689
+ requireAddress(requirement.payTo, "payTo");
690
+ parseUsdToMicro(requirement.amount);
691
+ const feeBps = extras?.feeBps ?? 0;
692
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
693
+ throw new ConfigError(
694
+ `feeBps must be an integer in [0, 10000], got: ${JSON.stringify(extras?.feeBps)}`,
695
+ { feeBps: extras?.feeBps }
696
+ );
697
+ }
698
+ const feeSink = extras?.feeSink ?? null;
699
+ if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
700
+ const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
701
+ return {
702
+ status: 402,
703
+ headers: {
704
+ "PAYMENT-REQUIRED": encodePaymentHeader(paymentRequired),
705
+ "Content-Type": "application/json"
706
+ },
707
+ body: {
708
+ error: { code: "payment_required", message: "Payment Required" },
709
+ requirement,
710
+ paymentRequired,
711
+ feeBps,
712
+ feeSink
713
+ },
714
+ paymentRequired
715
+ };
716
+ }
717
+ function validateX402ChallengeInput(input) {
718
+ if (!input || typeof input !== "object") {
719
+ throw new ConfigError("x402 challenge input is required");
720
+ }
721
+ if ("url" in input && input.url !== void 0) {
722
+ throw new ConfigError(
723
+ "playmos.x402 does not accept { url } in V1 \u2014 use pay({ payTo, amount }) wrapping challenges+settle (ADR: resource URLs deferred V1.1+). See docs/design/PHASE-4-X402-ADR.md.",
724
+ { field: "url" }
725
+ );
726
+ }
727
+ const payTo = requireAddress(input.payTo, "payTo");
728
+ parseUsdToMicro(input.amount);
729
+ const intent = input.intent ?? "transfer";
730
+ if (intent !== "transfer" && intent !== "marketplace.buy") {
731
+ throw new ConfigError(
732
+ `x402 intent must be "transfer" or "marketplace.buy", got: ${JSON.stringify(input.intent)}`,
733
+ { intent: input.intent }
734
+ );
735
+ }
736
+ const feeBps = input.feeBps ?? 0;
737
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
738
+ throw new ConfigError(
739
+ `feeBps must be an integer in [0, 10000], got: ${JSON.stringify(input.feeBps)}`,
740
+ { feeBps: input.feeBps }
741
+ );
742
+ }
743
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddress(input.feeSink, "feeSink");
744
+ return {
745
+ payTo,
746
+ amount: input.amount,
747
+ intent,
748
+ feeBps,
749
+ ...feeSink !== void 0 ? { feeSink } : {},
750
+ ...input.terms !== void 0 ? { terms: input.terms } : {},
751
+ ...input.expiresInMs !== void 0 ? { expiresInMs: input.expiresInMs } : {},
752
+ ...input.id !== void 0 ? { id: input.id } : {}
753
+ };
754
+ }
755
+
756
+ // src/client.ts
757
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
758
+ function requireAddressField(value, field) {
759
+ if (typeof value !== "string" || value.trim() === "") {
760
+ throw new MissingFieldError(field);
761
+ }
762
+ if (!ADDRESS_RE2.test(value)) {
571
763
  throw new ConfigError(
572
764
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
573
765
  { field, value }
@@ -612,6 +804,122 @@ var Playmos = class {
612
804
  /** Create a Bridge KYC onboarding link (fiat payout). */
613
805
  createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
614
806
  };
807
+ /**
808
+ * `x402` — Phase 4 HTTP 402 / x402-shaped adapter (#153).
809
+ *
810
+ * V1 surface (ADR):
811
+ * - `challenge` → `POST /v1/x402/challenges` (returns 402; treated as success)
812
+ * - `fulfill` → `POST /v1/x402/settle`
813
+ * - `pay` → challenge then fulfill (senior DX ≤15 lines)
814
+ *
815
+ * **Not** resource URLs (`pay({ url })` is V1.1+). **Not** stock third-party
816
+ * x402 client interop (X3b deferred until #161). Requires `sk_test_` +
817
+ * Base Sepolia; flag `PLAYMOS_X402_ENABLED` on the service.
818
+ *
819
+ * ```ts
820
+ * const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SK!, network: "base-sepolia" });
821
+ * const result = await playmos.x402.pay({
822
+ * payTo: "0x…",
823
+ * amount: "0.50",
824
+ * feeBps: 0,
825
+ * });
826
+ * // result.txHash → BaseScan
827
+ * ```
828
+ */
829
+ this.x402 = {
830
+ /**
831
+ * Mint a server-authoritative PaymentRequirement (HTTP 402).
832
+ * Fee terms are stored server-side — settle rejects payload disagreement.
833
+ */
834
+ challenge: async (input) => {
835
+ this.assertX402Allowed();
836
+ const body = validateX402ChallengeInput(input);
837
+ if (body.intent === "marketplace.buy") {
838
+ throw new ConfigError(
839
+ 'x402 challenge intent "marketplace.buy" is not supported yet (V1 service: "transfer" only; marketplace.buy follows).',
840
+ { intent: body.intent }
841
+ );
842
+ }
843
+ const res = await this.http.post("/x402/challenges", body, { acceptStatuses: [402] });
844
+ return {
845
+ requirement: res.requirement,
846
+ paymentRequired: res.paymentRequired,
847
+ feeBps: res.feeBps ?? body.feeBps,
848
+ feeSink: res.feeSink ?? body.feeSink ?? null
849
+ };
850
+ },
851
+ /**
852
+ * Settle a previously minted challenge with an `x402-payload` authorization.
853
+ * Defaults mode to `server-signer` when omitted (explicit on the wire — fail-closed
854
+ * materialize still requires mode; the route also defaults for this path).
855
+ */
856
+ fulfill: async (requirement, authorization) => {
857
+ this.assertX402Allowed();
858
+ if (!requirement || typeof requirement !== "object") {
859
+ throw new ConfigError("fulfill requires a PaymentRequirement from challenge()");
860
+ }
861
+ if (typeof requirement.id !== "string" || requirement.id.trim() === "") {
862
+ throw new MissingFieldError("requirement.id");
863
+ }
864
+ let payload;
865
+ if (authorization && typeof authorization === "object" && "kind" in authorization) {
866
+ const a = authorization;
867
+ if (a.kind !== "x402-payload") {
868
+ throw new ConfigError('authorization.kind must be "x402-payload"', { kind: a.kind });
869
+ }
870
+ payload = { ...a.payload ?? {} };
871
+ } else {
872
+ payload = { ...authorization ?? {} };
873
+ }
874
+ delete payload.feeBps;
875
+ delete payload.feeSink;
876
+ if (payload.mode === void 0 || typeof payload.mode === "string" && payload.mode.trim() === "") {
877
+ const hasNestedAuth = payload.authorization !== void 0 || payload.signature !== void 0 || payload.paymentPayload !== void 0;
878
+ if (hasNestedAuth) {
879
+ throw new ConfigError(
880
+ "fulfill: payload.mode is required when authorization/signature is present \u2014 stock-shaped payloads are not auto-mapped to server-signer (V1: no interop; set mode explicitly or see #161)",
881
+ { field: "mode" }
882
+ );
883
+ }
884
+ payload.mode = "server-signer";
885
+ }
886
+ if (payload.scheme === void 0) payload.scheme = "exact";
887
+ if (payload.requirementId === void 0) payload.requirementId = requirement.id;
888
+ const res = await this.http.post("/x402/settle", {
889
+ requirement,
890
+ authorization: { kind: "x402-payload", payload }
891
+ });
892
+ const s = res.settlement;
893
+ return {
894
+ requirementId: s.requirementId,
895
+ status: s.status,
896
+ txHash: s.txHash ?? null,
897
+ idempotentReplay: Boolean(s.idempotentReplay),
898
+ verifiedVia: s.verifiedVia,
899
+ feeBps: s.feeBps,
900
+ fee: s.fee,
901
+ net: s.net,
902
+ requirement: res.requirement,
903
+ success: s.success
904
+ };
905
+ },
906
+ /**
907
+ * Senior DX: mint challenge + settle in one call.
908
+ * Wraps challenges + settle only — **not** `pay({ url })` (ADR).
909
+ */
910
+ pay: async (input) => {
911
+ this.assertX402Allowed();
912
+ validateX402ChallengeInput(input);
913
+ const challenge = await this.x402.challenge(input);
914
+ const auth = {
915
+ mode: input.mode ?? "server-signer"
916
+ };
917
+ if (input.from !== void 0) auth.from = input.from;
918
+ if (input.authorization !== void 0) auth.authorization = input.authorization;
919
+ if (input.txHash !== void 0) auth.txHash = input.txHash;
920
+ return this.x402.fulfill(challenge.requirement, auth);
921
+ }
922
+ };
615
923
  /**
616
924
  * Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
617
925
  *
@@ -944,6 +1252,11 @@ var Playmos = class {
944
1252
  return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
945
1253
  }
946
1254
  };
1255
+ /**
1256
+ * Offline mock payments for this client instance — so `verify(id)` after
1257
+ * `mock: true` pay/enterRound does not hit the live API (#204).
1258
+ */
1259
+ this.mockPayments = /* @__PURE__ */ new Map();
947
1260
  /**
948
1261
  * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
949
1262
  * `get` reconciles once against chain truth; `wait` polls it to a terminal state
@@ -993,6 +1306,9 @@ var Playmos = class {
993
1306
  }
994
1307
  /** Connect the player's wallet and return their address. */
995
1308
  async connect() {
1309
+ if (this.config.mock) {
1310
+ return "0x0000000000000000000000000000000000000001";
1311
+ }
996
1312
  return getAccount(resolveProvider(this.config.wallet));
997
1313
  }
998
1314
  /**
@@ -1009,18 +1325,29 @@ var Playmos = class {
1009
1325
  payEntry: async (req) => {
1010
1326
  const entry = await this.enterRound({
1011
1327
  gameId: cfg.gameId,
1328
+ // Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
1012
1329
  roundId: req.roundKey,
1013
- // the round the server verifies against
1014
1330
  roundKey: req.roundKey,
1015
1331
  identity: req.identity,
1016
1332
  amount: formatMicroToUsd(req.entryUnits),
1017
1333
  playerId: req.wallet
1018
1334
  });
1019
1335
  const status = entry.status === "confirmed" ? "CONFIRMED" : entry.status === "failed" ? "FAILED" : "PENDING";
1020
- return { paymentId: entry.id, status, txHash: entry.txHash, onchain: true, identity: entry.identity };
1336
+ const onchain = !entry.mock;
1337
+ return {
1338
+ paymentId: entry.id,
1339
+ status,
1340
+ txHash: entry.txHash,
1341
+ onchain,
1342
+ identity: entry.identity ?? req.identity
1343
+ };
1021
1344
  }
1022
1345
  };
1023
1346
  }
1347
+ rememberMock(payment) {
1348
+ if (payment.mock) this.mockPayments.set(payment.id, payment);
1349
+ return payment;
1350
+ }
1024
1351
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
1025
1352
  async pay(input) {
1026
1353
  const amountMicro = validateAmount(input.amount);
@@ -1029,14 +1356,16 @@ var Playmos = class {
1029
1356
  const metadata = validateMetadata(input.metadata);
1030
1357
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1031
1358
  if (this.config.mock) {
1032
- return mockIapPayment({
1033
- amountMicro,
1034
- feeBps: IAP_FEE_BPS,
1035
- sku: input.sku,
1036
- playerId: input.playerId,
1037
- chain: this.env.network,
1038
- metadata
1039
- });
1359
+ return this.rememberMock(
1360
+ mockIapPayment({
1361
+ amountMicro,
1362
+ feeBps: IAP_FEE_BPS,
1363
+ sku: input.sku,
1364
+ playerId: input.playerId,
1365
+ chain: this.env.network,
1366
+ metadata
1367
+ })
1368
+ );
1040
1369
  }
1041
1370
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1042
1371
  const res = await this.http.post(
@@ -1079,17 +1408,22 @@ var Playmos = class {
1079
1408
  const metadata = validateMetadata(input.metadata);
1080
1409
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1081
1410
  if (this.config.mock) {
1082
- return mockEntryPayment({
1083
- amountMicro,
1084
- poolBps: POOL_BPS,
1085
- seedBps: SEED_BPS,
1086
- rakeBps: RAKE_BPS,
1087
- gameId: input.gameId,
1088
- roundId: input.roundId,
1089
- playerId: input.playerId,
1090
- chain: this.env.network,
1091
- metadata
1092
- });
1411
+ return this.rememberMock(
1412
+ mockEntryPayment({
1413
+ amountMicro,
1414
+ poolBps: POOL_BPS,
1415
+ seedBps: SEED_BPS,
1416
+ rakeBps: RAKE_BPS,
1417
+ gameId: input.gameId,
1418
+ roundId: input.roundId,
1419
+ playerId: input.playerId,
1420
+ chain: this.env.network,
1421
+ metadata,
1422
+ // B2 pins — must survive mock path for hub hasEntered parity (#204).
1423
+ roundKey: input.roundKey,
1424
+ identity: input.identity
1425
+ })
1426
+ );
1093
1427
  }
1094
1428
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1095
1429
  const res = await this.http.post(
@@ -1101,7 +1435,7 @@ var Playmos = class {
1101
1435
  if (input.identity) payment2.identity = input.identity;
1102
1436
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1103
1437
  if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1104
- payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1438
+ payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1105
1439
  return payment2;
1106
1440
  }
1107
1441
  const intent = await this.http.post(
@@ -1196,9 +1530,41 @@ var Playmos = class {
1196
1530
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1197
1531
  async verify(paymentId) {
1198
1532
  requireField(paymentId, "paymentId");
1533
+ if (this.config.mock) {
1534
+ const cached = this.mockPayments.get(paymentId);
1535
+ if (!cached) {
1536
+ throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
1537
+ }
1538
+ const m = mockVerifyResult(cached);
1539
+ return {
1540
+ id: m.id,
1541
+ status: m.status,
1542
+ amount: m.amount,
1543
+ fee: m.fee,
1544
+ net: m.net,
1545
+ txHash: m.txHash,
1546
+ playerId: m.playerId ?? "",
1547
+ sku: m.sku,
1548
+ roundId: m.roundId,
1549
+ chain: m.chain,
1550
+ verifiedVia: "cache"
1551
+ };
1552
+ }
1199
1553
  return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
1200
1554
  }
1201
1555
  // ---- internals ---------------------------------------------------------
1556
+ /**
1557
+ * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1558
+ * network call so mainnet / live keys never silently hit the adapter.
1559
+ */
1560
+ assertX402Allowed() {
1561
+ if (!this.env.isTest || this.env.network !== "base-sepolia") {
1562
+ throw new ConfigError(
1563
+ 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1564
+ { network: this.env.network, isTest: this.env.isTest }
1565
+ );
1566
+ }
1567
+ }
1202
1568
  /**
1203
1569
  * Map a server-settled payment (from the `settle: "server"` response) into the
1204
1570
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
@@ -1379,10 +1745,10 @@ var PayoutError = class extends Error {
1379
1745
  this.name = "PayoutError";
1380
1746
  }
1381
1747
  };
1382
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1748
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1383
1749
  var BPS = 10000n;
1384
- function requireAddress(w, i) {
1385
- if (!ADDRESS_RE2.test(w)) {
1750
+ function requireAddress2(w, i) {
1751
+ if (!ADDRESS_RE3.test(w)) {
1386
1752
  throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1387
1753
  }
1388
1754
  return w.toLowerCase();
@@ -1405,7 +1771,7 @@ function computePayout(pool, ranking, rule) {
1405
1771
  if (!Array.isArray(ranking) || ranking.length === 0) {
1406
1772
  throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1407
1773
  }
1408
- const wallets = ranking.map((w, i) => requireAddress(w, i));
1774
+ const wallets = ranking.map((w, i) => requireAddress2(w, i));
1409
1775
  const seen = /* @__PURE__ */ new Set();
1410
1776
  for (const w of wallets) {
1411
1777
  if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
@@ -1478,13 +1844,13 @@ function computePayout(pool, ranking, rule) {
1478
1844
  }
1479
1845
 
1480
1846
  // src/settlement.ts
1481
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1847
+ var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
1482
1848
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1483
- function requireAddress2(value, field) {
1849
+ function requireAddress3(value, field) {
1484
1850
  if (typeof value !== "string" || value.trim() === "") {
1485
1851
  throw new MissingFieldError(field);
1486
1852
  }
1487
- if (!ADDRESS_RE3.test(value)) {
1853
+ if (!ADDRESS_RE4.test(value)) {
1488
1854
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1489
1855
  field,
1490
1856
  value
@@ -1501,7 +1867,7 @@ function requireNetwork(value) {
1501
1867
  }
1502
1868
  function createPaymentRequirement(input) {
1503
1869
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1504
- const payTo = requireAddress2(input.payTo, "payTo");
1870
+ const payTo = requireAddress3(input.payTo, "payTo");
1505
1871
  const network = requireNetwork(input.network);
1506
1872
  const asset = input.asset ?? "USDC";
1507
1873
  if (asset !== "USDC") {
@@ -1544,7 +1910,7 @@ function parsePaymentRequirement(input) {
1544
1910
  asset: o.asset
1545
1911
  });
1546
1912
  }
1547
- const payTo = requireAddress2(o.payTo, "payTo");
1913
+ const payTo = requireAddress3(o.payTo, "payTo");
1548
1914
  const network = requireNetwork(o.network);
1549
1915
  if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1550
1916
  parseUsdToMicro(o.amount);
@@ -1570,6 +1936,6 @@ function isX402PayloadAuthorization(auth) {
1570
1936
  return auth.kind === "x402-payload";
1571
1937
  }
1572
1938
 
1573
- export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, formatMicroToUsd, isWalletSignatureAuthorization, isX402PayloadAuthorization, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, serializePaymentRequirement, ulid };
1939
+ export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
1574
1940
  //# sourceMappingURL=index.js.map
1575
1941
  //# sourceMappingURL=index.js.map