@playmos/sdk 0.3.7 → 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);
@@ -399,7 +413,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
399
413
  const why = res.__playmosSignerBelowReason ?? "low_balance";
400
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.`;
401
415
  } else if (gateway) {
402
- msg = `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).`;
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).`;
403
417
  } else {
404
418
  msg = `Non-JSON response (${res.status}) from ${res.url}`;
405
419
  }
@@ -441,7 +455,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
441
455
  }
442
456
 
443
457
  // src/wallet.ts
444
- 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) {
445
512
  if (wallet?.provider) return wallet.provider;
446
513
  const injected = globalThis.ethereum;
447
514
  const connector = wallet?.connector ?? "base-account";
@@ -458,18 +525,23 @@ function resolveProvider(wallet) {
458
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."
459
526
  );
460
527
  }
461
- 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) {
462
533
  if (wallet?.provider) return true;
463
534
  let provider;
464
535
  try {
465
- provider = resolveProvider(wallet);
536
+ provider = resolveProvider(wallet, opts);
466
537
  } catch {
467
538
  return false;
468
539
  }
469
540
  try {
470
541
  await getAccount(provider);
471
542
  return true;
472
- } catch {
543
+ } catch (e) {
544
+ if (e instanceof WalletTimeoutError) throw e;
473
545
  return false;
474
546
  }
475
547
  }
@@ -480,6 +552,7 @@ async function getAccount(provider) {
480
552
  if (!addr) throw new Error("no account");
481
553
  return addr;
482
554
  } catch (e) {
555
+ if (e instanceof WalletTimeoutError) throw e;
483
556
  throw new WalletConnectionError("Could not read the player's wallet account.", {
484
557
  cause: e?.message
485
558
  });
@@ -553,6 +626,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
553
626
  try {
554
627
  result = await provider.request({ method: "wallet_sendCalls", params: [params] });
555
628
  } catch (e) {
629
+ if (e instanceof WalletTimeoutError) throw e;
556
630
  const msg = e?.message ?? String(e);
557
631
  if (/reject|denied|cancel|closed/i.test(msg)) {
558
632
  throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
@@ -567,14 +641,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
567
641
  if (!id) return { status: "FAILED" };
568
642
  const deadline = Date.now() + timeoutMs;
569
643
  while (Date.now() < deadline) {
570
- const res = await provider.request({
571
- method: "wallet_getCallsStatus",
572
- params: [id]
573
- });
574
- const s = String(res?.status ?? "").toUpperCase();
575
- const txHash = res?.receipts?.[0]?.transactionHash;
576
- if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
577
- 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
+ }
578
660
  await new Promise((r) => setTimeout(r, 900));
579
661
  }
580
662
  return { status: "PENDING" };
@@ -850,6 +932,49 @@ function validateX402ChallengeInput(input) {
850
932
 
851
933
  // src/client.ts
852
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;
853
978
  function requireAddressField(value, field) {
854
979
  if (typeof value !== "string" || value.trim() === "") {
855
980
  throw new MissingFieldError(field);
@@ -1196,17 +1321,61 @@ var Playmos = class {
1196
1321
  status: "settled"
1197
1322
  };
1198
1323
  }
1199
- 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(
1200
1329
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1201
- { gameId: input.gameId, results: input.results },
1202
- { acceptStatuses: [202] }
1330
+ body,
1331
+ { acceptStatuses: [200, 202], idempotencyKey }
1203
1332
  );
1204
- 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);
1205
1370
  },
1206
1371
  /**
1207
1372
  * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
1208
- * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
1209
- * 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).
1210
1379
  */
1211
1380
  cancel: async (input) => {
1212
1381
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
@@ -1244,18 +1413,57 @@ var Playmos = class {
1244
1413
  round: cancelled
1245
1414
  };
1246
1415
  }
1247
- const body = await this.http.post(
1248
- `/rounds/${encodeURIComponent(input.roundId)}/cancel`,
1249
- { gameId: input.gameId },
1250
- { acceptStatuses: [202] }
1251
- );
1252
- const status = body.status === "cancelling" || body.round?.status === "cancelling" ? "cancelling" : "cancelled";
1253
- return {
1254
- roundId: input.roundId,
1255
- txHash: body.txHash ?? body.round?.cancelTxHash ?? null,
1256
- status,
1257
- 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
+ };
1258
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 };
1259
1467
  },
1260
1468
  get: async (input) => {
1261
1469
  requireField(input?.roundId, "roundId");
@@ -1406,7 +1614,7 @@ var Playmos = class {
1406
1614
  }
1407
1615
  prizePool = round.prizePoolAddress;
1408
1616
  }
