@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.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
  *
@@ -621,6 +929,8 @@ var Playmos = class {
621
929
  *
622
930
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
623
931
  */
932
+ /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
933
+ this.mockRounds = /* @__PURE__ */ new Map();
624
934
  this.rounds = {
625
935
  open: async (input) => {
626
936
  requireField(input?.gameId, "gameId");
@@ -630,6 +940,34 @@ var Playmos = class {
630
940
  throw new ConfigError("payout rule is required (winner-take-all | top-n | custom)");
631
941
  }
632
942
  validateAmount(input.entryAmount);
943
+ if (this.config.mock) {
944
+ const roundKey = input.roundKey ?? input.roundId;
945
+ const existing = this.mockRounds.get(input.roundId);
946
+ if (existing?.status === "open" || existing?.status === "locked") {
947
+ return existing;
948
+ }
949
+ if (existing?.status === "settled") {
950
+ throw new ApiError(`round ${input.roundId} already settled`, {
951
+ status: 409,
952
+ code: "conflict"
953
+ });
954
+ }
955
+ const round2 = {
956
+ roundId: input.roundId,
957
+ gameId: input.gameId,
958
+ roundKey,
959
+ status: "open",
960
+ entryAmount: input.entryAmount,
961
+ pool: null,
962
+ entrants: 0,
963
+ payout: input.payout,
964
+ closeAt: input.closeAt,
965
+ openTxHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
966
+ prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
967
+ };
968
+ this.mockRounds.set(input.roundId, round2);
969
+ return round2;
970
+ }
633
971
  const { round } = await this.http.post("/rounds", {
634
972
  gameId: input.gameId,
635
973
  roundId: input.roundId,
@@ -643,6 +981,29 @@ var Playmos = class {
643
981
  },
644
982
  lock: async (input) => {
645
983
  requireField(input?.roundId, "roundId");
984
+ if (this.config.mock) {
985
+ const existing = this.mockRounds.get(input.roundId);
986
+ if (!existing) {
987
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
988
+ }
989
+ if (existing.status === "locked" || existing.status === "settled") return existing;
990
+ if (existing.status !== "open") {
991
+ throw new ApiError(`round ${input.roundId} is not open (status=${existing.status})`, {
992
+ status: 409,
993
+ code: "conflict"
994
+ });
995
+ }
996
+ const locked = {
997
+ ...existing,
998
+ status: "locked",
999
+ // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1000
+ pool: existing.pool ?? existing.entryAmount,
1001
+ entrants: existing.entrants ?? 0,
1002
+ lockTxHash: "0x000000000000000000000000000000000000000000000000000000000000loc1"
1003
+ };
1004
+ this.mockRounds.set(input.roundId, locked);
1005
+ return locked;
1006
+ }
646
1007
  const { round } = await this.http.post(
647
1008
  `/rounds/${encodeURIComponent(input.roundId)}/lock`,
648
1009
  { gameId: input.gameId }
@@ -652,6 +1013,51 @@ var Playmos = class {
652
1013
  settle: async (input) => {
653
1014
  requireField(input?.roundId, "roundId");
654
1015
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1016
+ if (this.config.mock) {
1017
+ const existing = this.mockRounds.get(input.roundId);
1018
+ if (!existing) {
1019
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1020
+ }
1021
+ if (existing.status === "settled" && existing.settleTxHash) {
1022
+ return {
1023
+ roundId: input.roundId,
1024
+ txHash: existing.settleTxHash,
1025
+ poolPaid: existing.pool ?? "0",
1026
+ winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1027
+ wallet,
1028
+ amount: existing.pool ?? existing.entryAmount
1029
+ })),
1030
+ status: "settled"
1031
+ };
1032
+ }
1033
+ if (existing.status !== "locked" && existing.status !== "open") {
1034
+ throw new ApiError(`round ${input.roundId} cannot settle (status=${existing.status})`, {
1035
+ status: 409,
1036
+ code: "conflict"
1037
+ });
1038
+ }
1039
+ const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1040
+ wallet,
1041
+ // Winner-take-all mock shape when ranking only.
1042
+ amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1043
+ }));
1044
+ const settleTxHash = "0x000000000000000000000000000000000000000000000000000000000000set1";
1045
+ const poolPaid = existing.pool ?? existing.entryAmount;
1046
+ const settled = {
1047
+ ...existing,
1048
+ status: "settled",
1049
+ settleTxHash,
1050
+ pool: poolPaid
1051
+ };
1052
+ this.mockRounds.set(input.roundId, settled);
1053
+ return {
1054
+ roundId: input.roundId,
1055
+ txHash: settleTxHash,
1056
+ poolPaid,
1057
+ winners,
1058
+ status: "settled"
1059
+ };
1060
+ }
655
1061
  const { settle } = await this.http.post(
656
1062
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
657
1063
  { gameId: input.gameId, results: input.results }
@@ -660,6 +1066,13 @@ var Playmos = class {
660
1066
  },
661
1067
  get: async (input) => {
662
1068
  requireField(input?.roundId, "roundId");
1069
+ if (this.config.mock) {
1070
+ const existing = this.mockRounds.get(input.roundId);
1071
+ if (!existing) {
1072
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1073
+ }
1074
+ return existing;
1075
+ }
663
1076
  const { round } = await this.http.get(
664
1077
  `/rounds/${encodeURIComponent(input.roundId)}`
665
1078
  );
@@ -944,6 +1357,11 @@ var Playmos = class {
944
1357
  return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
945
1358
  }
946
1359
  };
1360
+ /**
1361
+ * Offline mock payments for this client instance — so `verify(id)` after
1362
+ * `mock: true` pay/enterRound does not hit the live API (#204).
1363
+ */
1364
+ this.mockPayments = /* @__PURE__ */ new Map();
947
1365
  /**
948
1366
  * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
949
1367
  * `get` reconciles once against chain truth; `wait` polls it to a terminal state
@@ -993,6 +1411,9 @@ var Playmos = class {
993
1411
  }
994
1412
  /** Connect the player's wallet and return their address. */
995
1413
  async connect() {
1414
+ if (this.config.mock) {
1415
+ return "0x0000000000000000000000000000000000000001";
1416
+ }
996
1417
  return getAccount(resolveProvider(this.config.wallet));
997
1418
  }
998
1419
  /**
@@ -1009,18 +1430,30 @@ var Playmos = class {
1009
1430
  payEntry: async (req) => {
1010
1431
  const entry = await this.enterRound({
1011
1432
  gameId: cfg.gameId,
1433
+ // Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
1012
1434
  roundId: req.roundKey,
1013
- // the round the server verifies against
1014
1435
  roundKey: req.roundKey,
1015
1436
  identity: req.identity,
1016
1437
  amount: formatMicroToUsd(req.entryUnits),
1017
1438
  playerId: req.wallet
1018
1439
  });
1019
- 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 };
1440
+ const raw = String(entry.status ?? "pending").toLowerCase();
1441
+ const status = raw === "confirmed" ? "confirmed" : raw === "failed" ? "failed" : "pending";
1442
+ const onchain = !entry.mock;
1443
+ return {
1444
+ paymentId: entry.id,
1445
+ status,
1446
+ ...entry.txHash ? { txHash: entry.txHash } : {},
1447
+ onchain,
1448
+ identity: entry.identity ?? req.identity
1449
+ };
1021
1450
  }
1022
1451
  };
1023
1452
  }
1453
+ rememberMock(payment) {
1454
+ if (payment.mock) this.mockPayments.set(payment.id, payment);
1455
+ return payment;
1456
+ }
1024
1457
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
1025
1458
  async pay(input) {
1026
1459
  const amountMicro = validateAmount(input.amount);
@@ -1029,14 +1462,16 @@ var Playmos = class {
1029
1462
  const metadata = validateMetadata(input.metadata);
1030
1463
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1031
1464
  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
- });
1465
+ return this.rememberMock(
1466
+ mockIapPayment({
1467
+ amountMicro,
1468
+ feeBps: IAP_FEE_BPS,
1469
+ sku: input.sku,
1470
+ playerId: input.playerId,
1471
+ chain: this.env.network,
1472
+ metadata
1473
+ })
1474
+ );
1040
1475
  }
1041
1476
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1042
1477
  const res = await this.http.post(
@@ -1079,17 +1514,22 @@ var Playmos = class {
1079
1514
  const metadata = validateMetadata(input.metadata);
1080
1515
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1081
1516
  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
- });
1517
+ return this.rememberMock(
1518
+ mockEntryPayment({
1519
+ amountMicro,
1520
+ poolBps: POOL_BPS,
1521
+ seedBps: SEED_BPS,
1522
+ rakeBps: RAKE_BPS,
1523
+ gameId: input.gameId,
1524
+ roundId: input.roundId,
1525
+ playerId: input.playerId,
1526
+ chain: this.env.network,
1527
+ metadata,
1528
+ // B2 pins — must survive mock path for hub hasEntered parity (#204).
1529
+ roundKey: input.roundKey,
1530
+ identity: input.identity
1531
+ })
1532
+ );
1093
1533
  }
1094
1534
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1095
1535
  const res = await this.http.post(
@@ -1101,7 +1541,7 @@ var Playmos = class {
1101
1541
  if (input.identity) payment2.identity = input.identity;
1102
1542
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1103
1543
  if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1104
- payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1544
+ payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1105
1545
  return payment2;
1106
1546
  }
1107
1547
  const intent = await this.http.post(
@@ -1196,9 +1636,41 @@ var Playmos = class {
1196
1636
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1197
1637
  async verify(paymentId) {
1198
1638
  requireField(paymentId, "paymentId");
1639
+ if (this.config.mock) {
1640
+ const cached = this.mockPayments.get(paymentId);
1641
+ if (!cached) {
1642
+ throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
1643
+ }
1644
+ const m = mockVerifyResult(cached);
1645
+ return {
1646
+ id: m.id,
1647
+ status: m.status,
1648
+ amount: m.amount,
1649
+ fee: m.fee,
1650
+ net: m.net,
1651
+ txHash: m.txHash,
1652
+ playerId: m.playerId ?? "",
1653
+ sku: m.sku,
1654
+ roundId: m.roundId,
1655
+ chain: m.chain,
1656
+ verifiedVia: "cache"
1657
+ };
1658
+ }
1199
1659
  return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
1200
1660
  }
1201
1661
  // ---- internals ---------------------------------------------------------
1662
+ /**
1663
+ * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1664
+ * network call so mainnet / live keys never silently hit the adapter.
1665
+ */
1666
+ assertX402Allowed() {
1667
+ if (!this.env.isTest || this.env.network !== "base-sepolia") {
1668
+ throw new ConfigError(
1669
+ 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1670
+ { network: this.env.network, isTest: this.env.isTest }
1671
+ );
1672
+ }
1673
+ }
1202
1674
  /**
1203
1675
  * Map a server-settled payment (from the `settle: "server"` response) into the
1204
1676
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
@@ -1379,10 +1851,10 @@ var PayoutError = class extends Error {
1379
1851
  this.name = "PayoutError";
1380
1852
  }
1381
1853
  };
1382
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1854
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1383
1855
  var BPS = 10000n;
1384
- function requireAddress(w, i) {
1385
- if (!ADDRESS_RE2.test(w)) {
1856
+ function requireAddress2(w, i) {
1857
+ if (!ADDRESS_RE3.test(w)) {
1386
1858
  throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1387
1859
  }
1388
1860
  return w.toLowerCase();
@@ -1405,7 +1877,7 @@ function computePayout(pool, ranking, rule) {
1405
1877
  if (!Array.isArray(ranking) || ranking.length === 0) {
1406
1878
  throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1407
1879
  }
1408
- const wallets = ranking.map((w, i) => requireAddress(w, i));
1880
+ const wallets = ranking.map((w, i) => requireAddress2(w, i));
1409
1881
  const seen = /* @__PURE__ */ new Set();
1410
1882
  for (const w of wallets) {
1411
1883
  if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
@@ -1478,13 +1950,13 @@ function computePayout(pool, ranking, rule) {
1478
1950
  }
1479
1951
 
1480
1952
  // src/settlement.ts
1481
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1953
+ var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
1482
1954
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1483
- function requireAddress2(value, field) {
1955
+ function requireAddress3(value, field) {
1484
1956
  if (typeof value !== "string" || value.trim() === "") {
1485
1957
  throw new MissingFieldError(field);
1486
1958
  }
1487
- if (!ADDRESS_RE3.test(value)) {
1959
+ if (!ADDRESS_RE4.test(value)) {
1488
1960
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1489
1961
  field,
1490
1962
  value
@@ -1501,7 +1973,7 @@ function requireNetwork(value) {
1501
1973
  }
1502
1974
  function createPaymentRequirement(input) {
1503
1975
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1504
- const payTo = requireAddress2(input.payTo, "payTo");
1976
+ const payTo = requireAddress3(input.payTo, "payTo");
1505
1977
  const network = requireNetwork(input.network);
1506
1978
  const asset = input.asset ?? "USDC";
1507
1979
  if (asset !== "USDC") {
@@ -1544,7 +2016,7 @@ function parsePaymentRequirement(input) {
1544
2016
  asset: o.asset
1545
2017
  });
1546
2018
  }
1547
- const payTo = requireAddress2(o.payTo, "payTo");
2019
+ const payTo = requireAddress3(o.payTo, "payTo");
1548
2020
  const network = requireNetwork(o.network);
1549
2021
  if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1550
2022
  parseUsdToMicro(o.amount);
@@ -1570,6 +2042,6 @@ function isX402PayloadAuthorization(auth) {
1570
2042
  return auth.kind === "x402-payload";
1571
2043
  }
1572
2044
 
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 };
2045
+ 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
2046
  //# sourceMappingURL=index.js.map
1575
2047
  //# sourceMappingURL=index.js.map