@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.js CHANGED
@@ -1,5 +1,5 @@
1
- import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError } from './chunk-VYR6BBHF.js';
2
- export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-VYR6BBHF.js';
1
+ import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, WalletTimeoutError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError, AlreadyEnteredError } from './chunk-UZECDT6F.js';
2
+ export { AlreadyEnteredError, ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError, WalletTimeoutError } from './chunk-UZECDT6F.js';
3
3
  import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
4
4
 
5
5
  // src/config.ts
@@ -245,12 +245,66 @@ function createHttpClient(baseUrl, apiKey, retry) {
245
245
  }
246
246
  return false;
247
247
  }
248
+ async function probeSignerBelowFloor() {
249
+ try {
250
+ const res = await send(`${base}/health`, { method: "GET" });
251
+ if (!res.ok) return { below: false };
252
+ const body = await res.json();
253
+ const bal = body?.capabilities?.payments?.signerBalance;
254
+ if (!bal || bal.ok !== false) return { below: false };
255
+ const reason = bal.reason ?? "";
256
+ const fundedFloorMiss = reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth";
257
+ if (!fundedFloorMiss) return { below: false, reason: reason || void 0 };
258
+ return { below: true, reason };
259
+ } catch {
260
+ return { below: false };
261
+ }
262
+ }
248
263
  async function sendWithRetry(url, init) {
249
264
  let res = await send(url, init);
265
+ let signerBelowFloor = false;
266
+ let signerBelowReason;
267
+ let probed = false;
268
+ const maybeProbe = async () => {
269
+ if (probed || signerBelowFloor) return;
270
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
271
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
272
+ if (ct.includes("application/json")) return;
273
+ probed = true;
274
+ const probe = await probeSignerBelowFloor();
275
+ signerBelowFloor = probe.below;
276
+ signerBelowReason = probe.reason;
277
+ };
278
+ const maybePeekJsonSignerLow = async () => {
279
+ if (signerBelowFloor) return;
280
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
281
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
282
+ if (!ct.includes("application/json")) return;
283
+ try {
284
+ const peek = JSON.parse(await res.clone().text());
285
+ const code = peek?.error?.code;
286
+ const reason = peek?.error?.reason;
287
+ if (code === "signer_low_balance" || reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth") {
288
+ signerBelowFloor = true;
289
+ signerBelowReason = reason ?? code;
290
+ }
291
+ } catch {
292
+ }
293
+ };
294
+ const classifySignerBelow = async () => {
295
+ await maybeProbe();
296
+ await maybePeekJsonSignerLow();
297
+ };
298
+ await classifySignerBelow();
250
299
  for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
300
+ if (signerBelowFloor) break;
251
301
  await sleep(backoffMs(res, attempt, cfg));
252
302
  res = await send(url, init);
303
+ await classifySignerBelow();
253
304
  }
305
+ const flagged = res;
306
+ flagged.__playmosSignerBelowFloor = signerBelowFloor;
307
+ if (signerBelowReason) flagged.__playmosSignerBelowReason = signerBelowReason;
254
308
  return res;
255
309
  }
