@playmos/sdk 0.3.6 → 0.3.8

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
@@ -42,11 +42,25 @@ var WalletConnectionError = class extends PlaymosError {
42
42
  super("wallet_connection", message, detail);
43
43
  }
44
44
  };
45
+ var WalletTimeoutError = class extends PlaymosError {
46
+ constructor(message = "The wallet did not respond in time. Ask the player to approve the prompt, or retry.", detail) {
47
+ super("wallet_timeout", message, detail);
48
+ }
49
+ };
45
50
  var PaymentFailedError = class extends PlaymosError {
46
51
  constructor(message = "The on-chain payment did not complete.", detail) {
47
52
  super("payment_failed", message, detail);
48
53
  }
49
54
  };
55
+ var AlreadyEnteredError = class extends PlaymosError {
56
+ constructor(detail) {
57
+ super(
58
+ "already_entered",
59
+ "This identity already entered this round on-chain. For pay-per-play, pass a unique identity per attempt (not just the wallet address).",
60
+ detail
61
+ );
62
+ }
63
+ };
50
64
  var AuthError = class extends PlaymosError {
51
65
  constructor(message = "Invalid or missing API key.", detail) {
52
66
  super("auth", message, detail);
@@ -315,12 +329,66 @@ function createHttpClient(baseUrl, apiKey, retry) {
315
329
  }
316
330
  return false;
317
331
  }
332
+ async function probeSignerBelowFloor() {
333
+ try {
334
+ const res = await send(`${base}/health`, { method: "GET" });
335
+ if (!res.ok) return { below: false };
336
+ const body = await res.json();
337
+ const bal = body?.capabilities?.payments?.signerBalance;
338
+ if (!bal || bal.ok !== false) return { below: false };
339
+ const reason = bal.reason ?? "";
340
+ const fundedFloorMiss = reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth";
341
+ if (!fundedFloorMiss) return { below: false, reason: reason || void 0 };
342
+ return { below: true, reason };
343
+ } catch {
344
+ return { below: false };
345
+ }
346
+ }
318
347
  async function sendWithRetry(url, init) {
319
348
  let res = await send(url, init);
349
+ let signerBelowFloor = false;
350
+ let signerBelowReason;
351
+ let probed = false;
352
+ const maybeProbe = async () => {
353
+ if (probed || signerBelowFloor) return;
354
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
355
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
356
+ if (ct.includes("application/json")) return;
357
+ probed = true;
358
+ const probe = await probeSignerBelowFloor();
359
+ signerBelowFloor = probe.below;
360
+ signerBelowReason = probe.reason;
361
+ };
362
+ const maybePeekJsonSignerLow = async () => {
363
+ if (signerBelowFloor) return;
364
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
365
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
366
+ if (!ct.includes("application/json")) return;
367
+ try {
368
+ const peek = JSON.parse(await res.clone().text());
369
+ const code = peek?.error?.code;
370
+ const reason = peek?.error?.reason;
371
+ if (code === "signer_low_balance" || reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth") {
372
+ signerBelowFloor = true;
373
+ signerBelowReason = reason ?? code;
374
+ }
375
+ } catch {
376
+ }
377
+ };
378
+ const classifySignerBelow = async () => {
379
+ await maybeProbe();
380
+ await maybePeekJsonSignerLow();
381
+ };
382
+ await classifySignerBelow();
320
383
  for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
384
+ if (signerBelowFloor) break;
321
385
  await sleep(backoffMs(res, attempt, cfg));
322
386
  res = await send(url, init);
387
+ await classifySignerBelow();
323
388
  }
389
+ const flagged = res;
390
+ flagged.__playmosSignerBelowFloor = signerBelowFloor;
391
+ if (signerBelowReason) flagged.__playmosSignerBelowReason = signerBelowReason;
324
392
  return res;
325
393
  }
