@playmos/sdk 0.3.1 → 0.3.3

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.cjs CHANGED
@@ -275,15 +275,36 @@ function createHttpClient(baseUrl, apiKey, retry) {
275
275
  );
276
276
  }
277
277
  }
278
+ function hasIdempotencyKey(init) {
279
+ const h = init.headers;
280
+ if (!h) return false;
281
+ if (h instanceof Headers) {
282
+ return Boolean(h.get("idempotency-key") || h.get("Idempotency-Key"));
283
+ }
284
+ if (Array.isArray(h)) {
285
+ return h.some(([k]) => String(k).toLowerCase() === "idempotency-key");
286
+ }
287
+ const rec = h;
288
+ return Object.keys(rec).some((k) => k.toLowerCase() === "idempotency-key" && !!rec[k]);
289
+ }
290
+ function isRetryable(res, init) {
291
+ if (res.status === 429) return true;
292
+ if (res.status === 502 || res.status === 503 || res.status === 504) {
293
+ const method = (init.method ?? "GET").toUpperCase();
294
+ if (method === "GET" || method === "HEAD") return true;
295
+ return hasIdempotencyKey(init);
296
+ }
297
+ return false;
298
+ }
278
299
  async function sendWithRetry(url, init) {
279
300
  let res = await send(url, init);
280
- for (let attempt = 0; res.status === 429 && attempt < cfg.maxRetries; attempt++) {
301
+ for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
281
302
  await sleep(backoffMs(res, attempt, cfg));
282
303
  res = await send(url, init);
283
304
  }
284
305
  return res;
285
306
  }
