@playmos/sdk 0.3.0 → 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.cjs CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  var viem = require('viem');
4
4
 
5
+ // src/client.ts
6
+
5
7
  // src/errors.ts
6
8
  var PlaymosError = class extends Error {
7
9
  constructor(code, message, detail) {
@@ -60,6 +62,15 @@ var ConfigError = class extends PlaymosError {
60
62
  super("config", message, detail);
61
63
  }
62
64
  };
65
+ var NothingToWithdrawError = class extends PlaymosError {
66
+ constructor(detail) {
67
+ super(
68
+ "nothing_to_withdraw",
69
+ "Nothing to withdraw \u2014 this wallet has no credited prize balance on this PrizePool (round not settled for them, or already claimed).",
70
+ detail
71
+ );
72
+ }
73
+ };
63
74
 
64
75
  // src/config.ts
65
76
  var CHAIN_ID = {
@@ -264,15 +275,36 @@ function createHttpClient(baseUrl, apiKey, retry) {
264
275
  );
265
276
  }
266
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
+ }
267
299
  async function sendWithRetry(url, init) {
268
300
  let res = await send(url, init);
269
- for (let attempt = 0; res.status === 429 && attempt < cfg.maxRetries; attempt++) {
301
+ for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
270
302
  await sleep(backoffMs(res, attempt, cfg));
271
303
  res = await send(url, init);
272
304
  }
273
305
  return res;
274
306
  }