326
394
  async function handle(res, acceptStatuses) {
@@ -339,8 +407,22 @@ function createHttpClient(baseUrl, apiKey, retry) {
339
407
  json = text ? JSON.parse(text) : {};
340
408
  } catch {
341
409
  const gateway = res.status === 502 || res.status === 503 || res.status === 504;
342
- 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}`;
343
- throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
410
+ const below = res.__playmosSignerBelowFloor === true;
411
+ let msg;
412
+ if (gateway && below) {
413
+ const why = res.__playmosSignerBelowReason ?? "low_balance";
414
+ msg = `Sandbox signer below funding floor (${why}) (${res.status} from ${res.url}) \u2014 top up Sepolia USDC (see GET /health capabilities.payments.signerBalance). Do not retry; retry cannot succeed until funded. Cross-ref: issue #205 recurrence \xB7 hub funding #15.`;
415
+ } else if (gateway) {
416
+ msg = `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform edge limit while on-chain work ran. Retry with the same idempotency key after checking chain/service status (safe if settle already landed). Do not assume missing RPC \u2014 sandbox usually has one; prefer shorter async settle paths (BB-GATEB-002 / #422).`;
417
+ } else {
418
+ msg = `Non-JSON response (${res.status}) from ${res.url}`;
419
+ }
420
+ throw new ApiError(msg, {
421
+ status: res.status,
422
+ body: text.slice(0, 500),
423
+ gateway,
424
+ signerBelowFloor: below || void 0
425
+ });
344
426
  }
345
427
  if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
346
428
  return json;
@@ -373,7 +455,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
373
455
  }
374
456
 
375
457
  // src/wallet.ts