256
310
  async function handle(res, acceptStatuses) {
@@ -269,8 +323,22 @@ function createHttpClient(baseUrl, apiKey, retry) {
269
323
  json = text ? JSON.parse(text) : {};
270
324
  } catch {
271
325
  const gateway = res.status === 502 || res.status === 503 || res.status === 504;
272
- 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}`;
273
- throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
326
+ const below = res.__playmosSignerBelowFloor === true;
327
+ let msg;
328
+ if (gateway && below) {
329
+ const why = res.__playmosSignerBelowReason ?? "low_balance";
330
+ 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.`;
331
+ } else if (gateway) {
332
+ 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).`;
333
+ } else {
334
+ msg = `Non-JSON response (${res.status}) from ${res.url}`;
335
+ }
336
+ throw new ApiError(msg, {
337
+ status: res.status,
338
+ body: text.slice(0, 500),
339
+ gateway,
340
+ signerBelowFloor: below || void 0
341
+ });
274
342
  }
275
343
  if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
276
344
  return json;
@@ -303,7 +371,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
303
371
  }
304
372
 
305
373
  // src/wallet.ts
306
- function resolveProvider(wallet) {
374
+ var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
375
+ var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
376
+ "wallet_sendCalls",
377
+ "eth_sendTransaction",
378
+ "eth_sendRawTransaction"
379
+ ]);
380
+ var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
381
+ var TIMED_FLAG = "__playmosTimedRequest";
382
+ function resolvePolicy(opts) {
383
+ const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
384
+ const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
385
+ const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
386
+ return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
387
+ }
388
+ function timeoutMsForMethod(method, opts) {
389
+ const p = resolvePolicy(opts);
390
+ if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
391
+ if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
392
+ return p.requestTimeoutMs;
393
+ }
394
+ async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
395
+ if (timeoutMs <= 0) {
396
+ return provider.request(args);
397
+ }
398
+ let timer;
399
+ try {
400
+ return await Promise.race([
401
+ provider.request(args),
402
+ new Promise((_, reject) => {
403
+ timer = setTimeout(() => {
404
+ reject(
405
+ new WalletTimeoutError(
406
+ `Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
407
+ { method: args.method, timeoutMs }
408
+ )
409
+ );
410
+ }, timeoutMs);
411
+ })
412
+ ]);
413
+ } finally {
414
+ if (timer !== void 0) clearTimeout(timer);
415
+ }
416
+ }
417
+ function withTimeoutProvider(provider, opts) {
418
+ const flagged = provider;
419
+ if (flagged[TIMED_FLAG]) return provider;
420
+ const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
421
+ const wrapped = {
422
+ request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
423
+ };
424
+ Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
425
+ return wrapped;
426
+ }
427
+ function resolveRawProvider(wallet) {
307
428
  if (wallet?.provider) return wallet.provider;
308
429
  const injected = globalThis.ethereum;
309
430
  const connector = wallet?.connector ?? "base-account";
@@ -320,18 +441,23 @@ function resolveProvider(wallet) {
320
441
  "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."
321
442
  );
322
443
  }
323
- async function walletAvailable(wallet) {
444
+ function resolveProvider(wallet, opts) {
445
+ const raw = resolveRawProvider(wallet);
446
+ return withTimeoutProvider(raw, opts);
447
+ }
448
+ async function walletAvailable(wallet, opts) {
324
449
  if (wallet?.provider) return true;
325
450
  let provider;
326
451
  try {
327
- provider = resolveProvider(wallet);
452
+ provider = resolveProvider(wallet, opts);
328
453
  } catch {
329
454
  return false;
330
455
  }
331
456
  try {
332
457
  await getAccount(provider);
333
458
  return true;
334
- } catch {
459
+ } catch (e) {
460
+ if (e instanceof WalletTimeoutError) throw e;
335
461
  return false;
336
462
  }
337
463
  }
@@ -342,6 +468,7 @@ async function getAccount(provider) {
342
468
  if (!addr) throw new Error("no account");
343
469
  return addr;
344
470
  } catch (e) {
471
+ if (e instanceof WalletTimeoutError) throw e;
345
472
  throw new WalletConnectionError("Could not read the player's wallet account.", {
346
473
  cause: e?.message
347
474
  });
@@ -415,6 +542,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
415
542
  try {
416
543
  result = await provider.request({ method: "wallet_sendCalls", params: [params] });
417
544
  } catch (e) {
545
+ if (e instanceof WalletTimeoutError) throw e;
418
546
  const msg = e?.message ?? String(e);
419
547
  if (/reject|denied|cancel|closed/i.test(msg)) {
420
548
  throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
@@ -429,14 +557,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
429
557
  if (!id) return { status: "FAILED" };
430
558
  const deadline = Date.now() + timeoutMs;
431
559
  while (Date.now() < deadline) {
432
- const res = await provider.request({
433
- method: "wallet_getCallsStatus",
434
- params: [id]
435
- });
436
- const s = String(res?.status ?? "").toUpperCase();
437
- const txHash = res?.receipts?.[0]?.transactionHash;
438
- if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
439
- if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
560
+ try {
561
+ const res = await provider.request({
562
+ method: "wallet_getCallsStatus",
563
+ params: [id]
564
+ });
565
+ const s = String(res?.status ?? "").toUpperCase();
566
+ const txHash = res?.receipts?.[0]?.transactionHash;
567
+ if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
568
+ if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
569
+ } catch (e) {
570
+ if (e instanceof WalletTimeoutError) {
571
+ await new Promise((r) => setTimeout(r, 900));
572
+ continue;
573
+ }
574
+ throw e;
575
+ }
440
576
  await new Promise((r) => setTimeout(r, 900));
441
577
  }
442
578
  return { status: "PENDING" };
@@ -712,6 +848,49 @@ function validateX402ChallengeInput(input) {
712
848
 
713
849
  // src/client.ts
714
850
  var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
851
+ function mapAlreadyEntered(e) {
852
+ const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
853
+ const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
854
+ const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
855
+ if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
856
+ throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
857
+ }
858
+ throw e;
859
+ }
860
+ var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
861
+ var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
862
+ function isSoftRoundPollError(e, kind) {
863
+ if (e instanceof AuthError || e instanceof ConfigError) return false;
864
+ if (e instanceof ApiError) {
865
+ const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
866
+ const msg = e.message;
867
+ if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
868
+ return true;
869
+ }
870
+ if (kind === "settle") {
871
+ if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
872
+ return false;
873
+ }
874
+ } else {
875
+ if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
876
+ msg
877
+ )) {
878
+ return false;
879
+ }
880
+ }
881
+ if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
882
+ return true;
883
+ }
884
+ if (status !== void 0 && status >= 400 && status < 500) return false;
885
+ if (status !== void 0 && status >= 500) return true;
886
+ }
887
+ return true;
888
+ }
889
+ function isSoftSettlePollError(e) {
890
+ return isSoftRoundPollError(e, "settle");
891
+ }
892
+ var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
893
+ var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
715
894
  function requireAddressField(value, field) {
716
895
  if (typeof value !== "string" || value.trim() === "") {
717
896
  throw new MissingFieldError(field);
@@ -911,7 +1090,7 @@ var Playmos = class {
911
1090
  this.mockWithdrawable = /* @__PURE__ */ new Map();
912
1091
  this.rounds = {
913
1092
  open: async (input) => {
914
- this.assertSecretKey("playmos.rounds.open");
1093
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
915
1094
  requireField(input?.gameId, "gameId");
916
1095
  requireField(input?.roundId, "roundId");
917
1096
  requireField(input?.entryAmount, "entryAmount");
@@ -965,7 +1144,7 @@ var Playmos = class {
965
1144
  return body.round;
966
1145
  },
967
1146
  lock: async (input) => {
968
- this.assertSecretKey("playmos.rounds.lock");
1147
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.lock");
969
1148
  requireField(input?.roundId, "roundId");
970
1149
  if (this.config.mock) {
971
1150
  const existing = this.mockRounds.get(input.roundId);
@@ -997,7 +1176,7 @@ var Playmos = class {
997
1176
  return round;
998
1177
  },
999
1178
  settle: async (input) => {
1000
- this.assertSecretKey("playmos.rounds.settle");
1179
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.settle");
1001
1180
  requireField(input?.roundId, "roundId");
1002
1181
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1003
1182
  if (this.config.mock) {
@@ -1058,20 +1237,64 @@ var Playmos = class {
1058
1237
  status: "settled"
1059
1238
  };
1060
1239
  }
1061
- const { settle } = await this.http.post(
1240
+ const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
1241
+ const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
1242
+ const body = { gameId: input.gameId, results: input.results };
1243
+ const idempotencyKey = `settle:${input.roundId}`;
1244
+ const postSettle = () => this.http.post(
1062
1245
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1063
- { gameId: input.gameId, results: input.results },
1064
- { acceptStatuses: [202] }
1246
+ body,
1247
+ { acceptStatuses: [200, 202], idempotencyKey }
1065
1248
  );
1066
- return settle;
1249
+ let { settle } = await postSettle();
1250
+ if (settle.status === "settled") return settle;
1251
+ const firstWinners = settle.winners;
1252
+ const firstPoolPaid = settle.poolPaid ?? null;
1253
+ const withFirstExtras = (s) => ({
1254
+ ...s,
1255
+ winners: s.winners ?? firstWinners,
1256
+ poolPaid: s.poolPaid ?? firstPoolPaid
1257
+ });
1258
+ const deadline = Date.now() + timeoutMs;
1259
+ while (Date.now() < deadline && settle.status === "settling") {
1260
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1261
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1262
+ if (Date.now() >= deadline) break;
1263
+ try {
1264
+ const again = await postSettle();
1265
+ settle = withFirstExtras(again.settle);
1266
+ if (settle.status === "settled") return settle;
1267
+ } catch (e) {
1268
+ if (!isSoftSettlePollError(e)) throw e;
1269
+ }
1270
+ try {
1271
+ const round = await this.rounds.get({ roundId: input.roundId });
1272
+ if (round.status === "settled") {
1273
+ return {
1274
+ roundId: input.roundId,
1275
+ txHash: round.settleTxHash ?? settle.txHash ?? null,
1276
+ poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
1277
+ winners: settle.winners ?? firstWinners,
1278
+ status: "settled"
1279
+ };
1280
+ }
1281
+ } catch (e) {
1282
+ if (!isSoftSettlePollError(e)) throw e;
1283
+ }
1284
+ }
1285
+ return withFirstExtras(settle);
1067
1286
  },
1068
1287
  /**
1069
1288
  * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
1070
- * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
1071
- * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1289
+ * and frees the series latch (#346 / #376).
1290
+ *
1291
+ * **Default is submit-only:** may return `status: "cancelling"` after broadcast —
1292
+ * that is **not** success. Only `status: "cancelled"` means the chain is terminal.
1293
+ * Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
1294
+ * if still pending — never invents terminal success).
1072
1295
  */
1073
1296
  cancel: async (input) => {
1074
- this.assertSecretKey("playmos.rounds.cancel");
1297
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
1075
1298
  requireField(input?.roundId, "roundId");
1076
1299
  if (this.config.mock) {
1077
1300
  const existing = this.mockRounds.get(input.roundId);
@@ -1106,18 +1329,57 @@ var Playmos = class {
1106
1329
  round: cancelled
1107
1330
  };
1108
1331
  }
1109
- const body = await this.http.post(
1110
- `/rounds/${encodeURIComponent(input.roundId)}/cancel`,
1111
- { gameId: input.gameId },
1112
- { acceptStatuses: [202] }
1113
- );
1114
- const status = body.status === "cancelling" || body.round?.status === "cancelling" ? "cancelling" : "cancelled";
1115
- return {
1116
- roundId: input.roundId,
1117
- txHash: body.txHash ?? body.round?.cancelTxHash ?? null,
1118
- status,
1119
- round: body.round
1332
+ const bodyPayload = { gameId: input.gameId };
1333
+ const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
1334
+ acceptStatuses: [200, 202]
1335
+ });
1336
+ const toResult = (body, priorTx) => {
1337
+ const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
1338
+ const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
1339
+ const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
1340
+ const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
1341
+ return {
1342
+ roundId: input.roundId,
1343
+ txHash: tx,
1344
+ status,
1345
+ round: body.round
1346
+ };
1120
1347
  };
1348
+ let result = toResult(await postCancel());
1349
+ if (result.status === "cancelled") return result;
1350
+ if (!input.confirm) return result;
1351
+ const timeoutMs = Math.min(
1352
+ Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
1353
+ CANCEL_RECONCILE_TIMEOUT_MS
1354
+ );
1355
+ const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
1356
+ const deadline = Date.now() + timeoutMs;
1357
+ const firstTx = result.txHash;
1358
+ while (Date.now() < deadline && result.status === "cancelling") {
1359
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1360
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1361
+ if (Date.now() >= deadline) break;
1362
+ try {
1363
+ result = toResult(await postCancel(), firstTx);
1364
+ if (result.status === "cancelled") return result;
1365
+ } catch (e) {
1366
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1367
+ }
1368
+ try {
1369
+ const round = await this.rounds.get({ roundId: input.roundId });
1370
+ if (round.status === "cancelled") {
1371
+ return {
1372
+ roundId: input.roundId,
1373
+ txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
1374
+ status: "cancelled",
1375
+ round
1376
+ };
1377
+ }
1378
+ } catch (e) {
1379
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1380
+ }
1381
+ }
1382
+ return { ...result, txHash: result.txHash ?? firstTx ?? null };
1121
1383
  },
1122
1384
  get: async (input) => {
1123
1385
  requireField(input?.roundId, "roundId");
@@ -1268,7 +1530,7 @@ var Playmos = class {
1268
1530
  }
1269
1531
  prizePool = round.prizePoolAddress;
1270
1532
  }
1271
- const provider = resolveProvider(this.config.wallet);
1533
+ const provider = this.walletProvider();
1272
1534
  if (this.config.gas?.mode === "player") {
1273
1535
  const from0 = await getAccount(provider);
1274
1536
  await assertEnoughGas(provider, from0);
@@ -1285,18 +1547,31 @@ var Playmos = class {
1285
1547
  const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1286
1548
  let txHash;
1287
1549
  let status = "pending";
1550
+ let callsId;
1288
1551
  try {
1289
- const { id: callsId } = await sendCalls(
1552
+ const sent = await sendCalls(
1290
1553
  provider,
1291
1554
  from,
1292
1555
  this.env.chainId,
1293
1556
  [call],
1294
1557
  paymasterUrl
1295
1558
  );
1559
+ callsId = sent.id;
1296
1560
  const waited = await waitForCalls(provider, callsId);
1297
1561
  txHash = waited.txHash;
1298
1562
  status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1299
1563
  } catch (e) {
1564
+ if (e instanceof WalletTimeoutError && callsId) {
1565
+ return {
1566
+ prizePoolAddress: prizePool,
1567
+ amount: formatMicroToUsd(amountMicro),
1568
+ amountMicro: amountMicro.toString(),
1569
+ txHash,
1570
+ status: "pending",
1571
+ callsId
1572
+ };
1573
+ }
1574
+ if (e instanceof WalletTimeoutError) throw e;
1300
1575
  const msg = e?.message ?? String(e);
1301
1576
  if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1302
1577
  throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
@@ -1316,7 +1591,8 @@ var Playmos = class {
1316
1591
  amount: formatMicroToUsd(amountMicro),
1317
1592
  amountMicro: amountMicro.toString(),
1318
1593
  txHash,
1319
- status
1594
+ status,
1595
+ ...status === "pending" && callsId ? { callsId } : {}
1320
1596
  };
1321
1597
  }
1322
1598
  };
@@ -1563,13 +1839,36 @@ var Playmos = class {
1563
1839
  }
1564
1840
  }
1565
1841
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
1842
+ if (!config.mock) {
1843
+ try {
1844
+ resolveProvider(config.wallet, this.walletTimeoutPolicy());
1845
+ } catch {
1846
+ }
1847
+ }
1848
+ }
1849
+ /**
1850
+ * Wallet timeout policy (#462 / PR #463 residual).
1851
+ * - connectTimeoutMs → eth_requestAccounts only
1852
+ * - machine RPCs → 20s default
1853
+ * - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
1854
+ */
1855
+ walletTimeoutPolicy() {
1856
+ const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
1857
+ return {
1858
+ connectTimeoutMs: connect,
1859
+ requestTimeoutMs: 2e4,
1860
+ sendCallsTimeoutMs: 0
1861
+ };
1862
+ }
1863
+ walletProvider() {
1864
+ return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
1566
1865
  }
1567
1866
  /** Connect the player's wallet and return their address. */
1568
1867
  async connect() {
1569
1868
  if (this.config.mock) {
1570
1869
  return "0x0000000000000000000000000000000000000001";
1571
1870
  }
1572
- return getAccount(resolveProvider(this.config.wallet));
1871
+ return getAccount(this.walletProvider());
1573
1872
  }
1574
1873
  /**
1575
1874
  * A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
@@ -1629,7 +1928,7 @@ var Playmos = class {
1629
1928
  })
1630
1929
  );
1631
1930
  }
1632
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1931
+ if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
1633
1932
  const res = await this.http.post(
1634
1933
  "/payments",
1635
1934
  {
@@ -1667,7 +1966,7 @@ var Playmos = class {
1667
1966
  },
1668
1967
  { idempotencyKey }
1669
1968
  );
1670
- const provider = resolveProvider(this.config.wallet);
1969
+ const provider = this.walletProvider();
1671
1970
  if (this.config.gas?.mode === "player") {
1672
1971
  const from0 = await getAccount(provider);
1673
1972
  await assertEnoughGas(provider, from0);
@@ -1712,7 +2011,17 @@ var Playmos = class {
1712
2011
  })
1713
2012
  );
1714
2013
  }
1715
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2014
+ const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
2015
+ if (!serverSettleNoWallet) {
2016
+ const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
2017
+ if (!pinned) {
2018
+ throw new ConfigError(
2019
+ '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).',
2020
+ { field: "identity" }
2021
+ );
2022
+ }
2023
+ }
2024
+ if (serverSettleNoWallet) {
1716
2025
  const pinnedRoundKey = input.roundKey ?? input.roundId;
1717
2026
  const res = await this.http.post(
1718
2027
  "/payments",
@@ -1757,7 +2066,7 @@ var Playmos = class {
1757
2066
  },
1758
2067
  { idempotencyKey }
1759
2068
  );
1760
- const provider = resolveProvider(this.config.wallet);
2069
+ const provider = this.walletProvider();
1761
2070
  if (this.config.gas?.mode === "player") {
1762
2071
  const from0 = await getAccount(provider);
1763
2072
  await assertEnoughGas(provider, from0);
@@ -1768,12 +2077,28 @@ var Playmos = class {
1768
2077
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
1769
2078
  const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
1770
2079
  if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
1771
- const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity ?? `${from}#${idempotencyKey}`;
2080
+ const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
2081
+ if (!identity || !String(identity).trim()) {
2082
+ throw new ConfigError(
2083
+ "enterRound: missing on-chain identity after create-intent (pass input.identity).",
2084
+ { field: "identity" }
2085
+ );
2086
+ }
1772
2087
  const amountUnits = this.resolveUnits(intent, amountMicro);
1773
2088
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
1774
- const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
2089
+ let callsId;
2090
+ try {
2091
+ ({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
2092
+ } catch (e) {
2093
+ throw mapAlreadyEntered(e);
2094
+ }
1775
2095
  const { txHash } = await waitForCalls(provider, callsId);
1776
- const payment = await this.settle(intent.payment.id, txHash);
2096
+ let payment;
2097
+ try {
2098
+ payment = await this.settle(intent.payment.id, txHash);
2099
+ } catch (e) {
2100
+ throw mapAlreadyEntered(e);
2101
+ }
1777
2102
  payment.identity = identity;
1778
2103
  payment.prizePoolAddress = prizePool.toLowerCase();
1779
2104
  payment.roundKey = roundKey;
@@ -1970,8 +2295,8 @@ var Playmos = class {
1970
2295
  });
1971
2296
  let raw;
1972
2297
  try {
1973
- if (await walletAvailable(this.config.wallet)) {
1974
- const provider = resolveProvider(this.config.wallet);
2298
+ if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
2299
+ const provider = this.walletProvider();
1975
2300
  raw = await provider.request({
1976
2301
  method: "eth_call",
1977
2302
  params: [{ to: prizePool, data }, "latest"]
@@ -1998,7 +2323,7 @@ var Playmos = class {
1998
2323
  raw = json.result;
1999
2324
  }
2000
2325
  } catch (e) {
2001
- if (e instanceof ApiError) throw e;
2326
+ if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
2002
2327
  throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
2003
2328
  prizePool,
2004
2329
  wallet
package/dist/server.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { U as PlaymosError, W as WebhookEvent } from './errors-CdVzHuhY.cjs';
1
+ import { Y as PlaymosError, W as WebhookEvent } from './errors-BMlWHsMb.cjs';
2
2
 
3
3
  /**
4
4
  * Webhook signature verification (spec §6.2) — server-side only.
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { U as PlaymosError, W as WebhookEvent } from './errors-CdVzHuhY.js';
1
+ import { Y as PlaymosError, W as WebhookEvent } from './errors-BMlWHsMb.js';
2
2
 
3
3
  /**
4
4
  * Webhook signature verification (spec §6.2) — server-side only.
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { PlaymosError } from './chunk-VYR6BBHF.js';
1
+ import { PlaymosError } from './chunk-UZECDT6F.js';
2
2
  import { timingSafeEqual, createHmac } from 'crypto';
3
3
 
4
4
  var WebhookSignatureError = class extends PlaymosError {
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@playmos/sdk",
3
- "version": "0.3.6",
4
- "description": "Playmos SDK \u2014 stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
3
+ "version": "0.3.8",
4
+ "description": "Playmos SDK stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
+ "homepage": "https://playmos-docs-public.vercel.app/docs",
8
+ "bugs": {
9
+ "url": "https://playmos-docs-public.vercel.app/docs"
10
+ },
7
11
  "repository": {
8
12
  "type": "git",
9
13
  "url": "git+https://github.com/playmos-labs/playmos-sdk.git",