@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.js CHANGED
@@ -1,6 +1,6 @@
1
- import { InvalidAmountError, ConfigError, ApiError, MissingFieldError, AuthError, WalletConnectionError, InsufficientGasError, PaymentFailedError } from './chunk-B7SHFZYY.js';
2
- export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-B7SHFZYY.js';
3
- import { encodeFunctionData, numberToHex, keccak256, toBytes } from 'viem';
1
+ import { InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError, AuthError } from './chunk-TMBBEGIF.js';
2
+ export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-TMBBEGIF.js';
3
+ import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
4
4
 
5
5
  // src/config.ts
6
6
  var CHAIN_ID = {
@@ -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}`, {
@@ -403,6 +428,13 @@ var prizePoolAbi = [
403
428
  ],
404
429
  outputs: [{ type: "bool" }]
405
430
  },
431
+ {
432
+ type: "function",
433
+ name: "withdrawable",
434
+ stateMutability: "view",
435
+ inputs: [{ name: "account", type: "address" }],
436
+ outputs: [{ type: "uint256" }]
437
+ },
406
438
  {
407
439
  type: "function",
408
440
  name: "withdraw",
@@ -455,7 +487,23 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
455
487
  function encodeApprove(spender, amountUnits) {
456
488
  return encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
457
489
  }
458
- 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
+ }
459
507
  let balance;
460
508
  try {
461
509
  const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
@@ -463,8 +511,11 @@ async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
463
511
  } catch {
464
512
  return;
465
513
  }
466
- if (balance < minWei) {
467
- 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
+ });
468
519
  }
469
520
  }
470
521
  var toBytes32 = (s) => keccak256(toBytes(s));
@@ -490,6 +541,14 @@ function buildEntryCalls(args) {
490
541
  { to: args.prizePool, data: enterData }
491
542
  ];
492
543
  }
544
+ function buildWithdrawCall(prizePool) {
545
+ const data = encodeFunctionData({
546
+ abi: prizePoolAbi,
547
+ functionName: "withdraw",
548
+ args: []
549
+ });
550
+ return { to: prizePool, data };
551
+ }
493
552
 
494
553
  // src/mock.ts
495
554
  var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
@@ -518,6 +577,7 @@ function mockEntryPayment(args) {
518
577
  args.seedBps,
519
578
  args.rakeBps
520
579
  );
580
+ const roundKey = args.roundKey && args.roundKey.trim() !== "" ? args.roundKey : args.roundId;
521
581
  return {
522
582
  id: prefixedId("entry"),
523
583
  status: "confirmed",
@@ -534,6 +594,10 @@ function mockEntryPayment(args) {
534
594
  },
535
595
  gameId: args.gameId,
536
596
  roundId: args.roundId,
597
+ roundKey,
598
+ identity: args.identity,
599
+ // Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
600
+ prizePoolAddress: "0x0000000000000000000000000000000000000001",
537
601
  playerId: args.playerId,
538
602
  txHash: MOCK_TX,
539
603
  chain: args.chain,
@@ -542,14 +606,160 @@ function mockEntryPayment(args) {
542
606
  mock: true
543
607
  };
544
608
  }
609
+ function mockVerifyResult(payment) {
610
+ return { ...payment, mock: true };
611
+ }
545
612
 
546
- // src/client.ts
613
+ // src/x402.ts
547
614
  var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
548
- function requireAddressField(value, field) {
615
+ function requireAddress(value, field) {
549
616
  if (typeof value !== "string" || value.trim() === "") {
550
617
  throw new MissingFieldError(field);
551
618
  }
552
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)) {
553
763
  throw new ConfigError(
554
764
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
555
765
  { field, value }
@@ -594,6 +804,122 @@ var Playmos = class {
594
804
  /** Create a Bridge KYC onboarding link (fiat payout). */
595
805
  createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
596
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
+ };
597
923
  /**
598
924
  * Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
599
925
  *
@@ -646,6 +972,112 @@ var Playmos = class {
646
972
  `/rounds/${encodeURIComponent(input.roundId)}`
647
973
  );
648
974
  return round;
975
+ },
976
+ /**
977
+ * Read a wallet's **claimable** prize balance for a round (issue #41).
978
+ * Prefers the service chain read (`GET /v1/rounds/:id/prize?wallet=`); falls
979
+ * back to a direct eth_call when `prizePoolAddress` is supplied and no wallet
980
+ * connector is needed for the read path… actually uses service first, then
981
+ * on-chain via the player's provider if the service is unavailable.
982
+ */
983
+ prize: async (input) => {
984
+ requireField(input?.roundId, "roundId");
985
+ const wallet = requireAddressField(input?.wallet, "wallet");
986
+ try {
987
+ const res = await this.http.get(
988
+ `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
989
+ );
990
+ return res.prize;
991
+ } catch (e) {
992
+ if (!input.prizePoolAddress && !(e instanceof ApiError)) throw e;
993
+ if (!input.prizePoolAddress) throw e;
994
+ }
995
+ const prizePoolAddress = requireAddressField(input.prizePoolAddress, "prizePoolAddress");
996
+ const claimableMicro = await this.readWithdrawable(prizePoolAddress, wallet);
997
+ return {
998
+ roundId: input.roundId,
999
+ prizePoolAddress,
1000
+ wallet,
1001
+ claimable: formatMicroToUsd(claimableMicro),
1002
+ claimableMicro: claimableMicro.toString()
1003
+ };
1004
+ },
1005
+ /**
1006
+ * Winner **claim** — call PrizePool.withdraw() from the player's wallet (issue #41).
1007
+ * Pull-payment: credits from settle live in `withdrawable[msg.sender]`. No approve needed.
1008
+ * Pass either `prizePoolAddress` (from enterRound / rounds.get) or `roundId` (service resolves).
1009
+ */
1010
+ withdraw: async (input) => {
1011
+ if (this.config.mock) {
1012
+ return {
1013
+ prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
1014
+ amount: "0.00",
1015
+ amountMicro: "0",
1016
+ txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1017
+ status: "confirmed"
1018
+ };
1019
+ }
1020
+ let prizePool = input.prizePoolAddress ? requireAddressField(input.prizePoolAddress, "prizePoolAddress") : void 0;
1021
+ if (!prizePool) {
1022
+ requireField(input?.roundId, "roundId");
1023
+ const round = await this.rounds.get({ roundId: input.roundId });
1024
+ if (!round.prizePoolAddress) {
1025
+ throw new ConfigError(
1026
+ "rounds.get did not return prizePoolAddress \u2014 pass prizePoolAddress from enterRound or configure contracts.prizePool"
1027
+ );
1028
+ }
1029
+ prizePool = round.prizePoolAddress;
1030
+ }
1031
+ const provider = resolveProvider(this.config.wallet);
1032
+ if (this.config.gas?.mode === "player") {
1033
+ const from0 = await getAccount(provider);
1034
+ await assertEnoughGas(provider, from0);
1035
+ }
1036
+ const from = await getAccount(provider);
1037
+ let amountMicro = 0n;
1038
+ if (input.checkBalance !== false) {
1039
+ amountMicro = await this.readWithdrawable(prizePool, from);
1040
+ if (amountMicro === 0n) {
1041
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from });
1042
+ }
1043
+ }
1044
+ const call = buildWithdrawCall(prizePool);
1045
+ const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1046
+ let txHash;
1047
+ let status = "pending";
1048
+ try {
1049
+ const { id: callsId } = await sendCalls(
1050
+ provider,
1051
+ from,
1052
+ this.env.chainId,
1053
+ [call],
1054
+ paymasterUrl
1055
+ );
1056
+ const waited = await waitForCalls(provider, callsId);
1057
+ txHash = waited.txHash;
1058
+ status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1059
+ } catch (e) {
1060
+ const msg = e?.message ?? String(e);
1061
+ if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1062
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
1063
+ }
1064
+ if (e instanceof NothingToWithdrawError || e instanceof PaymentFailedError) throw e;
1065
+ throw new PaymentFailedError("PrizePool.withdraw() failed.", { cause: msg });
1066
+ }
1067
+ if (status === "failed") {
1068
+ throw new PaymentFailedError("PrizePool.withdraw() did not confirm on-chain.", {
1069
+ prizePoolAddress: prizePool,
1070
+ txHash
1071
+ });
1072
+ }
1073
+ if (amountMicro === 0n && input.checkBalance === false) ;
1074
+ return {
1075
+ prizePoolAddress: prizePool,
1076
+ amount: formatMicroToUsd(amountMicro),
1077
+ amountMicro: amountMicro.toString(),
1078
+ txHash,
1079
+ status
1080
+ };
649
1081
  }