1409
- const provider = resolveProvider(this.config.wallet);
1617
+ const provider = this.walletProvider();
1410
1618
  if (this.config.gas?.mode === "player") {
1411
1619
  const from0 = await getAccount(provider);
1412
1620
  await assertEnoughGas(provider, from0);
@@ -1423,18 +1631,31 @@ var Playmos = class {
1423
1631
  const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1424
1632
  let txHash;
1425
1633
  let status = "pending";
1634
+ let callsId;
1426
1635
  try {
1427
- const { id: callsId } = await sendCalls(
1636
+ const sent = await sendCalls(
1428
1637
  provider,
1429
1638
  from,
1430
1639
  this.env.chainId,
1431
1640
  [call],
1432
1641
  paymasterUrl
1433
1642
  );
1643
+ callsId = sent.id;
1434
1644
  const waited = await waitForCalls(provider, callsId);
1435
1645
  txHash = waited.txHash;
1436
1646
  status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1437
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;
1438
1659
  const msg = e?.message ?? String(e);
1439
1660
  if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1440
1661
  throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
@@ -1454,7 +1675,8 @@ var Playmos = class {
1454
1675
  amount: formatMicroToUsd(amountMicro),
1455
1676
  amountMicro: amountMicro.toString(),
1456
1677
  txHash,
1457
- status
1678
+ status,
1679
+ ...status === "pending" && callsId ? { callsId } : {}
1458
1680
  };
1459
1681
  }
1460
1682
  };
@@ -1701,13 +1923,36 @@ var Playmos = class {
1701
1923
  }
1702
1924
  }
1703
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());
1704
1949
  }
1705
1950
  /** Connect the player's wallet and return their address. */
1706
1951
  async connect() {
1707
1952
  if (this.config.mock) {
1708
1953
  return "0x0000000000000000000000000000000000000001";
1709
1954
  }
1710
- return getAccount(resolveProvider(this.config.wallet));
1955
+ return getAccount(this.walletProvider());
1711
1956
  }