376
- function resolveProvider(wallet) {
458
+ var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
459
+ var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
460
+ "wallet_sendCalls",
461
+ "eth_sendTransaction",
462
+ "eth_sendRawTransaction"
463
+ ]);
464
+ var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
465
+ var TIMED_FLAG = "__playmosTimedRequest";
466
+ function resolvePolicy(opts) {
467
+ const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
468
+ const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
469
+ const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
470
+ return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
471
+ }
472
+ function timeoutMsForMethod(method, opts) {
473
+ const p = resolvePolicy(opts);
474
+ if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
475
+ if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
476
+ return p.requestTimeoutMs;
477
+ }
478
+ async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
479
+ if (timeoutMs <= 0) {
480
+ return provider.request(args);
481
+ }
482
+ let timer;
483
+ try {
484
+ return await Promise.race([
485
+ provider.request(args),
486
+ new Promise((_, reject) => {
487
+ timer = setTimeout(() => {
488
+ reject(
489
+ new WalletTimeoutError(
490
+ `Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
491
+ { method: args.method, timeoutMs }
492
+ )
493
+ );
494
+ }, timeoutMs);
495
+ })
496
+ ]);
497
+ } finally {
498
+ if (timer !== void 0) clearTimeout(timer);
499
+ }
500
+ }
501
+ function withTimeoutProvider(provider, opts) {
502
+ const flagged = provider;
503
+ if (flagged[TIMED_FLAG]) return provider;
504
+ const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
505
+ const wrapped = {
506
+ request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
507
+ };
508
+ Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
509
+ return wrapped;
510
+ }
511
+ function resolveRawProvider(wallet) {
377
512
  if (wallet?.provider) return wallet.provider;
378
513
  const injected = globalThis.ethereum;
379
514
  const connector = wallet?.connector ?? "base-account";
@@ -390,18 +525,23 @@ function resolveProvider(wallet) {
390
525
  "base-account connector needs a provider. In the Base App it is injected automatically; elsewhere, create one with @base-org/account and pass it as wallet.provider."
391
526
  );
392
527
  }
393
- async function walletAvailable(wallet) {
528
+ function resolveProvider(wallet, opts) {
529
+ const raw = resolveRawProvider(wallet);
530
+ return withTimeoutProvider(raw, opts);
531
+ }
532
+ async function walletAvailable(wallet, opts) {
394
533
  if (wallet?.provider) return true;
395
534
  let provider;
396
535
  try {
397
- provider = resolveProvider(wallet);
536
+ provider = resolveProvider(wallet, opts);
398
537
  } catch {
399
538
  return false;
400
539
  }
401
540
  try {
402
541
  await getAccount(provider);
403
542
  return true;
404
- } catch {
543
+ } catch (e) {
544
+ if (e instanceof WalletTimeoutError) throw e;
405
545
  return false;
406
546
  }
407
547
  }
@@ -412,6 +552,7 @@ async function getAccount(provider) {
412
552
  if (!addr) throw new Error("no account");
413
553
  return addr;
414
554
  } catch (e) {
555
+ if (e instanceof WalletTimeoutError) throw e;
415
556
  throw new WalletConnectionError("Could not read the player's wallet account.", {
416
557
  cause: e?.message
417
558
  });
@@ -485,6 +626,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
485
626
  try {
486
627
  result = await provider.request({ method: "wallet_sendCalls", params: [params] });
487
628
  } catch (e) {
629
+ if (e instanceof WalletTimeoutError) throw e;
488
630
  const msg = e?.message ?? String(e);
489
631
  if (/reject|denied|cancel|closed/i.test(msg)) {
490
632
  throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
@@ -499,14 +641,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
499
641
  if (!id) return { status: "FAILED" };
500
642
  const deadline = Date.now() + timeoutMs;
501
643
  while (Date.now() < deadline) {
502
- const res = await provider.request({
503
- method: "wallet_getCallsStatus",
504
- params: [id]
505
- });
506
- const s = String(res?.status ?? "").toUpperCase();
507
- const txHash = res?.receipts?.[0]?.transactionHash;
508
- if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
509
- if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
644
+ try {
645
+ const res = await provider.request({
646
+ method: "wallet_getCallsStatus",
647
+ params: [id]
648
+ });
649
+ const s = String(res?.status ?? "").toUpperCase();
650
+ const txHash = res?.receipts?.[0]?.transactionHash;
651
+ if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
652
+ if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
653
+ } catch (e) {
654
+ if (e instanceof WalletTimeoutError) {
655
+ await new Promise((r) => setTimeout(r, 900));
656
+ continue;
657
+ }
658
+ throw e;
659
+ }
510
660
  await new Promise((r) => setTimeout(r, 900));
511
661
  }
512
662
  return { status: "PENDING" };
@@ -782,6 +932,49 @@ function validateX402ChallengeInput(input) {
782
932
 
783
933
  // src/client.ts
784
934
  var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
935
+ function mapAlreadyEntered(e) {
936
+ const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
937
+ const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
938
+ const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
939
+ if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
940
+ throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
941
+ }
942
+ throw e;
943
+ }
944
+ var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
945
+ var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
946
+ function isSoftRoundPollError(e, kind) {
947
+ if (e instanceof AuthError || e instanceof ConfigError) return false;
948
+ if (e instanceof ApiError) {
949
+ const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
950
+ const msg = e.message;
951
+ if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
952
+ return true;
953
+ }
954
+ if (kind === "settle") {
955
+ if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
956
+ return false;
957
+ }
958
+ } else {
959
+ if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
960
+ msg
961
+ )) {
962
+ return false;
963
+ }
964
+ }
965
+ if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
966
+ return true;
967
+ }
968
+ if (status !== void 0 && status >= 400 && status < 500) return false;
969
+ if (status !== void 0 && status >= 500) return true;
970
+ }
971
+ return true;
972
+ }
973
+ function isSoftSettlePollError(e) {
974
+ return isSoftRoundPollError(e, "settle");
975
+ }
976
+ var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
977
+ var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
785
978
  function requireAddressField(value, field) {
786
979
  if (typeof value !== "string" || value.trim() === "") {
787
980
  throw new MissingFieldError(field);
@@ -981,7 +1174,7 @@ var Playmos = class {
981
1174
  this.mockWithdrawable = /* @__PURE__ */ new Map();
982
1175
  this.rounds = {
983
1176
  open: async (input) => {
984
- this.assertSecretKey("playmos.rounds.open");
1177
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
985
1178
  requireField(input?.gameId, "gameId");
986
1179
  requireField(input?.roundId, "roundId");
987
1180
  requireField(input?.entryAmount, "entryAmount");
@@ -1035,7 +1228,7 @@ var Playmos = class {
1035
1228
  return body.round;
1036
1229
  },
1037
1230
  lock: async (input) => {
1038
- this.assertSecretKey("playmos.rounds.lock");
1231
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.lock");
1039
1232
  requireField(input?.roundId, "roundId");
1040
1233
  if (this.config.mock) {
1041
1234
  const existing = this.mockRounds.get(input.roundId);
@@ -1067,7 +1260,7 @@ var Playmos = class {
1067
1260
  return round;
1068
1261
  },
1069
1262
  settle: async (input) => {
1070
- this.assertSecretKey("playmos.rounds.settle");
1263
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.settle");
1071
1264
  requireField(input?.roundId, "roundId");
1072
1265
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1073
1266
  if (this.config.mock) {
@@ -1128,20 +1321,64 @@ var Playmos = class {
1128
1321
  status: "settled"
1129
1322
  };
1130
1323
  }
1131
- const { settle } = await this.http.post(
1324
+ const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
1325
+ const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
1326
+ const body = { gameId: input.gameId, results: input.results };
1327
+ const idempotencyKey = `settle:${input.roundId}`;
1328
+ const postSettle = () => this.http.post(
1132
1329
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1133
- { gameId: input.gameId, results: input.results },
1134
- { acceptStatuses: [202] }
1330
+ body,
1331
+ { acceptStatuses: [200, 202], idempotencyKey }
1135
1332
  );
1136
- return settle;
1333
+ let { settle } = await postSettle();
1334
+ if (settle.status === "settled") return settle;
1335
+ const firstWinners = settle.winners;
1336
+ const firstPoolPaid = settle.poolPaid ?? null;
1337
+ const withFirstExtras = (s) => ({
1338
+ ...s,
1339
+ winners: s.winners ?? firstWinners,
1340
+ poolPaid: s.poolPaid ?? firstPoolPaid
1341
+ });
1342
+ const deadline = Date.now() + timeoutMs;
1343
+ while (Date.now() < deadline && settle.status === "settling") {
1344
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1345
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1346
+ if (Date.now() >= deadline) break;
1347
+ try {
1348
+ const again = await postSettle();
1349
+ settle = withFirstExtras(again.settle);
1350
+ if (settle.status === "settled") return settle;
1351
+ } catch (e) {
1352
+ if (!isSoftSettlePollError(e)) throw e;
1353
+ }
1354
+ try {
1355
+ const round = await this.rounds.get({ roundId: input.roundId });
1356
+ if (round.status === "settled") {
1357
+ return {
1358
+ roundId: input.roundId,
1359
+ txHash: round.settleTxHash ?? settle.txHash ?? null,
1360
+ poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
1361
+ winners: settle.winners ?? firstWinners,
1362
+ status: "settled"
1363
+ };
1364
+ }
1365
+ } catch (e) {
1366
+ if (!isSoftSettlePollError(e)) throw e;
1367
+ }
1368
+ }
1369
+ return withFirstExtras(settle);
1137
1370
  },
1138
1371
  /**
1139
1372
  * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
1140
- * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
1141
- * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1373
+ * and frees the series latch (#346 / #376).
1374
+ *
1375
+ * **Default is submit-only:** may return `status: "cancelling"` after broadcast —
1376
+ * that is **not** success. Only `status: "cancelled"` means the chain is terminal.
1377
+ * Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
1378
+ * if still pending — never invents terminal success).
1142
1379
  */
1143
1380
  cancel: async (input) => {
1144
- this.assertSecretKey("playmos.rounds.cancel");
1381
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
1145
1382
  requireField(input?.roundId, "roundId");
1146
1383
  if (this.config.mock) {
1147
1384
  const existing = this.mockRounds.get(input.roundId);
@@ -1176,18 +1413,57 @@ var Playmos = class {
1176
1413
  round: cancelled
1177
1414
  };
1178
1415
  }
1179
- const body = await this.http.post(
1180
- `/rounds/${encodeURIComponent(input.roundId)}/cancel`,
1181
- { gameId: input.gameId },
1182
- { acceptStatuses: [202] }
1183
- );
1184
- const status = body.status === "cancelling" || body.round?.status === "cancelling" ? "cancelling" : "cancelled";
1185
- return {
1186
- roundId: input.roundId,
1187
- txHash: body.txHash ?? body.round?.cancelTxHash ?? null,
1188
- status,
1189
- round: body.round
1416
+ const bodyPayload = { gameId: input.gameId };
1417
+ const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
1418
+ acceptStatuses: [200, 202]
1419
+ });
1420
+ const toResult = (body, priorTx) => {
1421
+ const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
1422
+ const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
1423
+ const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
1424
+ const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
1425
+ return {
1426
+ roundId: input.roundId,
1427
+ txHash: tx,
1428
+ status,
1429
+ round: body.round
1430
+ };
1190
1431
  };
1432
+ let result = toResult(await postCancel());
1433
+ if (result.status === "cancelled") return result;
1434
+ if (!input.confirm) return result;
1435
+ const timeoutMs = Math.min(
1436
+ Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
1437
+ CANCEL_RECONCILE_TIMEOUT_MS
1438
+ );
1439
+ const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
1440
+ const deadline = Date.now() + timeoutMs;
1441
+ const firstTx = result.txHash;
1442
+ while (Date.now() < deadline && result.status === "cancelling") {
1443
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1444
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1445
+ if (Date.now() >= deadline) break;
1446
+ try {
1447
+ result = toResult(await postCancel(), firstTx);
1448
+ if (result.status === "cancelled") return result;
1449
+ } catch (e) {
1450
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1451
+ }
1452
+ try {
1453
+ const round = await this.rounds.get({ roundId: input.roundId });
1454
+ if (round.status === "cancelled") {
1455
+ return {
1456
+ roundId: input.roundId,
1457
+ txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
1458
+ status: "cancelled",
1459
+ round
1460
+ };
1461
+ }
1462
+ } catch (e) {
1463
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1464
+ }
1465
+ }
1466
+ return { ...result, txHash: result.txHash ?? firstTx ?? null };
1191
1467
  },
1192
1468
  get: async (input) => {
1193
1469
  requireField(input?.roundId, "roundId");
@@ -1338,7 +1614,7 @@ var Playmos = class {
1338
1614
  }
1339
1615
  prizePool = round.prizePoolAddress;
1340
1616
  }
1341
- const provider = resolveProvider(this.config.wallet);
1617
+ const provider = this.walletProvider();
1342
1618
  if (this.config.gas?.mode === "player") {
1343
1619
  const from0 = await getAccount(provider);
1344
1620
  await assertEnoughGas(provider, from0);
@@ -1355,18 +1631,31 @@ var Playmos = class {
1355
1631
  const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1356
1632
  let txHash;
1357
1633
  let status = "pending";
1634
+ let callsId;
1358
1635
  try {
1359
- const { id: callsId } = await sendCalls(
1636
+ const sent = await sendCalls(
1360
1637
  provider,
1361
1638
  from,
1362
1639
  this.env.chainId,
1363
1640
  [call],
1364
1641
  paymasterUrl
1365
1642
  );
1643
+ callsId = sent.id;
1366
1644
  const waited = await waitForCalls(provider, callsId);
1367
1645
  txHash = waited.txHash;
1368
1646
  status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1369
1647
  } catch (e) {
1648
+ if (e instanceof WalletTimeoutError && callsId) {
1649
+ return {
1650
+ prizePoolAddress: prizePool,
1651
+ amount: formatMicroToUsd(amountMicro),
1652
+ amountMicro: amountMicro.toString(),
1653
+ txHash,
1654
+ status: "pending",
1655
+ callsId
1656
+ };
1657
+ }
1658
+ if (e instanceof WalletTimeoutError) throw e;
1370
1659
  const msg = e?.message ?? String(e);
1371
1660
  if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1372
1661
  throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
@@ -1386,7 +1675,8 @@ var Playmos = class {
1386
1675
  amount: formatMicroToUsd(amountMicro),
1387
1676
  amountMicro: amountMicro.toString(),
1388
1677
  txHash,
1389
- status
1678
+ status,
1679
+ ...status === "pending" && callsId ? { callsId } : {}
1390
1680
  };
1391
1681
  }
1392
1682
  };
@@ -1633,13 +1923,36 @@ var Playmos = class {
1633
1923
  }
1634
1924
  }
1635
1925
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
1926
+ if (!config.mock) {
1927
+ try {
1928
+ resolveProvider(config.wallet, this.walletTimeoutPolicy());
1929
+ } catch {
1930
+ }
1931
+ }
1932
+ }
1933
+ /**
1934
+ * Wallet timeout policy (#462 / PR #463 residual).
1935
+ * - connectTimeoutMs → eth_requestAccounts only
1936
+ * - machine RPCs → 20s default
1937
+ * - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
1938
+ */
1939
+ walletTimeoutPolicy() {
1940
+ const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
1941
+ return {
1942
+ connectTimeoutMs: connect,
1943
+ requestTimeoutMs: 2e4,
1944
+ sendCallsTimeoutMs: 0
1945
+ };
1946
+ }
1947
+ walletProvider() {
1948
+ return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
1636
1949
  }
1637
1950
  /** Connect the player's wallet and return their address. */
1638
1951
  async connect() {
1639
1952
  if (this.config.mock) {
1640
1953
  return "0x0000000000000000000000000000000000000001";
1641
1954
  }
1642
- return getAccount(resolveProvider(this.config.wallet));
1955
+ return getAccount(this.walletProvider());
1643
1956
  }
1644
1957
  /**
1645
1958
  * A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
@@ -1699,7 +2012,7 @@ var Playmos = class {
1699
2012
  })
1700
2013
  );
1701
2014
  }
1702
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2015
+ if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
1703
2016
  const res = await this.http.post(
1704
2017
  "/payments",
1705
2018
  {
@@ -1737,7 +2050,7 @@ var Playmos = class {
1737
2050
  },
1738
2051
  { idempotencyKey }
1739
2052
  );
1740
- const provider = resolveProvider(this.config.wallet);
2053
+ const provider = this.walletProvider();
1741
2054
  if (this.config.gas?.mode === "player") {
1742
2055
  const from0 = await getAccount(provider);
1743
2056
  await assertEnoughGas(provider, from0);
@@ -1782,7 +2095,17 @@ var Playmos = class {
1782
2095
  })
1783
2096
  );
1784
2097
  }
1785
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2098
+ const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
2099
+ if (!serverSettleNoWallet) {
2100
+ const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
2101
+ if (!pinned) {
2102
+ throw new ConfigError(
2103
+ 'enterRound: "identity" is required when using a player wallet. Pass the same identity your game server will use for hasEntered / entry confirm. One entry per identity \u2014 use a unique value per paid attempt (pay-per-play). Silent invent caused paid-but-not-admitted failures (#374 / #466). No-wallet sandbox smoke may omit identity (server-settle derives one).',
2104
+ { field: "identity" }
2105
+ );
2106
+ }
2107
+ }
2108
+ if (serverSettleNoWallet) {
1786
2109
  const pinnedRoundKey = input.roundKey ?? input.roundId;
1787
2110
  const res = await this.http.post(
1788
2111
  "/payments",
@@ -1827,7 +2150,7 @@ var Playmos = class {
1827
2150
  },
1828
2151
  { idempotencyKey }
1829
2152
  );
1830
- const provider = resolveProvider(this.config.wallet);
2153
+ const provider = this.walletProvider();
1831
2154
  if (this.config.gas?.mode === "player") {
1832
2155
  const from0 = await getAccount(provider);
1833
2156
  await assertEnoughGas(provider, from0);
@@ -1838,12 +2161,28 @@ var Playmos = class {
1838
2161
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
1839
2162
  const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
1840
2163
  if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
1841
- const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity ?? `${from}#${idempotencyKey}`;
2164
+ const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
2165
+ if (!identity || !String(identity).trim()) {
2166
+ throw new ConfigError(
2167
+ "enterRound: missing on-chain identity after create-intent (pass input.identity).",
2168
+ { field: "identity" }
2169
+ );
2170
+ }
1842
2171
  const amountUnits = this.resolveUnits(intent, amountMicro);
1843
2172
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
1844
- const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
2173
+ let callsId;
2174
+ try {
2175
+ ({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
2176
+ } catch (e) {
2177
+ throw mapAlreadyEntered(e);
2178
+ }
1845
2179
  const { txHash } = await waitForCalls(provider, callsId);
1846
- const payment = await this.settle(intent.payment.id, txHash);
2180
+ let payment;
2181
+ try {
2182
+ payment = await this.settle(intent.payment.id, txHash);
2183
+ } catch (e) {
2184
+ throw mapAlreadyEntered(e);
2185
+ }
1847
2186
  payment.identity = identity;
1848
2187
  payment.prizePoolAddress = prizePool.toLowerCase();
1849
2188
  payment.roundKey = roundKey;
@@ -2040,8 +2379,8 @@ var Playmos = class {
2040
2379
  });
2041
2380
  let raw;
2042
2381
  try {
2043
- if (await walletAvailable(this.config.wallet)) {
2044
- const provider = resolveProvider(this.config.wallet);
2382
+ if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
2383
+ const provider = this.walletProvider();
2045
2384
  raw = await provider.request({
2046
2385
  method: "eth_call",
2047
2386
  params: [{ to: prizePool, data }, "latest"]
@@ -2068,7 +2407,7 @@ var Playmos = class {
2068
2407
  raw = json.result;
2069
2408
  }
2070
2409
  } catch (e) {
2071
- if (e instanceof ApiError) throw e;
2410
+ if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
2072
2411
  throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
2073
2412
  prizePool,
2074
2413
  wallet
@@ -2358,6 +2697,7 @@ function isX402PayloadAuthorization(auth) {
2358
2697
  return auth.kind === "x402-payload";
2359
2698
  }
2360
2699
 
2700
+ exports.AlreadyEnteredError = AlreadyEnteredError;
2361
2701
  exports.ApiError = ApiError;
2362
2702
  exports.AuthError = AuthError;
2363
2703
  exports.CHAIN_ID = CHAIN_ID;
@@ -2375,6 +2715,7 @@ exports.PlaymosError = PlaymosError;
2375
2715
  exports.USDC_ADDRESS = USDC_ADDRESS;
2376
2716
  exports.USDC_DECIMALS = USDC_DECIMALS;
2377
2717
  exports.WalletConnectionError = WalletConnectionError;
2718
+ exports.WalletTimeoutError = WalletTimeoutError;
2378
2719
  exports.clientRuntimeSignals = clientRuntimeSignals;
2379
2720
  exports.computeIapSplit = computeIapSplit;
2380
2721
  exports.computePayout = computePayout;