275
- async function handle(res) {
307
+ async function handle(res, acceptStatuses) {
276
308
  let text;
277
309
  try {
278
310
  text = await res.text();
@@ -287,9 +319,13 @@ function createHttpClient(baseUrl, apiKey, retry) {
287
319
  try {
288
320
  json = text ? JSON.parse(text) : {};
289
321
  } catch {
290
- 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;
291
328
  }
292
- if (res.ok) return json;
293
329
  const errBody = json;
294
330
  const message = errBody?.error?.message ?? `Request failed with ${res.status}`;
295
331
  if (res.status === 401 || res.status === 403 || errBody?.error?.code === "auth") {
@@ -305,7 +341,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
305
341
  };
306
342
  if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
307
343
  const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
308
- return handle(res);
344
+ return handle(res, opts?.acceptStatuses);
309
345
  },
310
346
  async get(path) {
311
347
  const res = await sendWithRetry(`${base}${path}`, {
@@ -462,6 +498,13 @@ var prizePoolAbi = [
462
498
  ],
463
499
  outputs: [{ type: "bool" }]
464
500
  },
501
+ {
502
+ type: "function",
503
+ name: "withdrawable",
504
+ stateMutability: "view",
505
+ inputs: [{ name: "account", type: "address" }],
506
+ outputs: [{ type: "uint256" }]
507
+ },
465
508
  {
466
509
  type: "function",
467
510
  name: "withdraw",
@@ -514,7 +557,23 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
514
557
  function encodeApprove(spender, amountUnits) {
515
558
  return viem.encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
516
559
  }
517
- 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
+ }
518
577
  let balance;
519
578
  try {
520
579
  const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
@@ -522,8 +581,11 @@ async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
522
581
  } catch {
523
582
  return;
524
583
  }
525
- if (balance < minWei) {
526
- 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
+ });
527
589
  }
528
590
  }
529
591
  var toBytes32 = (s) => viem.keccak256(viem.toBytes(s));
@@ -549,6 +611,14 @@ function buildEntryCalls(args) {
549
611
  { to: args.prizePool, data: enterData }
550
612
  ];
551
613
  }
614
+ function buildWithdrawCall(prizePool) {
615
+ const data = viem.encodeFunctionData({
616
+ abi: prizePoolAbi,
617
+ functionName: "withdraw",
618
+ args: []
619
+ });
620
+ return { to: prizePool, data };
621
+ }
552
622
 
553
623
  // src/mock.ts
554
624
  var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
@@ -577,6 +647,7 @@ function mockEntryPayment(args) {
577
647
  args.seedBps,
578
648
  args.rakeBps
579
649
  );
650
+ const roundKey = args.roundKey && args.roundKey.trim() !== "" ? args.roundKey : args.roundId;
580
651
  return {
581
652
  id: prefixedId("entry"),
582
653
  status: "confirmed",
@@ -593,6 +664,10 @@ function mockEntryPayment(args) {
593
664
  },
594
665
  gameId: args.gameId,
595
666
  roundId: args.roundId,
667
+ roundKey,
668
+ identity: args.identity,
669
+ // Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
670
+ prizePoolAddress: "0x0000000000000000000000000000000000000001",
596
671
  playerId: args.playerId,
597
672
  txHash: MOCK_TX,
598
673
  chain: args.chain,
@@ -601,14 +676,160 @@ function mockEntryPayment(args) {
601
676
  mock: true
602
677
  };
603
678
  }
679
+ function mockVerifyResult(payment) {
680
+ return { ...payment, mock: true };
681
+ }
604
682
 
605
- // src/client.ts
683
+ // src/x402.ts
606
684
  var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
607
- function requireAddressField(value, field) {
685
+ function requireAddress(value, field) {
608
686
  if (typeof value !== "string" || value.trim() === "") {
609
687
  throw new MissingFieldError(field);
610
688
  }
611
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)) {
612
833
  throw new ConfigError(
613
834
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
614
835
  { field, value }
@@ -653,6 +874,122 @@ var Playmos = class {
653
874
  /** Create a Bridge KYC onboarding link (fiat payout). */
654
875
  createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
655
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
+ };
656
993
  /**
657
994
  * Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
658
995
  *
@@ -705,6 +1042,112 @@ var Playmos = class {
705
1042
  `/rounds/${encodeURIComponent(input.roundId)}`
706
1043
  );
707
1044
  return round;
1045
+ },
1046
+ /**
1047
+ * Read a wallet's **claimable** prize balance for a round (issue #41).
1048
+ * Prefers the service chain read (`GET /v1/rounds/:id/prize?wallet=`); falls
1049
+ * back to a direct eth_call when `prizePoolAddress` is supplied and no wallet
1050
+ * connector is needed for the read path… actually uses service first, then
1051
+ * on-chain via the player's provider if the service is unavailable.
1052
+ */
1053
+ prize: async (input) => {
1054
+ requireField(input?.roundId, "roundId");
1055
+ const wallet = requireAddressField(input?.wallet, "wallet");
1056
+ try {
1057
+ const res = await this.http.get(
1058
+ `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
1059
+ );
1060
+ return res.prize;
1061
+ } catch (e) {
1062
+ if (!input.prizePoolAddress && !(e instanceof ApiError)) throw e;
1063
+ if (!input.prizePoolAddress) throw e;
1064
+ }
1065
+ const prizePoolAddress = requireAddressField(input.prizePoolAddress, "prizePoolAddress");
1066
+ const claimableMicro = await this.readWithdrawable(prizePoolAddress, wallet);
1067
+ return {
1068
+ roundId: input.roundId,
1069
+ prizePoolAddress,
1070
+ wallet,
1071
+ claimable: formatMicroToUsd(claimableMicro),
1072
+ claimableMicro: claimableMicro.toString()
1073
+ };
1074
+ },
1075
+ /**
1076
+ * Winner **claim** — call PrizePool.withdraw() from the player's wallet (issue #41).
1077
+ * Pull-payment: credits from settle live in `withdrawable[msg.sender]`. No approve needed.
1078
+ * Pass either `prizePoolAddress` (from enterRound / rounds.get) or `roundId` (service resolves).
1079
+ */
1080
+ withdraw: async (input) => {
1081
+ if (this.config.mock) {
1082
+ return {
1083
+ prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
1084
+ amount: "0.00",
1085
+ amountMicro: "0",
1086
+ txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1087
+ status: "confirmed"
1088
+ };
1089
+ }
1090
+ let prizePool = input.prizePoolAddress ? requireAddressField(input.prizePoolAddress, "prizePoolAddress") : void 0;
1091
+ if (!prizePool) {
1092
+ requireField(input?.roundId, "roundId");
1093
+ const round = await this.rounds.get({ roundId: input.roundId });
1094
+ if (!round.prizePoolAddress) {
1095
+ throw new ConfigError(
1096
+ "rounds.get did not return prizePoolAddress \u2014 pass prizePoolAddress from enterRound or configure contracts.prizePool"
1097
+ );
1098
+ }
1099
+ prizePool = round.prizePoolAddress;
1100
+ }
1101
+ const provider = resolveProvider(this.config.wallet);
1102
+ if (this.config.gas?.mode === "player") {
1103
+ const from0 = await getAccount(provider);
1104
+ await assertEnoughGas(provider, from0);
1105
+ }
1106
+ const from = await getAccount(provider);
1107
+ let amountMicro = 0n;
1108
+ if (input.checkBalance !== false) {
1109
+ amountMicro = await this.readWithdrawable(prizePool, from);
1110
+ if (amountMicro === 0n) {
1111
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from });
1112
+ }
1113
+ }
1114
+ const call = buildWithdrawCall(prizePool);
1115
+ const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1116
+ let txHash;
1117
+ let status = "pending";
1118
+ try {
1119
+ const { id: callsId } = await sendCalls(
1120
+ provider,
1121
+ from,
1122
+ this.env.chainId,
1123
+ [call],
1124
+ paymasterUrl
1125
+ );
1126
+ const waited = await waitForCalls(provider, callsId);
1127
+ txHash = waited.txHash;
1128
+ status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1129
+ } catch (e) {
1130
+ const msg = e?.message ?? String(e);
1131
+ if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1132
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
1133
+ }
1134
+ if (e instanceof NothingToWithdrawError || e instanceof PaymentFailedError) throw e;
1135
+ throw new PaymentFailedError("PrizePool.withdraw() failed.", { cause: msg });
1136
+ }
1137
+ if (status === "failed") {
1138
+ throw new PaymentFailedError("PrizePool.withdraw() did not confirm on-chain.", {
1139
+ prizePoolAddress: prizePool,
1140
+ txHash
1141
+ });
1142
+ }
1143
+ if (amountMicro === 0n && input.checkBalance === false) ;
1144
+ return {
1145
+ prizePoolAddress: prizePool,
1146
+ amount: formatMicroToUsd(amountMicro),
1147
+ amountMicro: amountMicro.toString(),
1148
+ txHash,
1149
+ status
1150
+ };
708
1151
  }
709
1152
  };
710
1153
  /**
@@ -879,6 +1322,11 @@ var Playmos = class {
879
1322
  return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
880
1323
  }
881
1324
  };
1325
+ /**
1326
+ * Offline mock payments for this client instance — so `verify(id)` after
1327
+ * `mock: true` pay/enterRound does not hit the live API (#204).
1328
+ */
1329
+ this.mockPayments = /* @__PURE__ */ new Map();
882
1330
  /**
883
1331
  * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
884
1332
  * `get` reconciles once against chain truth; `wait` polls it to a terminal state
@@ -928,6 +1376,9 @@ var Playmos = class {
928
1376
  }
929
1377
  /** Connect the player's wallet and return their address. */
930
1378
  async connect() {
1379
+ if (this.config.mock) {
1380
+ return "0x0000000000000000000000000000000000000001";
1381
+ }
931
1382
  return getAccount(resolveProvider(this.config.wallet));
932
1383
  }
933
1384
  /**
@@ -944,18 +1395,29 @@ var Playmos = class {
944
1395
  payEntry: async (req) => {
945
1396
  const entry = await this.enterRound({
946
1397
  gameId: cfg.gameId,
1398
+ // Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
947
1399
  roundId: req.roundKey,
948
- // the round the server verifies against
949
1400
  roundKey: req.roundKey,
950
1401
  identity: req.identity,
951
1402
  amount: formatMicroToUsd(req.entryUnits),
952
1403
  playerId: req.wallet
953
1404
  });
954
1405
  const status = entry.status === "confirmed" ? "CONFIRMED" : entry.status === "failed" ? "FAILED" : "PENDING";
955
- return { paymentId: entry.id, status, txHash: entry.txHash, onchain: true, identity: entry.identity };
1406
+ const onchain = !entry.mock;
1407
+ return {
1408
+ paymentId: entry.id,
1409
+ status,
1410
+ txHash: entry.txHash,
1411
+ onchain,
1412
+ identity: entry.identity ?? req.identity
1413
+ };
956
1414
  }
957
1415
  };
958
1416
  }
1417
+ rememberMock(payment) {
1418
+ if (payment.mock) this.mockPayments.set(payment.id, payment);
1419
+ return payment;
1420
+ }
959
1421
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
960
1422
  async pay(input) {
961
1423
  const amountMicro = validateAmount(input.amount);
@@ -964,14 +1426,16 @@ var Playmos = class {
964
1426
  const metadata = validateMetadata(input.metadata);
965
1427
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
966
1428
  if (this.config.mock) {
967
- return mockIapPayment({
968
- amountMicro,
969
- feeBps: IAP_FEE_BPS,
970
- sku: input.sku,
971
- playerId: input.playerId,
972
- chain: this.env.network,
973
- metadata
974
- });
1429
+ return this.rememberMock(
1430
+ mockIapPayment({
1431
+ amountMicro,
1432
+ feeBps: IAP_FEE_BPS,
1433
+ sku: input.sku,
1434
+ playerId: input.playerId,
1435
+ chain: this.env.network,
1436
+ metadata
1437
+ })
1438
+ );
975
1439
  }
976
1440
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
977
1441
  const res = await this.http.post(
@@ -1014,17 +1478,22 @@ var Playmos = class {
1014
1478
  const metadata = validateMetadata(input.metadata);
1015
1479
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1016
1480
  if (this.config.mock) {
1017
- return mockEntryPayment({
1018
- amountMicro,
1019
- poolBps: POOL_BPS,
1020
- seedBps: SEED_BPS,
1021
- rakeBps: RAKE_BPS,
1022
- gameId: input.gameId,
1023
- roundId: input.roundId,
1024
- playerId: input.playerId,
1025
- chain: this.env.network,
1026
- metadata
1027
- });
1481
+ return this.rememberMock(
1482
+ mockEntryPayment({
1483
+ amountMicro,
1484
+ poolBps: POOL_BPS,
1485
+ seedBps: SEED_BPS,
1486
+ rakeBps: RAKE_BPS,
1487
+ gameId: input.gameId,
1488
+ roundId: input.roundId,
1489
+ playerId: input.playerId,
1490
+ chain: this.env.network,
1491
+ metadata,
1492
+ // B2 pins — must survive mock path for hub hasEntered parity (#204).
1493
+ roundKey: input.roundKey,
1494
+ identity: input.identity
1495
+ })
1496
+ );
1028
1497
  }
1029
1498
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1030
1499
  const res = await this.http.post(
@@ -1034,6 +1503,9 @@ var Playmos = class {
1034
1503
  );
1035
1504
  const payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
1036
1505
  if (input.identity) payment2.identity = input.identity;
1506
+ const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1507
+ if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1508
+ payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1037
1509
  return payment2;
1038
1510
  }
1039
1511
  const intent = await this.http.post(
@@ -1061,6 +1533,8 @@ var Playmos = class {
1061
1533
  const { txHash } = await waitForCalls(provider, callsId);
1062
1534
  const payment = await this.settle(intent.payment.id, txHash);
1063
1535
  payment.identity = identity;
1536
+ payment.prizePoolAddress = prizePool.toLowerCase();
1537
+ payment.roundKey = roundKey;
1064
1538
  return payment;
1065
1539
  }
1066
1540
  /**
@@ -1126,9 +1600,41 @@ var Playmos = class {
1126
1600
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1127
1601
  async verify(paymentId) {
1128
1602
  requireField(paymentId, "paymentId");
1603
+ if (this.config.mock) {
1604
+ const cached = this.mockPayments.get(paymentId);
1605
+ if (!cached) {
1606
+ throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
1607
+ }
1608
+ const m = mockVerifyResult(cached);
1609
+ return {
1610
+ id: m.id,
1611
+ status: m.status,
1612
+ amount: m.amount,
1613
+ fee: m.fee,
1614
+ net: m.net,
1615
+ txHash: m.txHash,
1616
+ playerId: m.playerId ?? "",
1617
+ sku: m.sku,
1618
+ roundId: m.roundId,
1619
+ chain: m.chain,
1620
+ verifiedVia: "cache"
1621
+ };
1622
+ }
1129
1623
  return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
1130
1624
  }
1131
1625
  // ---- internals ---------------------------------------------------------
1626
+ /**
1627
+ * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1628
+ * network call so mainnet / live keys never silently hit the adapter.
1629
+ */
1630
+ assertX402Allowed() {
1631
+ if (!this.env.isTest || this.env.network !== "base-sepolia") {
1632
+ throw new ConfigError(
1633
+ 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1634
+ { network: this.env.network, isTest: this.env.isTest }
1635
+ );
1636
+ }
1637
+ }
1132
1638
  /**
1133
1639
  * Map a server-settled payment (from the `settle: "server"` response) into the
1134
1640
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
@@ -1170,6 +1676,58 @@ var Playmos = class {
1170
1676
  const fromServer = intent.clientParams?.amountUnits ?? intent.clientParams?.amountMicro;
1171
1677
  return fromServer ? BigInt(fromServer) : local;
1172
1678
  }
1679
+ /**
1680
+ * Read PrizePool.withdrawable[wallet] via eth_call (issue #41).
1681
+ * Uses the wallet provider when present; otherwise a public Base Sepolia RPC in test.
1682
+ */
1683
+ async readWithdrawable(prizePool, wallet) {
1684
+ const data = viem.encodeFunctionData({
1685
+ abi: prizePoolAbi,
1686
+ functionName: "withdrawable",
1687
+ args: [wallet]
1688
+ });
1689
+ let raw;
1690
+ try {
1691
+ if (await walletAvailable(this.config.wallet)) {
1692
+ const provider = resolveProvider(this.config.wallet);
1693
+ raw = await provider.request({
1694
+ method: "eth_call",
1695
+ params: [{ to: prizePool, data }, "latest"]
1696
+ });
1697
+ } else {
1698
+ const rpc = this.env.network === "base" ? "https://mainnet.base.org" : "https://sepolia.base.org";
1699
+ const res = await fetch(rpc, {
1700
+ method: "POST",
1701
+ headers: { "content-type": "application/json" },
1702
+ body: JSON.stringify({
1703
+ jsonrpc: "2.0",
1704
+ id: 1,
1705
+ method: "eth_call",
1706
+ params: [{ to: prizePool, data }, "latest"]
1707
+ })
1708
+ });
1709
+ const json = await res.json();
1710
+ if (!json.result) {
1711
+ throw new ApiError(
1712
+ `eth_call withdrawable failed: ${json.error?.message ?? "no result"}`,
1713
+ { prizePool, wallet }
1714
+ );
1715
+ }
1716
+ raw = json.result;
1717
+ }
1718
+ } catch (e) {
1719
+ if (e instanceof ApiError) throw e;
1720
+ throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
1721
+ prizePool,
1722
+ wallet
1723
+ });
1724
+ }
1725
+ return viem.decodeFunctionResult({
1726
+ abi: prizePoolAbi,
1727
+ functionName: "withdrawable",
1728
+ data: raw
1729
+ });
1730
+ }
1173
1731
  sponsorUrl(intent) {
1174
1732
  if (this.config.gas?.mode === "player") return void 0;
1175
1733
  return this.config.gas?.paymasterUrl ?? intent.clientParams?.paymasterUrl;
@@ -1257,10 +1815,10 @@ var PayoutError = class extends Error {
1257
1815
  this.name = "PayoutError";
1258
1816
  }
1259
1817
  };
1260
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1818
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1261
1819
  var BPS = 10000n;
1262
- function requireAddress(w, i) {
1263
- if (!ADDRESS_RE2.test(w)) {
1820
+ function requireAddress2(w, i) {
1821
+ if (!ADDRESS_RE3.test(w)) {
1264
1822
  throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1265
1823
  }
1266
1824
  return w.toLowerCase();
@@ -1283,7 +1841,7 @@ function computePayout(pool, ranking, rule) {
1283
1841
  if (!Array.isArray(ranking) || ranking.length === 0) {
1284
1842
  throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1285
1843
  }
1286
- const wallets = ranking.map((w, i) => requireAddress(w, i));
1844
+ const wallets = ranking.map((w, i) => requireAddress2(w, i));
1287
1845
  const seen = /* @__PURE__ */ new Set();
1288
1846
  for (const w of wallets) {
1289
1847
  if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
@@ -1356,13 +1914,13 @@ function computePayout(pool, ranking, rule) {
1356
1914
  }
1357
1915
 
1358
1916
  // src/settlement.ts
1359
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1917
+ var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
1360
1918
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1361
- function requireAddress2(value, field) {
1919
+ function requireAddress3(value, field) {
1362
1920
  if (typeof value !== "string" || value.trim() === "") {
1363
1921
  throw new MissingFieldError(field);
1364
1922
  }
1365
- if (!ADDRESS_RE3.test(value)) {
1923
+ if (!ADDRESS_RE4.test(value)) {
1366
1924
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1367
1925
  field,
1368
1926
  value
@@ -1379,7 +1937,7 @@ function requireNetwork(value) {
1379
1937
  }
1380
1938
  function createPaymentRequirement(input) {
1381
1939
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1382
- const payTo = requireAddress2(input.payTo, "payTo");
1940
+ const payTo = requireAddress3(input.payTo, "payTo");
1383
1941
  const network = requireNetwork(input.network);
1384
1942
  const asset = input.asset ?? "USDC";
1385
1943
  if (asset !== "USDC") {
@@ -1422,7 +1980,7 @@ function parsePaymentRequirement(input) {
1422
1980
  asset: o.asset
1423
1981
  });
1424
1982
  }
1425
- const payTo = requireAddress2(o.payTo, "payTo");
1983
+ const payTo = requireAddress3(o.payTo, "payTo");
1426
1984
  const network = requireNetwork(o.network);
1427
1985
  if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1428
1986
  parseUsdToMicro(o.amount);
@@ -1457,6 +2015,7 @@ exports.InsufficientGasError = InsufficientGasError;
1457
2015
  exports.InvalidAmountError = InvalidAmountError;
1458
2016
  exports.MICRO_PER_USDC = MICRO_PER_USDC;
1459
2017
  exports.MissingFieldError = MissingFieldError;
2018
+ exports.NothingToWithdrawError = NothingToWithdrawError;
1460
2019
  exports.PaymentFailedError = PaymentFailedError;
1461
2020
  exports.PayoutError = PayoutError;
1462
2021
  exports.Playmos = Playmos;
@@ -1468,9 +2027,13 @@ exports.computeIapSplit = computeIapSplit;
1468
2027
  exports.computePayout = computePayout;
1469
2028
  exports.computePoolSplit = computePoolSplit;
1470
2029
  exports.createPaymentRequirement = createPaymentRequirement;
2030
+ exports.createX402Challenge = createX402Challenge;
2031
+ exports.decodePaymentHeader = decodePaymentHeader;
2032
+ exports.encodePaymentHeader = encodePaymentHeader;
1471
2033
  exports.formatMicroToUsd = formatMicroToUsd;
1472
2034
  exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
1473
2035
  exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
2036
+ exports.networkToCaip2 = networkToCaip2;
1474
2037
  exports.parsePaymentRequirement = parsePaymentRequirement;
1475
2038
  exports.parseUsdToMicro = parseUsdToMicro;
1476
2039
  exports.prefixedId = prefixedId;
@@ -1480,6 +2043,8 @@ exports.previewMarketplaceSplit = previewMarketplaceSplit;
1480
2043
  exports.previewPoolSplit = previewPoolSplit;
1481
2044
  exports.previewTransferSplit = previewTransferSplit;
1482
2045
  exports.serializePaymentRequirement = serializePaymentRequirement;
2046
+ exports.toX402PaymentRequired = toX402PaymentRequired;
1483
2047
  exports.ulid = ulid;
2048
+ exports.validateX402ChallengeInput = validateX402ChallengeInput;
1484
2049
  //# sourceMappingURL=index.cjs.map
1485
2050
  //# sourceMappingURL=index.cjs.map