1712
1957
  /**
1713
1958
  * A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
@@ -1767,7 +2012,7 @@ var Playmos = class {
1767
2012
  })
1768
2013
  );
1769
2014
  }
1770
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2015
+ if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
1771
2016
  const res = await this.http.post(
1772
2017
  "/payments",
1773
2018
  {
@@ -1805,7 +2050,7 @@ var Playmos = class {
1805
2050
  },
1806
2051
  { idempotencyKey }
1807
2052
  );
1808
- const provider = resolveProvider(this.config.wallet);
2053
+ const provider = this.walletProvider();
1809
2054
  if (this.config.gas?.mode === "player") {
1810
2055
  const from0 = await getAccount(provider);
1811
2056
  await assertEnoughGas(provider, from0);
@@ -1850,7 +2095,17 @@ var Playmos = class {
1850
2095
  })
1851
2096
  );
1852
2097
  }
1853
- 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) {
1854
2109
  const pinnedRoundKey = input.roundKey ?? input.roundId;
1855
2110
  const res = await this.http.post(
1856
2111
  "/payments",
@@ -1895,7 +2150,7 @@ var Playmos = class {
1895
2150
  },
1896
2151
  { idempotencyKey }
1897
2152
  );
1898
- const provider = resolveProvider(this.config.wallet);
2153
+ const provider = this.walletProvider();
1899
2154
  if (this.config.gas?.mode === "player") {
1900
2155
  const from0 = await getAccount(provider);
1901
2156
  await assertEnoughGas(provider, from0);
@@ -1906,12 +2161,28 @@ var Playmos = class {
1906
2161
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
1907
2162
  const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
1908
2163
  if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
1909
- 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
+ }
1910
2171
  const amountUnits = this.resolveUnits(intent, amountMicro);
1911
2172
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
1912
- 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
+ }
1913
2179
  const { txHash } = await waitForCalls(provider, callsId);
1914
- 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
+ }
1915
2186
  payment.identity = identity;
1916
2187
  payment.prizePoolAddress = prizePool.toLowerCase();
1917
2188
  payment.roundKey = roundKey;
@@ -2108,8 +2379,8 @@ var Playmos = class {
2108
2379
  });
2109
2380
  let raw;
2110
2381
  try {
2111
- if (await walletAvailable(this.config.wallet)) {
2112
- const provider = resolveProvider(this.config.wallet);
2382
+ if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
2383
+ const provider = this.walletProvider();
2113
2384
  raw = await provider.request({
2114
2385
  method: "eth_call",
2115
2386
  params: [{ to: prizePool, data }, "latest"]
@@ -2136,7 +2407,7 @@ var Playmos = class {
2136
2407
  raw = json.result;
2137
2408
  }
2138
2409
  } catch (e) {
2139
- if (e instanceof ApiError) throw e;
2410
+ if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
2140
2411
  throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
2141
2412
  prizePool,
2142
2413
  wallet
@@ -2426,6 +2697,7 @@ function isX402PayloadAuthorization(auth) {
2426
2697
  return auth.kind === "x402-payload";
2427
2698
  }
2428
2699
 
2700
+ exports.AlreadyEnteredError = AlreadyEnteredError;
2429
2701
  exports.ApiError = ApiError;
2430
2702
  exports.AuthError = AuthError;
2431
2703
  exports.CHAIN_ID = CHAIN_ID;
@@ -2443,6 +2715,7 @@ exports.PlaymosError = PlaymosError;
2443
2715
  exports.USDC_ADDRESS = USDC_ADDRESS;
2444
2716
  exports.USDC_DECIMALS = USDC_DECIMALS;
2445
2717
  exports.WalletConnectionError = WalletConnectionError;
2718
+ exports.WalletTimeoutError = WalletTimeoutError;
2446
2719
  exports.clientRuntimeSignals = clientRuntimeSignals;
2447
2720
  exports.computeIapSplit = computeIapSplit;
2448
2721
  exports.computePayout = computePayout;
package/dist/index.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { N as Network, P as PlaymosConfig, W as WebhookEvent, R as RoundOpenInput, a as RoundState, b as RoundSettleInput, S as SettleRoundResult, C as CancelRoundResult, A as ActiveSeriesRound, c as PrizeBalance, d as WithdrawResult, e as AgentWallet, f as AgentFundResult, T as TransferResult, E as EscrowHoldInput, g as EscrowHoldResult, h as EscrowResolveResult, M as MarketplaceListInput, L as Listing, i as MarketplaceSaleResult, j as MarketplaceGetResult, k as PayInput, l as Payment, m as EnterRoundInput, n as TransferReconcile, o as WaitOptions, p as TransferInput, q as TransferConfirmOptions, V as VerifyResult, r as PayoutRule } from './errors-CdVzHuhY.cjs';
2
- export { s as ActiveForSeriesResult, t as AgentEconomyConfig, u as ApiError, v as AuthError, w as ConfigError, x as ContractConfig, y as Eip1193Provider, G as GasConfig, z as GasMode, I as InsufficientGasError, B as InvalidAmountError, D as ListingStatus, F as MarketplaceItem, H as MarketplaceSale, J as MissingFieldError, K as NothingToWithdrawError, O as PaymentFailedError, Q as PaymentStatus, U as PlaymosError, X as PlaymosErrorCode, Y as RetryOptions, Z as RoundStatus, _ as WalletConfig, $ as WalletConnectionError, a0 as WalletConnector, a1 as WebhookEventType } from './errors-CdVzHuhY.cjs';
1
+ import { N as Network, P as PlaymosConfig, W as WebhookEvent, R as RoundOpenInput, a as RoundState, b as RoundSettleInput, S as SettleRoundResult, c as RoundCancelInput, C as CancelRoundResult, A as ActiveSeriesRound, d as PrizeBalance, e as WithdrawResult, f as AgentWallet, g as AgentFundResult, T as TransferResult, E as EscrowHoldInput, h as EscrowHoldResult, i as EscrowResolveResult, M as MarketplaceListInput, L as Listing, j as MarketplaceSaleResult, k as MarketplaceGetResult, l as PayInput, m as Payment, n as EnterRoundInput, o as TransferReconcile, p as WaitOptions, q as TransferInput, r as TransferConfirmOptions, V as VerifyResult, s as PayoutRule } from './errors-BMlWHsMb.cjs';
2
+ export { t as ActiveForSeriesResult, u as AgentEconomyConfig, v as AlreadyEnteredError, w as ApiError, x as AuthError, y as ConfigError, z as ContractConfig, B as Eip1193Provider, G as GasConfig, D as GasMode, I as InsufficientGasError, F as InvalidAmountError, H as ListingStatus, J as MarketplaceItem, K as MarketplaceSale, O as MissingFieldError, Q as NothingToWithdrawError, U as PaymentFailedError, X as PaymentStatus, Y as PlaymosError, Z as PlaymosErrorCode, _ as RetryOptions, $ as RoundStatus, a0 as WalletConfig, a1 as WalletConnectionError, a2 as WalletConnector, a3 as WalletTimeoutError, a4 as WebhookEventType } from './errors-BMlWHsMb.cjs';
3
3
 
4
4
  /**
5
5
  * Environment resolution + the canonical address book.
@@ -477,13 +477,14 @@ declare class Playmos {
477
477
  settle: (input: RoundSettleInput) => Promise<SettleRoundResult>;
478
478
  /**
479
479
  * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
480
- * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
481
- * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
480
+ * and frees the series latch (#346 / #376).
481
+ *
482
+ * **Default is submit-only:** may return `status: "cancelling"` after broadcast —
483
+ * that is **not** success. Only `status: "cancelled"` means the chain is terminal.
484
+ * Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
485
+ * if still pending — never invents terminal success).
482
486
  */