650
1082
  };
651
1083
  /**
@@ -820,6 +1252,11 @@ var Playmos = class {
820
1252
  return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
821
1253
  }
822
1254
  };
1255
+ /**
1256
+ * Offline mock payments for this client instance — so `verify(id)` after
1257
+ * `mock: true` pay/enterRound does not hit the live API (#204).
1258
+ */
1259
+ this.mockPayments = /* @__PURE__ */ new Map();
823
1260
  /**
824
1261
  * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
825
1262
  * `get` reconciles once against chain truth; `wait` polls it to a terminal state
@@ -869,6 +1306,9 @@ var Playmos = class {
869
1306
  }
870
1307
  /** Connect the player's wallet and return their address. */
871
1308
  async connect() {
1309
+ if (this.config.mock) {
1310
+ return "0x0000000000000000000000000000000000000001";
1311
+ }
872
1312
  return getAccount(resolveProvider(this.config.wallet));
873
1313
  }
874
1314
  /**
@@ -885,18 +1325,29 @@ var Playmos = class {
885
1325
  payEntry: async (req) => {
886
1326
  const entry = await this.enterRound({
887
1327
  gameId: cfg.gameId,
1328
+ // Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
888
1329
  roundId: req.roundKey,
889
- // the round the server verifies against
890
1330
  roundKey: req.roundKey,
891
1331
  identity: req.identity,
892
1332
  amount: formatMicroToUsd(req.entryUnits),
893
1333
  playerId: req.wallet
894
1334
  });
895
1335
  const status = entry.status === "confirmed" ? "CONFIRMED" : entry.status === "failed" ? "FAILED" : "PENDING";
896
- return { paymentId: entry.id, status, txHash: entry.txHash, onchain: true, identity: entry.identity };
1336
+ const onchain = !entry.mock;
1337
+ return {
1338
+ paymentId: entry.id,
1339
+ status,
1340
+ txHash: entry.txHash,
1341
+ onchain,
1342
+ identity: entry.identity ?? req.identity
1343
+ };
897
1344
  }
898
1345
  };
899
1346
  }
1347
+ rememberMock(payment) {
1348
+ if (payment.mock) this.mockPayments.set(payment.id, payment);
1349
+ return payment;
1350
+ }
900
1351
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
901
1352
  async pay(input) {
902
1353
  const amountMicro = validateAmount(input.amount);
@@ -905,14 +1356,16 @@ var Playmos = class {
905
1356
  const metadata = validateMetadata(input.metadata);
906
1357
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
907
1358
  if (this.config.mock) {
908
- return mockIapPayment({
909
- amountMicro,
910
- feeBps: IAP_FEE_BPS,
911
- sku: input.sku,
912
- playerId: input.playerId,
913
- chain: this.env.network,
914
- metadata
915
- });
1359
+ return this.rememberMock(
1360
+ mockIapPayment({
1361
+ amountMicro,
1362
+ feeBps: IAP_FEE_BPS,
1363
+ sku: input.sku,
1364
+ playerId: input.playerId,
1365
+ chain: this.env.network,
1366
+ metadata
1367
+ })
1368
+ );
916
1369
  }
917
1370
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
918
1371
  const res = await this.http.post(
@@ -955,17 +1408,22 @@ var Playmos = class {
955
1408
  const metadata = validateMetadata(input.metadata);
956
1409
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
957
1410
  if (this.config.mock) {
958
- return mockEntryPayment({
959
- amountMicro,
960
- poolBps: POOL_BPS,
961
- seedBps: SEED_BPS,
962
- rakeBps: RAKE_BPS,
963
- gameId: input.gameId,
964
- roundId: input.roundId,
965
- playerId: input.playerId,
966
- chain: this.env.network,
967
- metadata
968
- });
1411
+ return this.rememberMock(
1412
+ mockEntryPayment({
1413
+ amountMicro,
1414
+ poolBps: POOL_BPS,
1415
+ seedBps: SEED_BPS,
1416
+ rakeBps: RAKE_BPS,
1417
+ gameId: input.gameId,
1418
+ roundId: input.roundId,
1419
+ playerId: input.playerId,
1420
+ chain: this.env.network,
1421
+ metadata,
1422
+ // B2 pins — must survive mock path for hub hasEntered parity (#204).
1423
+ roundKey: input.roundKey,
1424
+ identity: input.identity
1425
+ })
1426
+ );
969
1427
  }
970
1428
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
971
1429
  const res = await this.http.post(
@@ -975,6 +1433,9 @@ var Playmos = class {
975
1433
  );
976
1434
  const payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
977
1435
  if (input.identity) payment2.identity = input.identity;
1436
+ const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1437
+ if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1438
+ payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
978
1439
  return payment2;
979
1440
  }
980
1441
  const intent = await this.http.post(
@@ -1002,6 +1463,8 @@ var Playmos = class {
1002
1463
  const { txHash } = await waitForCalls(provider, callsId);
1003
1464
  const payment = await this.settle(intent.payment.id, txHash);
1004
1465
  payment.identity = identity;
1466
+ payment.prizePoolAddress = prizePool.toLowerCase();
1467
+ payment.roundKey = roundKey;
1005
1468
  return payment;
1006
1469
  }
1007
1470
  /**
@@ -1067,9 +1530,41 @@ var Playmos = class {
1067
1530
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1068
1531
  async verify(paymentId) {
1069
1532
  requireField(paymentId, "paymentId");
1533
+ if (this.config.mock) {
1534
+ const cached = this.mockPayments.get(paymentId);
1535
+ if (!cached) {
1536
+ throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
1537
+ }
1538
+ const m = mockVerifyResult(cached);
1539
+ return {
1540
+ id: m.id,
1541
+ status: m.status,
1542
+ amount: m.amount,
1543
+ fee: m.fee,
1544
+ net: m.net,
1545
+ txHash: m.txHash,
1546
+ playerId: m.playerId ?? "",
1547
+ sku: m.sku,
1548
+ roundId: m.roundId,
1549
+ chain: m.chain,
1550
+ verifiedVia: "cache"
1551
+ };
1552
+ }
1070
1553
  return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
1071
1554
  }
1072
1555
  // ---- internals ---------------------------------------------------------
1556
+ /**
1557
+ * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1558
+ * network call so mainnet / live keys never silently hit the adapter.
1559
+ */
1560
+ assertX402Allowed() {
1561
+ if (!this.env.isTest || this.env.network !== "base-sepolia") {
1562
+ throw new ConfigError(
1563
+ 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1564
+ { network: this.env.network, isTest: this.env.isTest }
1565
+ );
1566
+ }
1567
+ }
1073
1568
  /**
1074
1569
  * Map a server-settled payment (from the `settle: "server"` response) into the
1075
1570
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
@@ -1111,6 +1606,58 @@ var Playmos = class {
1111
1606
  const fromServer = intent.clientParams?.amountUnits ?? intent.clientParams?.amountMicro;
1112
1607
  return fromServer ? BigInt(fromServer) : local;
1113
1608
  }
1609
+ /**
1610
+ * Read PrizePool.withdrawable[wallet] via eth_call (issue #41).
1611
+ * Uses the wallet provider when present; otherwise a public Base Sepolia RPC in test.
1612
+ */
1613
+ async readWithdrawable(prizePool, wallet) {
1614
+ const data = encodeFunctionData({
1615
+ abi: prizePoolAbi,
1616
+ functionName: "withdrawable",
1617
+ args: [wallet]
1618
+ });
1619
+ let raw;
1620
+ try {
1621
+ if (await walletAvailable(this.config.wallet)) {
1622
+ const provider = resolveProvider(this.config.wallet);
1623
+ raw = await provider.request({
1624
+ method: "eth_call",
1625
+ params: [{ to: prizePool, data }, "latest"]
1626
+ });
1627
+ } else {
1628
+ const rpc = this.env.network === "base" ? "https://mainnet.base.org" : "https://sepolia.base.org";
1629
+ const res = await fetch(rpc, {
1630
+ method: "POST",
1631
+ headers: { "content-type": "application/json" },
1632
+ body: JSON.stringify({
1633
+ jsonrpc: "2.0",
1634
+ id: 1,
1635
+ method: "eth_call",
1636
+ params: [{ to: prizePool, data }, "latest"]
1637
+ })
1638
+ });
1639
+ const json = await res.json();
1640
+ if (!json.result) {
1641
+ throw new ApiError(
1642
+ `eth_call withdrawable failed: ${json.error?.message ?? "no result"}`,
1643
+ { prizePool, wallet }
1644
+ );
1645
+ }
1646
+ raw = json.result;
1647
+ }
1648
+ } catch (e) {
1649
+ if (e instanceof ApiError) throw e;
1650
+ throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
1651
+ prizePool,
1652
+ wallet
1653
+ });
1654
+ }
1655
+ return decodeFunctionResult({
1656
+ abi: prizePoolAbi,
1657
+ functionName: "withdrawable",
1658
+ data: raw
1659
+ });
1660
+ }
1114
1661
  sponsorUrl(intent) {
1115
1662
  if (this.config.gas?.mode === "player") return void 0;
1116
1663
  return this.config.gas?.paymasterUrl ?? intent.clientParams?.paymasterUrl;
@@ -1198,10 +1745,10 @@ var PayoutError = class extends Error {
1198
1745
  this.name = "PayoutError";
1199
1746
  }
1200
1747
  };
1201
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1748
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1202
1749
  var BPS = 10000n;
1203
- function requireAddress(w, i) {
1204
- if (!ADDRESS_RE2.test(w)) {
1750
+ function requireAddress2(w, i) {
1751
+ if (!ADDRESS_RE3.test(w)) {
1205
1752
  throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1206
1753
  }
1207
1754
  return w.toLowerCase();
@@ -1224,7 +1771,7 @@ function computePayout(pool, ranking, rule) {
1224
1771
  if (!Array.isArray(ranking) || ranking.length === 0) {
1225
1772
  throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1226
1773
  }
1227
- const wallets = ranking.map((w, i) => requireAddress(w, i));
1774
+ const wallets = ranking.map((w, i) => requireAddress2(w, i));
1228
1775
  const seen = /* @__PURE__ */ new Set();
1229
1776
  for (const w of wallets) {
1230
1777
  if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
@@ -1297,13 +1844,13 @@ function computePayout(pool, ranking, rule) {
1297
1844
  }
1298
1845
 
1299
1846
  // src/settlement.ts
1300
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1847
+ var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
1301
1848
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1302
- function requireAddress2(value, field) {
1849
+ function requireAddress3(value, field) {
1303
1850
  if (typeof value !== "string" || value.trim() === "") {
1304
1851
  throw new MissingFieldError(field);
1305
1852
  }
1306
- if (!ADDRESS_RE3.test(value)) {
1853
+ if (!ADDRESS_RE4.test(value)) {
1307
1854
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1308
1855
  field,
1309
1856
  value
@@ -1320,7 +1867,7 @@ function requireNetwork(value) {
1320
1867
  }
1321
1868
  function createPaymentRequirement(input) {
1322
1869
  const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1323
- const payTo = requireAddress2(input.payTo, "payTo");
1870
+ const payTo = requireAddress3(input.payTo, "payTo");
1324
1871
  const network = requireNetwork(input.network);
1325
1872
  const asset = input.asset ?? "USDC";
1326
1873
  if (asset !== "USDC") {
@@ -1363,7 +1910,7 @@ function parsePaymentRequirement(input) {
1363
1910
  asset: o.asset
1364
1911
  });
1365
1912
  }
1366
- const payTo = requireAddress2(o.payTo, "payTo");
1913
+ const payTo = requireAddress3(o.payTo, "payTo");
1367
1914
  const network = requireNetwork(o.network);
1368
1915
  if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1369
1916
  parseUsdToMicro(o.amount);
@@ -1389,6 +1936,6 @@ function isX402PayloadAuthorization(auth) {
1389
1936
  return auth.kind === "x402-payload";
1390
1937
  }
1391
1938
 
1392
- export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, formatMicroToUsd, isWalletSignatureAuthorization, isX402PayloadAuthorization, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, serializePaymentRequirement, ulid };
1939
+ export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
1393
1940
  //# sourceMappingURL=index.js.map
1394
1941
  //# sourceMappingURL=index.js.map