286
- async function handle(res) {
307
+ async function handle(res, acceptStatuses) {
287
308
  let text;
288
309
  try {
289
310
  text = await res.text();
@@ -298,9 +319,13 @@ function createHttpClient(baseUrl, apiKey, retry) {
298
319
  try {
299
320
  json = text ? JSON.parse(text) : {};
300
321
  } catch {
301
- throw new ApiError(`Non-JSON response (${res.status}) from ${res.url}`, { status: res.status, body: text });
322
+ const gateway = res.status === 502 || res.status === 503 || res.status === 504;
323
+ 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}`;
324
+ throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
325
+ }
326
+ if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
327
+ return json;
302
328
  }
303
- if (res.ok) return json;
304
329
  const errBody = json;
305
330
  const message = errBody?.error?.message ?? `Request failed with ${res.status}`;
306
331
  if (res.status === 401 || res.status === 403 || errBody?.error?.code === "auth") {
@@ -316,7 +341,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
316
341
  };
317
342
  if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
318
343
  const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
319
- return handle(res);
344
+ return handle(res, opts?.acceptStatuses);
320
345
  },
321
346
  async get(path) {
322
347
  const res = await sendWithRetry(`${base}${path}`, {
@@ -532,7 +557,23 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
532
557
  function encodeApprove(spender, amountUnits) {
533
558
  return viem.encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
534
559
  }
535
- async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
560
+ var GAS_FLOOR_WEI_DEFAULT = 200000000000000n;
561
+ var GAS_FLOOR_WEI_BASE_SEPOLIA = 50000000000000n;
562
+ var CHAIN_ID_BASE_SEPOLIA = 84532;
563
+ function gasFloorWei(chainId) {
564
+ return chainId === CHAIN_ID_BASE_SEPOLIA ? GAS_FLOOR_WEI_BASE_SEPOLIA : GAS_FLOOR_WEI_DEFAULT;
565
+ }
566
+ async function assertEnoughGas(provider, from, minWei) {
567
+ let floor = minWei;
568
+ if (floor === void 0) {
569
+ let chainId = 0;
570
+ try {
571
+ const hex = await provider.request({ method: "eth_chainId", params: [] });
572
+ chainId = Number(BigInt(hex));
573
+ } catch {
574
+ }
575
+ floor = gasFloorWei(chainId);
576
+ }
536
577
  let balance;
537
578
  try {
538
579
  const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
@@ -540,8 +581,11 @@ async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
540
581
  } catch {
541
582
  return;
542
583
  }
543
- if (balance < minWei) {
544
- throw new InsufficientGasError({ balanceWei: balance.toString(), minWei: minWei.toString() });
584
+ if (balance < floor) {
585
+ throw new InsufficientGasError({
586
+ balanceWei: balance.toString(),
587
+ minWei: floor.toString()
588
+ });
545
589
  }
546
590
  }
547
591
  var toBytes32 = (s) => viem.keccak256(viem.toBytes(s));
@@ -603,6 +647,7 @@ function mockEntryPayment(args) {
603
647
  args.seedBps,
604
648
  args.rakeBps
605
649
  );
650
+ const roundKey = args.roundKey && args.roundKey.trim() !== "" ? args.roundKey : args.roundId;
606
651
  return {
607
652
  id: prefixedId("entry"),
608
653
  status: "confirmed",
@@ -619,7 +664,8 @@ function mockEntryPayment(args) {
619
664
  },
620
665
  gameId: args.gameId,
621
666
  roundId: args.roundId,
622
- roundKey: args.roundId,
667
+ roundKey,
668
+ identity: args.identity,
623
669
  // Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
624
670
  prizePoolAddress: "0x0000000000000000000000000000000000000001",
625
671
  playerId: args.playerId,
@@ -630,14 +676,160 @@ function mockEntryPayment(args) {
630
676
  mock: true
631
677
  };
632
678
  }
679
+ function mockVerifyResult(payment) {
680
+ return { ...payment, mock: true };
681
+ }
633
682
 
634
- // src/client.ts
683
+ // src/x402.ts
635
684
  var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
636
- function requireAddressField(value, field) {
685
+ function requireAddress(value, field) {
637
686
  if (typeof value !== "string" || value.trim() === "") {
638
687
  throw new MissingFieldError(field);
639
688
  }
640
689
  if (!ADDRESS_RE.test(value)) {
690
+ throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
691
+ field,
692
+ value
693
+ });
694
+ }
695
+ return value.toLowerCase();
696
+ }
697
+ function networkToCaip2(network) {
698
+ if (network === "base") return "eip155:8453";
699
+ if (network === "base-sepolia") return "eip155:84532";
700
+ throw new ConfigError(`Unknown network for x402: ${JSON.stringify(network)}`, { network });
701
+ }
702
+ function toX402PaymentRequired(req, extras) {
703
+ const amountMicro = parseUsdToMicro(req.amount);
704
+ const out = {
705
+ scheme: "exact",
706
+ network: networkToCaip2(req.network),
707
+ maxAmountRequired: amountMicro.toString(),
708
+ // Service V1 emits the asset symbol; address is implied by network + config.
709
+ asset: "USDC",
710
+ payTo: req.payTo,
711
+ playmosRequirementId: req.id,
712
+ amount: req.amount,
713
+ playmosNetwork: req.network,
714
+ expiresAt: req.expiresAt,
715
+ terms: req.terms ?? null,
716
+ feeBps: extras?.feeBps ?? null,
717
+ feeSink: extras?.feeSink ?? null
718
+ };
719
+ return out;
720
+ }
721
+ function encodePaymentHeader(obj) {
722
+ const json = JSON.stringify(obj);
723
+ if (typeof Buffer !== "undefined") {
724
+ return Buffer.from(json, "utf8").toString("base64");
725
+ }
726
+ const bytes = new TextEncoder().encode(json);
727
+ let bin = "";
728
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
729
+ return btoa(bin);
730
+ }
731
+ function decodePaymentHeader(header) {
732
+ if (typeof header !== "string" || header.trim() === "") {
733
+ throw new ConfigError("payment header must be a non-empty base64 JSON string");
734
+ }
735
+ try {
736
+ let json;
737
+ if (typeof Buffer !== "undefined") {
738
+ json = Buffer.from(header, "base64").toString("utf8");
739
+ } else {
740
+ const bin = atob(header);
741
+ const bytes = new Uint8Array(bin.length);
742
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
743
+ json = new TextDecoder().decode(bytes);
744
+ }
745
+ return JSON.parse(json);
746
+ } catch (err) {
747
+ throw new ConfigError("invalid base64 JSON payment header", {
748
+ cause: err instanceof Error ? err.message : String(err)
749
+ });
750
+ }
751
+ }
752
+ function createX402Challenge(requirement, extras) {
753
+ if (!requirement || typeof requirement !== "object") {
754
+ throw new ConfigError("createX402Challenge requires a PaymentRequirement");
755
+ }
756
+ if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
757
+ throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
758
+ }
759
+ requireAddress(requirement.payTo, "payTo");
760
+ parseUsdToMicro(requirement.amount);
761
+ const feeBps = extras?.feeBps ?? 0;
762
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
763
+ throw new ConfigError(
764
+ `feeBps must be an integer in [0, 10000], got: ${JSON.stringify(extras?.feeBps)}`,
765
+ { feeBps: extras?.feeBps }
766
+ );
767
+ }
768
+ const feeSink = extras?.feeSink ?? null;
769
+ if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
770
+ const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
771
+ return {
772
+ status: 402,
773
+ headers: {
774
+ "PAYMENT-REQUIRED": encodePaymentHeader(paymentRequired),
775
+ "Content-Type": "application/json"
776
+ },
777
+ body: {
778
+ error: { code: "payment_required", message: "Payment Required" },
779
+ requirement,
780
+ paymentRequired,
781
+ feeBps,
782
+ feeSink
783
+ },
784
+ paymentRequired
785
+ };
786
+ }
787
+ function validateX402ChallengeInput(input) {
788
+ if (!input || typeof input !== "object") {
789
+ throw new ConfigError("x402 challenge input is required");
790
+ }
791
+ if ("url" in input && input.url !== void 0) {
792
+ throw new ConfigError(
793
+ "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.",
794
+ { field: "url" }
795
+ );
796
+ }
797
+ const payTo = requireAddress(input.payTo, "payTo");
798
+ parseUsdToMicro(input.amount);
799
+ const intent = input.intent ?? "transfer";
800
+ if (intent !== "transfer" && intent !== "marketplace.buy") {
801
+ throw new ConfigError(
802
+ `x402 intent must be "transfer" or "marketplace.buy", got: ${JSON.stringify(input.intent)}`,
803
+ { intent: input.intent }
804
+ );
805
+ }
806
+ const feeBps = input.feeBps ?? 0;
807
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
808
+ throw new ConfigError(
809
+ `feeBps must be an integer in [0, 10000], got: ${JSON.stringify(input.feeBps)}`,
810
+ { feeBps: input.feeBps }
811
+ );
812
+ }
813
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddress(input.feeSink, "feeSink");
814
+ return {
815
+ payTo,
816
+ amount: input.amount,
817
+ intent,
818
+ feeBps,
819
+ ...feeSink !== void 0 ? { feeSink } : {},
820
+ ...input.terms !== void 0 ? { terms: input.terms } : {},
821
+ ...input.expiresInMs !== void 0 ? { expiresInMs: input.expiresInMs } : {},
822
+ ...input.id !== void 0 ? { id: input.id } : {}
823
+ };
824
+ }
825
+
826
+ // src/client.ts
827
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
828
+ function requireAddressField(value, field) {
829
+ if (typeof value !== "string" || value.trim() === "") {
830
+ throw new MissingFieldError(field);
831
+ }
832
+ if (!ADDRESS_RE2.test(value)) {
641
833
  throw new ConfigError(
642
834
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
643
835
  { field, value }
@@ -682,6 +874,122 @@ var Playmos = class {
682
874
  /** Create a Bridge KYC onboarding link (fiat payout). */
683
875
  createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
684
876
  };
877
+ /**
878
+ * `x402` — Phase 4 HTTP 402 / x402-shaped adapter (#153).
879
+ *
880
+ * V1 surface (ADR):
881
+ * - `challenge` → `POST /v1/x402/challenges` (returns 402; treated as success)
882
+ * - `fulfill` → `POST /v1/x402/settle`
883
+ * - `pay` → challenge then fulfill (senior DX ≤15 lines)
884
+ *
885
+ * **Not** resource URLs (`pay({ url })` is V1.1+). **Not** stock third-party
886
+ * x402 client interop (X3b deferred until #161). Requires `sk_test_` +
887
+ * Base Sepolia; flag `PLAYMOS_X402_ENABLED` on the service.
888
+ *
889
+ * ```ts
890
+ * const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SK!, network: "base-sepolia" });
891
+ * const result = await playmos.x402.pay({
892
+ * payTo: "0x…",
893
+ * amount: "0.50",
894
+ * feeBps: 0,
895
+ * });
896
+ * // result.txHash → BaseScan
897
+ * ```
898
+ */
899
+ this.x402 = {
900
+ /**
901
+ * Mint a server-authoritative PaymentRequirement (HTTP 402).
902
+ * Fee terms are stored server-side — settle rejects payload disagreement.
903
+ */
904
+ challenge: async (input) => {
905
+ this.assertX402Allowed();
906
+ const body = validateX402ChallengeInput(input);
907
+ if (body.intent === "marketplace.buy") {
908
+ throw new ConfigError(
909
+ 'x402 challenge intent "marketplace.buy" is not supported yet (V1 service: "transfer" only; marketplace.buy follows).',
910
+ { intent: body.intent }
911
+ );
912
+ }
913
+ const res = await this.http.post("/x402/challenges", body, { acceptStatuses: [402] });
914
+ return {
915
+ requirement: res.requirement,
916
+ paymentRequired: res.paymentRequired,
917
+ feeBps: res.feeBps ?? body.feeBps,
918
+ feeSink: res.feeSink ?? body.feeSink ?? null
919
+ };
920
+ },
921
+ /**
922
+ * Settle a previously minted challenge with an `x402-payload` authorization.
923
+ * Defaults mode to `server-signer` when omitted (explicit on the wire — fail-closed
924
+ * materialize still requires mode; the route also defaults for this path).
925
+ */
926
+ fulfill: async (requirement, authorization) => {
927
+ this.assertX402Allowed();
928
+ if (!requirement || typeof requirement !== "object") {
929
+ throw new ConfigError("fulfill requires a PaymentRequirement from challenge()");
930
+ }
931
+ if (typeof requirement.id !== "string" || requirement.id.trim() === "") {
932
+ throw new MissingFieldError("requirement.id");
933
+ }
934
+ let payload;
935
+ if (authorization && typeof authorization === "object" && "kind" in authorization) {
936
+ const a = authorization;
937
+ if (a.kind !== "x402-payload") {
938
+ throw new ConfigError('authorization.kind must be "x402-payload"', { kind: a.kind });
939
+ }
940
+ payload = { ...a.payload ?? {} };
941
+ } else {
942
+ payload = { ...authorization ?? {} };
943
+ }
944
+ delete payload.feeBps;
945
+ delete payload.feeSink;
946
+ if (payload.mode === void 0 || typeof payload.mode === "string" && payload.mode.trim() === "") {
947
+ const hasNestedAuth = payload.authorization !== void 0 || payload.signature !== void 0 || payload.paymentPayload !== void 0;
948
+ if (hasNestedAuth) {
949
+ throw new ConfigError(
950
+ "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)",
951
+ { field: "mode" }
952
+ );
953
+ }
954
+ payload.mode = "server-signer";
955
+ }
956
+ if (payload.scheme === void 0) payload.scheme = "exact";
957
+ if (payload.requirementId === void 0) payload.requirementId = requirement.id;
958
+ const res = await this.http.post("/x402/settle", {
959
+ requirement,
960
+ authorization: { kind: "x402-payload", payload }
961
+ });
962
+ const s = res.settlement;
963
+ return {
964
+ requirementId: s.requirementId,
965
+ status: s.status,
966
+ txHash: s.txHash ?? null,
967
+ idempotentReplay: Boolean(s.idempotentReplay),
968
+ verifiedVia: s.verifiedVia,
969
+ feeBps: s.feeBps,
970
+ fee: s.fee,
971
+ net: s.net,
972
+ requirement: res.requirement,
973
+ success: s.success
974
+ };
975
+ },
976
+ /**
977
+ * Senior DX: mint challenge + settle in one call.
978
+ * Wraps challenges + settle only — **not** `pay({ url })` (ADR).
979
+ */
980
+ pay: async (input) => {
981
+ this.assertX402Allowed();
982
+ validateX402ChallengeInput(input);
983
+ const challenge = await this.x402.challenge(input);
984
+ const auth = {
985
+ mode: input.mode ?? "server-signer"
986
+ };
987
+ if (input.from !== void 0) auth.from = input.from;
988
+ if (input.authorization !== void 0) auth.authorization = input.authorization;
989
+ if (input.txHash !== void 0) auth.txHash = input.txHash;
990
+ return this.x402.fulfill(challenge.requirement, auth);
991
+ }
992
+ };
685
993
  /**
686
994
  * Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
687
995
  *
@@ -691,6 +999,8 @@ var Playmos = class {
691
999
  *
692
1000
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
693
1001
  */
1002
+ /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
1003
+ this.mockRounds = /* @__PURE__ */ new Map();
694
1004
  this.rounds = {
695
1005
  open: async (input) => {
696
1006
  requireField(input?.gameId, "gameId");
@@ -700,6 +1010,34 @@ var Playmos = class {
700
1010
  throw new ConfigError("payout rule is required (winner-take-all | top-n | custom)");
701
1011
  }
702
1012
  validateAmount(input.entryAmount);
1013
+ if (this.config.mock) {
1014
+ const roundKey = input.roundKey ?? input.roundId;
1015
+ const existing = this.mockRounds.get(input.roundId);
1016
+ if (existing?.status === "open" || existing?.status === "locked") {
1017
+ return existing;
1018
+ }
1019
+ if (existing?.status === "settled") {
1020
+ throw new ApiError(`round ${input.roundId} already settled`, {
1021
+ status: 409,
1022
+ code: "conflict"
1023
+ });
1024
+ }
1025
+ const round2 = {
1026
+ roundId: input.roundId,
1027
+ gameId: input.gameId,
1028
+ roundKey,
1029
+ status: "open",
1030
+ entryAmount: input.entryAmount,
1031
+ pool: null,
1032
+ entrants: 0,
1033
+ payout: input.payout,
1034
+ closeAt: input.closeAt,
1035
+ openTxHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1036
+ prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1037
+ };
1038
+ this.mockRounds.set(input.roundId, round2);
1039
+ return round2;
1040
+ }
703
1041
  const { round } = await this.http.post("/rounds", {
704
1042
  gameId: input.gameId,
705
1043
  roundId: input.roundId,
@@ -713,6 +1051,29 @@ var Playmos = class {
713
1051
  },
714
1052
  lock: async (input) => {
715
1053
  requireField(input?.roundId, "roundId");
1054
+ if (this.config.mock) {
1055
+ const existing = this.mockRounds.get(input.roundId);
1056
+ if (!existing) {
1057
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1058
+ }
1059
+ if (existing.status === "locked" || existing.status === "settled") return existing;
1060
+ if (existing.status !== "open") {
1061
+ throw new ApiError(`round ${input.roundId} is not open (status=${existing.status})`, {
1062
+ status: 409,
1063
+ code: "conflict"
1064
+ });
1065
+ }
1066
+ const locked = {
1067
+ ...existing,
1068
+ status: "locked",
1069
+ // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1070
+ pool: existing.pool ?? existing.entryAmount,
1071
+ entrants: existing.entrants ?? 0,
1072
+ lockTxHash: "0x000000000000000000000000000000000000000000000000000000000000loc1"
1073
+ };
1074
+ this.mockRounds.set(input.roundId, locked);
1075
+ return locked;
1076
+ }
716
1077
  const { round } = await this.http.post(
717
1078
  `/rounds/${encodeURIComponent(input.roundId)}/lock`,
718
1079
  { gameId: input.gameId }
@@ -722,6 +1083,51 @@ var Playmos = class {
722
1083
  settle: async (input) => {
723
1084
  requireField(input?.roundId, "roundId");
724
1085
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1086
+ if (this.config.mock) {
1087
+ const existing = this.mockRounds.get(input.roundId);
1088
+ if (!existing) {
1089
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1090
+ }
1091
+ if (existing.status === "settled" && existing.settleTxHash) {
1092
+ return {
1093
+ roundId: input.roundId,
1094
+ txHash: existing.settleTxHash,
1095
+ poolPaid: existing.pool ?? "0",
1096
+ winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1097
+ wallet,
1098
+ amount: existing.pool ?? existing.entryAmount
1099
+ })),
1100
+ status: "settled"
1101
+ };
1102
+ }
1103
+ if (existing.status !== "locked" && existing.status !== "open") {
1104
+ throw new ApiError(`round ${input.roundId} cannot settle (status=${existing.status})`, {
1105
+ status: 409,
1106
+ code: "conflict"
1107
+ });
1108
+ }
1109
+ const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1110
+ wallet,
1111
+ // Winner-take-all mock shape when ranking only.
1112
+ amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1113
+ }));
1114
+ const settleTxHash = "0x000000000000000000000000000000000000000000000000000000000000set1";
1115
+ const poolPaid = existing.pool ?? existing.entryAmount;
1116
+ const settled = {
1117
+ ...existing,
1118
+ status: "settled",
1119
+ settleTxHash,
1120
+ pool: poolPaid
1121
+ };
1122
+ this.mockRounds.set(input.roundId, settled);
1123
+ return {
1124
+ roundId: input.roundId,
1125
+ txHash: settleTxHash,
1126
+ poolPaid,
1127
+ winners,
1128
+ status: "settled"
1129
+ };
1130
+ }
725
1131
  const { settle } = await this.http.post(
726
1132
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
727
1133
  { gameId: input.gameId, results: input.results }
@@ -730,6 +1136,13 @@ var Playmos = class {
730
1136
  },
731
1137
  get: async (input) => {
732
1138
  requireField(input?.roundId, "roundId");
1139
+ if (this.config.mock) {
1140
+ const existing = this.mockRounds.get(input.roundId);
1141
+ if (!existing) {
1142
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1143
+ }
1144
+ return existing;
1145
+ }
733
1146
  const { round } = await this.http.get(
734
1147
  `/rounds/${encodeURIComponent(input.roundId)}`
735
1148
  );
@@ -1014,6 +1427,11 @@ var Playmos = class {
1014
1427
  return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
1015
1428
  }
1016
1429
  };
1430
+ /**
1431
+ * Offline mock payments for this client instance — so `verify(id)` after
1432
+ * `mock: true` pay/enterRound does not hit the live API (#204).
1433
+ */
1434
+ this.mockPayments = /* @__PURE__ */ new Map();
1017
1435
  /**
1018
1436
  * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
1019
1437
  * `get` reconciles once against chain truth; `wait` polls it to a terminal state
@@ -1063,6 +1481,9 @@ var Playmos = class {
1063
1481
  }
1064
1482
  /** Connect the player's wallet and return their address. */
1065
1483
  async connect() {
1484
+ if (this.config.mock) {
1485
+ return "0x0000000000000000000000000000000000000001";
1486
+ }
1066
1487
  return getAccount(resolveProvider(this.config.wallet));
1067
1488
  }
1068
1489
  /**
@@ -1079,18 +1500,30 @@ var Playmos = class {
1079
1500
  payEntry: async (req) => {
1080
1501
  const entry = await this.enterRound({
1081
1502
  gameId: cfg.gameId,
1503
+ // Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
1082
1504
  roundId: req.roundKey,
1083
- // the round the server verifies against
1084
1505
  roundKey: req.roundKey,
1085
1506
  identity: req.identity,
1086
1507
  amount: formatMicroToUsd(req.entryUnits),
1087
1508
  playerId: req.wallet
1088
1509
  });
1089
- const status = entry.status === "confirmed" ? "CONFIRMED" : entry.status === "failed" ? "FAILED" : "PENDING";
1090
- return { paymentId: entry.id, status, txHash: entry.txHash, onchain: true, identity: entry.identity };
1510
+ const raw = String(entry.status ?? "pending").toLowerCase();
1511
+ const status = raw === "confirmed" ? "confirmed" : raw === "failed" ? "failed" : "pending";
1512
+ const onchain = !entry.mock;
1513
+ return {
1514
+ paymentId: entry.id,
1515
+ status,
1516
+ ...entry.txHash ? { txHash: entry.txHash } : {},
1517
+ onchain,
1518
+ identity: entry.identity ?? req.identity
1519
+ };
1091
1520
  }
1092
1521
  };
1093
1522
  }
1523
+ rememberMock(payment) {
1524
+ if (payment.mock) this.mockPayments.set(payment.id, payment);
1525
+ return payment;
1526
+ }
1094
1527
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
1095
1528
  async pay(input) {
1096
1529
  const amountMicro = validateAmount(input.amount);
@@ -1099,14 +1532,16 @@ var Playmos = class {
1099
1532
  const metadata = validateMetadata(input.metadata);
1100
1533
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1101
1534
  if (this.config.mock) {
1102
- return mockIapPayment({
1103
- amountMicro,
1104
- feeBps: IAP_FEE_BPS,
1105
- sku: input.sku,
1106
- playerId: input.playerId,
1107
- chain: this.env.network,
1108
- metadata
1109
- });
1535
+ return this.rememberMock(
1536
+ mockIapPayment({
1537
+ amountMicro,
1538
+ feeBps: IAP_FEE_BPS,
1539
+ sku: input.sku,
1540
+ playerId: input.playerId,
1541
+ chain: this.env.network,
1542
+ metadata
1543
+ })
1544
+ );
1110
1545
  }
1111
1546
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1112
1547
  const res = await this.http.post(
@@ -1149,17 +1584,22 @@ var Playmos = class {
1149
1584
  const metadata = validateMetadata(input.metadata);
1150
1585
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1151
1586
  if (this.config.mock) {
1152
- return mockEntryPayment({
1153
- amountMicro,
1154
- poolBps: POOL_BPS,
1155
- seedBps: SEED_BPS,
1156
- rakeBps: RAKE_BPS,
1157
- gameId: input.gameId,
1158
- roundId: input.roundId,
1159
- playerId: input.playerId,
1160
- chain: this.env.network,
1161
- metadata
1162
- });
1587
+ return this.rememberMock(
1588
+ mockEntryPayment({
1589
+ amountMicro,
1590
+ poolBps: POOL_BPS,
1591
+ seedBps: SEED_BPS,
1592
+ rakeBps: RAKE_BPS,
1593
+ gameId: input.gameId,
1594
+ roundId: input.roundId,
1595
+ playerId: input.playerId,
1596
+ chain: this.env.network,
1597
+ metadata,
1598
+ // B2 pins — must survive mock path for hub hasEntered parity (#204).
1599
+ roundKey: input.roundKey,
1600
+ identity: input.identity
1601
+ })
1602
+ );
1163
1603
  }
1164
1604
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1165
1605
  const res = await this.http.post(
@@ -1171,7 +1611,7 @@ var Playmos = class {
1171
1611
  if (input.identity) payment2.identity = input.identity;
1172
1612
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1173
1613
  if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1174
- payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1614
+ payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1175
1615
  return payment2;
1176
1616
  }
1177
1617
  const intent = await this.http.post(
@@ -1266,9 +1706,41 @@ var Playmos = class {
1266
1706
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1267
1707
  async verify(paymentId) {
1268
1708
  requireField(paymentId, "paymentId");
1709
+ if (this.config.mock) {
1710
+ const cached = this.mockPayments.get(paymentId);
1711
+ if (!cached) {
1712
+ throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
1713
+ }
1714
+ const m = mockVerifyResult(cached);
1715
+ return {
1716
+ id: m.id,
1717
+ status: m.status,
1718
+ amount: m.amount,
1719
+ fee: m.fee,
1720
+ net: m.net,
1721
+ txHash: m.txHash,
1722
+ playerId: m.playerId ?? "",
1723
+ sku: m.sku,
1724
+ roundId: m.roundId,
1725
+ chain: m.chain,
1726
+ verifiedVia: "cache"
1727
+ };
1728
+ }
1269
1729
  return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
1270
1730
  }
1271
1731
  // ---- internals ---------------------------------------------------------
1732
+ /**
1733
+ * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1734
+ * network call so mainnet / live keys never silently hit the adapter.
1735
+ */
1736
+ assertX402Allowed() {
1737
+ if (!this.env.isTest || this.env.network !== "base-sepolia") {
1738
+ throw new ConfigError(
1739
+ 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1740
+ { network: this.env.network, isTest: this.env.isTest }
1741
+ );
1742
+ }
1743
+ }
1272
1744
  /**
1273
1745
  * Map a server-settled payment (from the `settle: "server"` response) into the
1274
1746
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
@@ -1449,10 +1921,10 @@ var PayoutError = class extends Error {
1449
1921
  this.name = "PayoutError";
1450
1922
  }
1451
1923
  };
1452
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1924
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1453
1925
  var BPS = 10000n;
1454
- function requireAddress(w, i) {
1455
- if (!ADDRESS_RE2.test(w)) {
1926
+ function requireAddress2(w, i) {
1927
+ if (!ADDRESS_RE3.test(w)) {
1456
1928
  throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1457
1929
  }
1458
1930
  return w.toLowerCase();
@@ -1475,7 +1947,7 @@ function computePayout(pool, ranking, rule) {
1475
1947
  if (!Array.isArray(ranking) || ranking.length === 0) {
1476
1948
  throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1477
1949
  }
1478
- const wallets = ranking.map((w, i) => requireAddress(w, i));
1950
+ const wallets = ranking.map((w, i) => requireAddress2(w, i));
1479
1951
  const seen = /* @__PURE__ */ new Set();
1480
1952
  for (const w of wallets) {
1481
1953
  if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
@@ -1548,13 +2020,13 @@ function computePayout(pool, ranking, rule) {
1548
2020
  }
1549
2021
 
1550
2022
  // src/settlement.ts
1551
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
2023
+ var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
1552
2024
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1553
- function requireAddress2(value, field) {
2025
+ function requireAddress3(value, field) {
1554
2026
  if (typeof value !== "string" || value.trim() === "") {
1555
2027
  throw new MissingFieldError(field);
1556
2028
  }
1557
- if (!ADDRESS_RE3.test(value)) {
2029
+ if (!ADDRESS_RE4.test(value)) {
1558
2030
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1559
2031
  field,
1560
2032
  value
@@ -1571,7 +2043,7 @@ function requireNetwork(value) {
1571
2043
  }
1572
2044
  function createPaymentRequirement(input) {
1573
2045
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1574
- const payTo = requireAddress2(input.payTo, "payTo");
2046
+ const payTo = requireAddress3(input.payTo, "payTo");
1575
2047
  const network = requireNetwork(input.network);
1576
2048
  const asset = input.asset ?? "USDC";
1577
2049
  if (asset !== "USDC") {
@@ -1614,7 +2086,7 @@ function parsePaymentRequirement(input) {
1614
2086
  asset: o.asset
1615
2087
  });
1616
2088
  }
1617
- const payTo = requireAddress2(o.payTo, "payTo");
2089
+ const payTo = requireAddress3(o.payTo, "payTo");
1618
2090
  const network = requireNetwork(o.network);
1619
2091
  if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1620
2092
  parseUsdToMicro(o.amount);
@@ -1661,9 +2133,13 @@ exports.computeIapSplit = computeIapSplit;
1661
2133
  exports.computePayout = computePayout;
1662
2134
  exports.computePoolSplit = computePoolSplit;
1663
2135
  exports.createPaymentRequirement = createPaymentRequirement;
2136
+ exports.createX402Challenge = createX402Challenge;
2137
+ exports.decodePaymentHeader = decodePaymentHeader;
2138
+ exports.encodePaymentHeader = encodePaymentHeader;
1664
2139
  exports.formatMicroToUsd = formatMicroToUsd;
1665
2140
  exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
1666
2141
  exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
2142
+ exports.networkToCaip2 = networkToCaip2;
1667
2143
  exports.parsePaymentRequirement = parsePaymentRequirement;
1668
2144
  exports.parseUsdToMicro = parseUsdToMicro;
1669
2145
  exports.prefixedId = prefixedId;
@@ -1673,6 +2149,8 @@ exports.previewMarketplaceSplit = previewMarketplaceSplit;
1673
2149
  exports.previewPoolSplit = previewPoolSplit;
1674
2150
  exports.previewTransferSplit = previewTransferSplit;
1675
2151
  exports.serializePaymentRequirement = serializePaymentRequirement;
2152
+ exports.toX402PaymentRequired = toX402PaymentRequired;
1676
2153
  exports.ulid = ulid;
2154
+ exports.validateX402ChallengeInput = validateX402ChallengeInput;
1677
2155
  //# sourceMappingURL=index.cjs.map
1678
2156
  //# sourceMappingURL=index.cjs.map