483
- cancel: (input: {
484
- roundId: string;
485
- gameId?: string;
486
- }) => Promise<CancelRoundResult>;
487
+ cancel: (input: RoundCancelInput) => Promise<CancelRoundResult>;
487
488
  get: (input: {
488
489
  roundId: string;
489
490
  }) => Promise<RoundState>;
@@ -606,6 +607,14 @@ declare class Playmos {
606
607
  */
607
608
  private readonly mockPayments;
608
609
  constructor(config: PlaymosConfig);
610
+ /**
611
+ * Wallet timeout policy (#462 / PR #463 residual).
612
+ * - connectTimeoutMs → eth_requestAccounts only
613
+ * - machine RPCs → 20s default
614
+ * - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
615
+ */
616
+ private walletTimeoutPolicy;
617
+ private walletProvider;
609
618
  /** Connect the player's wallet and return their address. */
610
619
  connect(): Promise<`0x${string}`>;
611
620
  /**
@@ -848,4 +857,4 @@ declare function ulid(seedTime?: number): string;
848
857
  /** `${prefix}_${ulid()}` — e.g. `pay_01J…`, `entry_01J…`, `idem_01J…`. */
849
858
  declare function prefixedId(prefix: string): string;
850
859
 
851
- export { ActiveSeriesRound, AgentFundResult, AgentWallet, type Authorization, CHAIN_ID, CancelRoundResult, type CreatePaymentRequirementInput, DEFAULT_API_BASE_URL, EnterRoundInput, EscrowHoldInput, EscrowHoldResult, EscrowResolveResult, Listing, MICRO_PER_USDC, MarketplaceGetResult, MarketplaceListInput, MarketplaceSaleResult, Network, PayInput, Payment, type PaymentRequirement, PayoutError, PayoutRule, PayoutRule as PayoutRuleCompute, Playmos, PlaymosConfig, PrizeBalance, RoundOpenInput, RoundSettleInput, RoundState, SettleRoundResult, type SettlementAsset, TransferConfirmOptions, TransferInput, TransferReconcile, TransferResult, USDC_ADDRESS, USDC_DECIMALS, VerifyResult, WaitOptions, type WalletSignatureAuthorization, WebhookEvent, WithdrawResult, type X402ChallengeInput, type X402ChallengeResult, type X402FulfillAuthorization, type X402PayInput, type X402PayloadAuthorization, type X402PayloadMode, type X402PaymentRequired, type X402SettleResult, clientRuntimeSignals, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isClientRuntime, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, resolveEnv, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
860
+ export { ActiveSeriesRound, AgentFundResult, AgentWallet, type Authorization, CHAIN_ID, CancelRoundResult, type CreatePaymentRequirementInput, DEFAULT_API_BASE_URL, EnterRoundInput, EscrowHoldInput, EscrowHoldResult, EscrowResolveResult, Listing, MICRO_PER_USDC, MarketplaceGetResult, MarketplaceListInput, MarketplaceSaleResult, Network, PayInput, Payment, type PaymentRequirement, PayoutError, PayoutRule, PayoutRule as PayoutRuleCompute, Playmos, PlaymosConfig, PrizeBalance, RoundCancelInput, RoundOpenInput, RoundSettleInput, RoundState, SettleRoundResult, type SettlementAsset, TransferConfirmOptions, TransferInput, TransferReconcile, TransferResult, USDC_ADDRESS, USDC_DECIMALS, VerifyResult, WaitOptions, type WalletSignatureAuthorization, WebhookEvent, WithdrawResult, type X402ChallengeInput, type X402ChallengeResult, type X402FulfillAuthorization, type X402PayInput, type X402PayloadAuthorization, type X402PayloadMode, type X402PaymentRequired, type X402SettleResult, clientRuntimeSignals, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isClientRuntime, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, resolveEnv, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };