@playmos/sdk 0.3.7 → 0.3.9

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);
@@ -251,6 +265,112 @@ function validateMetadata(metadata) {
251
265
  return out;
252
266
  }
253
267
 
268
+ // src/payout.ts
269
+ var PayoutError = class extends Error {
270
+ constructor(message) {
271
+ super(message);
272
+ this.code = "payout_invalid";
273
+ this.name = "PayoutError";
274
+ }
275
+ };
276
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
277
+ var BPS = 10000n;
278
+ function requireAddress(w, i) {
279
+ if (!ADDRESS_RE.test(w)) {
280
+ throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
281
+ }
282
+ return w.toLowerCase();
283
+ }
284
+ function parseUsdToMicroLoose(amount) {
285
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
286
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
287
+ }
288
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
289
+ const whole = wholeRaw ?? "0";
290
+ const fracPadded = (frac + "000000").slice(0, 6);
291
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
292
+ if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
293
+ return micro;
294
+ }
295
+ function computePayout(pool, ranking, rule) {
296
+ if (typeof pool !== "bigint" || pool <= 0n) {
297
+ throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
298
+ }
299
+ if (!Array.isArray(ranking) || ranking.length === 0) {
300
+ throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
301
+ }
302
+ const wallets = ranking.map((w, i) => requireAddress(w, i));
303
+ const seen = /* @__PURE__ */ new Set();
304
+ for (const w of wallets) {
305
+ if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
306
+ seen.add(w);
307
+ }
308
+ if (rule.kind === "winner-take-all") {
309
+ return [{ wallet: wallets[0], amount: pool }];
310
+ }
311
+ if (rule.kind === "top-n") {
312
+ const splits = rule.splitsBps;
313
+ if (!Array.isArray(splits) || splits.length === 0) {
314
+ throw new PayoutError("top-n splitsBps must be a non-empty array");
315
+ }
316
+ if (splits.length > wallets.length) {
317
+ throw new PayoutError(
318
+ `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
319
+ );
320
+ }
321
+ let sumBps = 0;
322
+ for (const b of splits) {
323
+ if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
324
+ throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
325
+ }
326
+ sumBps += b;
327
+ }
328
+ if (sumBps !== 1e4) {
329
+ throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
330
+ }
331
+ const out = [];
332
+ let allocated = 0n;
333
+ for (let i = 0; i < splits.length; i++) {
334
+ const amt = pool * BigInt(splits[i]) / BPS;
335
+ out.push({ wallet: wallets[i], amount: amt });
336
+ allocated += amt;
337
+ }
338
+ const remainder = pool - allocated;
339
+ if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
340
+ out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
341
+ const filtered = out.filter((x) => x.amount > 0n);
342
+ if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
343
+ const total = filtered.reduce((s, x) => s + x.amount, 0n);
344
+ if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
345
+ return filtered;
346
+ }
347
+ if (rule.kind === "custom") {
348
+ const amounts = rule.amounts;
349
+ if (!Array.isArray(amounts) || amounts.length === 0) {
350
+ throw new PayoutError("custom amounts must be a non-empty array of USD strings");
351
+ }
352
+ if (amounts.length > wallets.length) {
353
+ throw new PayoutError(
354
+ `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
355
+ );
356
+ }
357
+ const out = [];
358
+ let total = 0n;
359
+ for (let i = 0; i < amounts.length; i++) {
360
+ const amt = parseUsdToMicroLoose(amounts[i]);
361
+ out.push({ wallet: wallets[i], amount: amt });
362
+ total += amt;
363
+ }
364
+ if (total !== pool) {
365
+ throw new PayoutError(
366
+ `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
367
+ );
368
+ }
369
+ return out;
370
+ }
371
+ throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
372
+ }
373
+
254
374
  // src/http.ts
255
375
  function resolveRetry(retry) {
256
376
  const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
@@ -399,7 +519,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
399
519
  const why = res.__playmosSignerBelowReason ?? "low_balance";
400
520
  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
521
  } 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).`;
522
+ 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
523
  } else {
404
524
  msg = `Non-JSON response (${res.status}) from ${res.url}`;
405
525
  }
@@ -441,7 +561,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
441
561
  }
442
562
 
443
563
  // src/wallet.ts
444
- function resolveProvider(wallet) {
564
+ var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
565
+ var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
566
+ "wallet_sendCalls",
567
+ "eth_sendTransaction",
568
+ "eth_sendRawTransaction"
569
+ ]);
570
+ var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
571
+ var TIMED_FLAG = "__playmosTimedRequest";
572
+ function resolvePolicy(opts) {
573
+ const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
574
+ const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
575
+ const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
576
+ return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
577
+ }
578
+ function timeoutMsForMethod(method, opts) {
579
+ const p = resolvePolicy(opts);
580
+ if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
581
+ if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
582
+ return p.requestTimeoutMs;
583
+ }
584
+ async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
585
+ if (timeoutMs <= 0) {
586
+ return provider.request(args);
587
+ }
588
+ let timer;
589
+ try {
590
+ return await Promise.race([
591
+ provider.request(args),
592
+ new Promise((_, reject) => {
593
+ timer = setTimeout(() => {
594
+ reject(
595
+ new WalletTimeoutError(
596
+ `Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
597
+ { method: args.method, timeoutMs }
598
+ )
599
+ );
600
+ }, timeoutMs);
601
+ })
602
+ ]);
603
+ } finally {
604
+ if (timer !== void 0) clearTimeout(timer);
605
+ }
606
+ }
607
+ function withTimeoutProvider(provider, opts) {
608
+ const flagged = provider;
609
+ if (flagged[TIMED_FLAG]) return provider;
610
+ const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
611
+ const wrapped = {
612
+ request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
613
+ };
614
+ Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
615
+ return wrapped;
616
+ }
617
+ function resolveRawProvider(wallet) {
445
618
  if (wallet?.provider) return wallet.provider;
446
619
  const injected = globalThis.ethereum;
447
620
  const connector = wallet?.connector ?? "base-account";
@@ -458,18 +631,23 @@ function resolveProvider(wallet) {
458
631
  "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
632
  );
460
633
  }
461
- async function walletAvailable(wallet) {
634
+ function resolveProvider(wallet, opts) {
635
+ const raw = resolveRawProvider(wallet);
636
+ return withTimeoutProvider(raw, opts);
637
+ }
638
+ async function walletAvailable(wallet, opts) {
462
639
  if (wallet?.provider) return true;
463
640
  let provider;
464
641
  try {
465
- provider = resolveProvider(wallet);
642
+ provider = resolveProvider(wallet, opts);
466
643
  } catch {
467
644
  return false;
468
645
  }
469
646
  try {
470
647
  await getAccount(provider);
471
648
  return true;
472
- } catch {
649
+ } catch (e) {
650
+ if (e instanceof WalletTimeoutError) throw e;
473
651
  return false;
474
652
  }
475
653
  }
@@ -480,6 +658,7 @@ async function getAccount(provider) {
480
658
  if (!addr) throw new Error("no account");
481
659
  return addr;
482
660
  } catch (e) {
661
+ if (e instanceof WalletTimeoutError) throw e;
483
662
  throw new WalletConnectionError("Could not read the player's wallet account.", {
484
663
  cause: e?.message
485
664
  });
@@ -553,6 +732,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
553
732
  try {
554
733
  result = await provider.request({ method: "wallet_sendCalls", params: [params] });
555
734
  } catch (e) {
735
+ if (e instanceof WalletTimeoutError) throw e;
556
736
  const msg = e?.message ?? String(e);
557
737
  if (/reject|denied|cancel|closed/i.test(msg)) {
558
738
  throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
@@ -567,14 +747,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
567
747
  if (!id) return { status: "FAILED" };
568
748
  const deadline = Date.now() + timeoutMs;
569
749
  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 };
750
+ try {
751
+ const res = await provider.request({
752
+ method: "wallet_getCallsStatus",
753
+ params: [id]
754
+ });
755
+ const s = String(res?.status ?? "").toUpperCase();
756
+ const txHash = res?.receipts?.[0]?.transactionHash;
757
+ if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
758
+ if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
759
+ } catch (e) {
760
+ if (e instanceof WalletTimeoutError) {
761
+ await new Promise((r) => setTimeout(r, 900));
762
+ continue;
763
+ }
764
+ throw e;
765
+ }
578
766
  await new Promise((r) => setTimeout(r, 900));
579
767
  }
580
768
  return { status: "PENDING" };
@@ -706,12 +894,12 @@ function mockVerifyResult(payment) {
706
894
  }
707
895
 
708
896
  // src/x402.ts
709
- var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
710
- function requireAddress(value, field) {
897
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
898
+ function requireAddress2(value, field) {
711
899
  if (typeof value !== "string" || value.trim() === "") {
712
900
  throw new MissingFieldError(field);
713
901
  }
714
- if (!ADDRESS_RE.test(value)) {
902
+ if (!ADDRESS_RE2.test(value)) {
715
903
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
716
904
  field,
717
905
  value
@@ -781,7 +969,7 @@ function createX402Challenge(requirement, extras) {
781
969
  if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
782
970
  throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
783
971
  }
784
- requireAddress(requirement.payTo, "payTo");
972
+ requireAddress2(requirement.payTo, "payTo");
785
973
  parseUsdToMicro(requirement.amount);
786
974
  const feeBps = extras?.feeBps ?? 0;
787
975
  if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
@@ -791,7 +979,7 @@ function createX402Challenge(requirement, extras) {
791
979
  );
792
980
  }
793
981
  const feeSink = extras?.feeSink ?? null;
794
- if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
982
+ if (feeBps > 0 && feeSink) requireAddress2(feeSink, "feeSink");
795
983
  const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
796
984
  return {
797
985
  status: 402,
@@ -819,7 +1007,7 @@ function validateX402ChallengeInput(input) {
819
1007
  { field: "url" }
820
1008
  );
821
1009
  }
822
- const payTo = requireAddress(input.payTo, "payTo");
1010
+ const payTo = requireAddress2(input.payTo, "payTo");
823
1011
  parseUsdToMicro(input.amount);
824
1012
  const intent = input.intent ?? "transfer";
825
1013
  if (intent !== "transfer" && intent !== "marketplace.buy") {
@@ -835,7 +1023,7 @@ function validateX402ChallengeInput(input) {
835
1023
  { feeBps: input.feeBps }
836
1024
  );
837
1025
  }
838
- const feeSink = input.feeSink === void 0 ? void 0 : requireAddress(input.feeSink, "feeSink");
1026
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddress2(input.feeSink, "feeSink");
839
1027
  return {
840
1028
  payTo,
841
1029
  amount: input.amount,
@@ -849,12 +1037,55 @@ function validateX402ChallengeInput(input) {
849
1037
  }
850
1038
 
851
1039
  // src/client.ts
852
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1040
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1041
+ function mapAlreadyEntered(e) {
1042
+ const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
1043
+ const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
1044
+ const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
1045
+ if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
1046
+ throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
1047
+ }
1048
+ throw e;
1049
+ }
1050
+ var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
1051
+ var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
1052
+ function isSoftRoundPollError(e, kind) {
1053
+ if (e instanceof AuthError || e instanceof ConfigError) return false;
1054
+ if (e instanceof ApiError) {
1055
+ const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
1056
+ const msg = e.message;
1057
+ if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
1058
+ return true;
1059
+ }
1060
+ if (kind === "settle") {
1061
+ if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
1062
+ return false;
1063
+ }
1064
+ } else {
1065
+ if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
1066
+ msg
1067
+ )) {
1068
+ return false;
1069
+ }
1070
+ }
1071
+ if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
1072
+ return true;
1073
+ }
1074
+ if (status !== void 0 && status >= 400 && status < 500) return false;
1075
+ if (status !== void 0 && status >= 500) return true;
1076
+ }
1077
+ return true;
1078
+ }
1079
+ function isSoftSettlePollError(e) {
1080
+ return isSoftRoundPollError(e, "settle");
1081
+ }
1082
+ var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
1083
+ var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
853
1084
  function requireAddressField(value, field) {
854
1085
  if (typeof value !== "string" || value.trim() === "") {
855
1086
  throw new MissingFieldError(field);
856
1087
  }
857
- if (!ADDRESS_RE2.test(value)) {
1088
+ if (!ADDRESS_RE3.test(value)) {
858
1089
  throw new ConfigError(
859
1090
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
860
1091
  { field, value }
@@ -1042,11 +1273,22 @@ var Playmos = class {
1042
1273
  * rounds.settle({ ranking }) → contract pays winners.
1043
1274
  *
1044
1275
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
1276
+ *
1277
+ * Offline `mock: true`: settle applies the stored payout rule and the 60/30/10
1278
+ * pool leg across entries (#490). No series seed bank — mock pool is a floor,
1279
+ * not a forecast of a live series with inherited seed.
1045
1280
  */
1046
1281
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
1047
1282
  this.mockRounds = /* @__PURE__ */ new Map();
1048
1283
  /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
1049
1284
  this.mockWithdrawable = /* @__PURE__ */ new Map();
1285
+ /**
1286
+ * Mock pot after per-entry rake (PrizePool escrowed sum) — keyed by roundId (#490).
1287
+ * Payable pool at lock = pot × POOL_BPS / (POOL_BPS + SEED_BPS); inherited seed = 0 offline.
1288
+ */
1289
+ this.mockPotMicro = /* @__PURE__ */ new Map();
1290
+ /** Persisted settle winners for re-settle replay (#490 C2) — never re-invent pool-to-everyone. */
1291
+ this.mockSettleWinners = /* @__PURE__ */ new Map();
1050
1292
  this.rounds = {
1051
1293
  open: async (input) => {
1052
1294
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
@@ -1085,6 +1327,8 @@ var Playmos = class {
1085
1327
  prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1086
1328
  };
1087
1329
  this.mockRounds.set(input.roundId, round);
1330
+ this.mockPotMicro.set(input.roundId, 0n);
1331
+ this.mockSettleWinners.delete(input.roundId);
1088
1332
  return round;
1089
1333
  }
1090
1334
  const body = await this.http.post(
@@ -1117,11 +1361,12 @@ var Playmos = class {
1117
1361
  code: "conflict"
1118
1362
  });
1119
1363
  }
1364
+ const payableMicro = this.mockPayablePoolMicro(existing);
1365
+ const poolUsd = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
1120
1366
  const locked = {
1121
1367
  ...existing,
1122
1368
  status: "locked",
1123
- // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1124
- pool: existing.pool ?? existing.entryAmount,
1369
+ pool: poolUsd,
1125
1370
  entrants: existing.entrants ?? 0,
1126
1371
  lockTxHash: MOCK_TX
1127
1372
  };
@@ -1144,14 +1389,15 @@ var Playmos = class {
1144
1389
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1145
1390
  }
1146
1391
  if (existing.status === "settled" && existing.settleTxHash) {
1392
+ const stored = this.mockSettleWinners.get(input.roundId);
1147
1393
  return {
1148
1394
  roundId: input.roundId,
1149
1395
  txHash: existing.settleTxHash,
1150
1396
  poolPaid: existing.pool ?? "0",
1151
- winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1397
+ winners: stored ?? ("winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1152
1398
  wallet,
1153
- amount: existing.pool ?? existing.entryAmount
1154
- })),
1399
+ amount: "0"
1400
+ }))),
1155
1401
  status: "settled"
1156
1402
  };
1157
1403
  }
@@ -1161,13 +1407,45 @@ var Playmos = class {
1161
1407
  code: "conflict"
1162
1408
  });
1163
1409
  }
1164
- const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1165
- wallet,
1166
- // Winner-take-all mock shape when ranking only.
1167
- amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1168
- }));
1410
+ const payableMicro = existing.pool != null ? this.parseUsdToMicroLoose(existing.pool) : this.mockPayablePoolMicro(existing);
1411
+ const poolPaid = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
1412
+ let rows;
1413
+ if ("winners" in input.results) {
1414
+ rows = input.results.winners.map((w) => {
1415
+ let micro;
1416
+ try {
1417
+ micro = this.parseUsdToMicroLoose(String(w.amount ?? "0"));
1418
+ } catch (e) {
1419
+ this.mockPayoutApiError(e);
1420
+ }
1421
+ return {
1422
+ wallet: w.wallet,
1423
+ amount: formatMicroToUsd(micro),
1424
+ micro
1425
+ };
1426
+ });
1427
+ } else {
1428
+ if (payableMicro <= 0n) {
1429
+ rows = [];
1430
+ } else {
1431
+ try {
1432
+ const computed = computePayout(
1433
+ payableMicro,
1434
+ input.results.ranking,
1435
+ existing.payout
1436
+ );
1437
+ rows = computed.map((c) => ({
1438
+ wallet: c.wallet,
1439
+ amount: formatMicroToUsd(c.amount),
1440
+ micro: c.amount
1441
+ }));
1442
+ } catch (e) {
1443
+ this.mockPayoutApiError(e);
1444
+ }
1445
+ }
1446
+ }
1447
+ const winners = rows.map(({ wallet, amount }) => ({ wallet, amount }));
1169
1448
  const settleTxHash = MOCK_TX;
1170
- const poolPaid = existing.pool ?? existing.entryAmount;
1171
1449
  const settled = {
1172
1450
  ...existing,
1173
1451
  status: "settled",
@@ -1175,18 +1453,11 @@ var Playmos = class {
1175
1453
  pool: poolPaid
1176
1454
  };
1177
1455
  this.mockRounds.set(input.roundId, settled);
1178
- for (const w of winners) {
1179
- const wAddr = String(w.wallet).toLowerCase();
1180
- let micro = 0n;
1181
- try {
1182
- micro = validateAmount(String(w.amount ?? "0"));
1183
- } catch {
1184
- micro = 0n;
1185
- }
1186
- if (micro > 0n) {
1187
- const k = `${input.roundId}:${wAddr}`;
1188
- this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
1189
- }
1456
+ this.mockSettleWinners.set(input.roundId, winners);
1457
+ for (const w of rows) {
1458
+ if (w.micro <= 0n) continue;
1459
+ const k = `${input.roundId}:${w.wallet.toLowerCase()}`;
1460
+ this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + w.micro);
1190
1461
  }
1191
1462
  return {
1192
1463
  roundId: input.roundId,
@@ -1196,17 +1467,61 @@ var Playmos = class {
1196
1467
  status: "settled"
1197
1468
  };
1198
1469
  }
1199
- const { settle } = await this.http.post(
1470
+ const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
1471
+ const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
1472
+ const body = { gameId: input.gameId, results: input.results };
1473
+ const idempotencyKey = `settle:${input.roundId}`;
1474
+ const postSettle = () => this.http.post(
1200
1475
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1201
- { gameId: input.gameId, results: input.results },
1202
- { acceptStatuses: [202] }
1476
+ body,
1477
+ { acceptStatuses: [200, 202], idempotencyKey }
1203
1478
  );
1204
- return settle;
1479
+ let { settle } = await postSettle();
1480
+ if (settle.status === "settled") return settle;
1481
+ const firstWinners = settle.winners;
1482
+ const firstPoolPaid = settle.poolPaid ?? null;
1483
+ const withFirstExtras = (s) => ({
1484
+ ...s,
1485
+ winners: s.winners ?? firstWinners,
1486
+ poolPaid: s.poolPaid ?? firstPoolPaid
1487
+ });
1488
+ const deadline = Date.now() + timeoutMs;
1489
+ while (Date.now() < deadline && settle.status === "settling") {
1490
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1491
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1492
+ if (Date.now() >= deadline) break;
1493
+ try {
1494
+ const again = await postSettle();
1495
+ settle = withFirstExtras(again.settle);
1496
+ if (settle.status === "settled") return settle;
1497
+ } catch (e) {
1498
+ if (!isSoftSettlePollError(e)) throw e;
1499
+ }
1500
+ try {
1501
+ const round = await this.rounds.get({ roundId: input.roundId });
1502
+ if (round.status === "settled") {
1503
+ return {
1504
+ roundId: input.roundId,
1505
+ txHash: round.settleTxHash ?? settle.txHash ?? null,
1506
+ poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
1507
+ winners: settle.winners ?? firstWinners,
1508
+ status: "settled"
1509
+ };
1510
+ }
1511
+ } catch (e) {
1512
+ if (!isSoftSettlePollError(e)) throw e;
1513
+ }
1514
+ }
1515
+ return withFirstExtras(settle);
1205
1516
  },
1206
1517
  /**
1207
1518
  * 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).
1519
+ * and frees the series latch (#346 / #376).
1520
+ *
1521
+ * **Default is submit-only:** may return `status: "cancelling"` after broadcast —
1522
+ * that is **not** success. Only `status: "cancelled"` means the chain is terminal.
1523
+ * Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
1524
+ * if still pending — never invents terminal success).
1210
1525
  */
1211
1526
  cancel: async (input) => {
1212
1527
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
@@ -1234,9 +1549,12 @@ var Playmos = class {
1234
1549
  ...existing,
1235
1550
  status: "cancelled",
1236
1551
  cancelTxHash: MOCK_TX,
1237
- pool: null
1552
+ pool: null,
1553
+ entrants: 0
1238
1554
  };
1239
1555
  this.mockRounds.set(input.roundId, cancelled);
1556
+ this.mockPotMicro.set(input.roundId, 0n);
1557
+ this.mockSettleWinners.delete(input.roundId);
1240
1558
  return {
1241
1559
  roundId: input.roundId,
1242
1560
  txHash: MOCK_TX,
@@ -1244,18 +1562,57 @@ var Playmos = class {
1244
1562
  round: cancelled
1245
1563
  };
1246
1564
  }
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
1565
+ const bodyPayload = { gameId: input.gameId };
1566
+ const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
1567
+ acceptStatuses: [200, 202]
1568
+ });
1569
+ const toResult = (body, priorTx) => {
1570
+ const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
1571
+ const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
1572
+ const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
1573
+ const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
1574
+ return {
1575
+ roundId: input.roundId,
1576
+ txHash: tx,
1577
+ status,
1578
+ round: body.round
1579
+ };
1258
1580
  };
1581
+ let result = toResult(await postCancel());
1582
+ if (result.status === "cancelled") return result;
1583
+ if (!input.confirm) return result;
1584
+ const timeoutMs = Math.min(
1585
+ Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
1586
+ CANCEL_RECONCILE_TIMEOUT_MS
1587
+ );
1588
+ const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
1589
+ const deadline = Date.now() + timeoutMs;
1590
+ const firstTx = result.txHash;
1591
+ while (Date.now() < deadline && result.status === "cancelling") {
1592
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1593
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1594
+ if (Date.now() >= deadline) break;
1595
+ try {
1596
+ result = toResult(await postCancel(), firstTx);
1597
+ if (result.status === "cancelled") return result;
1598
+ } catch (e) {
1599
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1600
+ }
1601
+ try {
1602
+ const round = await this.rounds.get({ roundId: input.roundId });
1603
+ if (round.status === "cancelled") {
1604
+ return {
1605
+ roundId: input.roundId,
1606
+ txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
1607
+ status: "cancelled",
1608
+ round
1609
+ };
1610
+ }
1611
+ } catch (e) {
1612
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1613
+ }
1614
+ }
1615
+ return { ...result, txHash: result.txHash ?? firstTx ?? null };
1259
1616
  },
1260
1617
  get: async (input) => {
1261
1618
  requireField(input?.roundId, "roundId");
@@ -1406,7 +1763,7 @@ var Playmos = class {
1406
1763
  }
1407
1764
  prizePool = round.prizePoolAddress;
1408
1765
  }
1409
- const provider = resolveProvider(this.config.wallet);
1766
+ const provider = this.walletProvider();
1410
1767
  if (this.config.gas?.mode === "player") {
1411
1768
  const from0 = await getAccount(provider);
1412
1769
  await assertEnoughGas(provider, from0);
@@ -1423,18 +1780,31 @@ var Playmos = class {
1423
1780
  const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1424
1781
  let txHash;
1425
1782
  let status = "pending";
1783
+ let callsId;
1426
1784
  try {
1427
- const { id: callsId } = await sendCalls(
1785
+ const sent = await sendCalls(
1428
1786
  provider,
1429
1787
  from,
1430
1788
  this.env.chainId,
1431
1789
  [call],
1432
1790
  paymasterUrl
1433
1791
  );
1792
+ callsId = sent.id;
1434
1793
  const waited = await waitForCalls(provider, callsId);
1435
1794
  txHash = waited.txHash;
1436
1795
  status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1437
1796
  } catch (e) {
1797
+ if (e instanceof WalletTimeoutError && callsId) {
1798
+ return {
1799
+ prizePoolAddress: prizePool,
1800
+ amount: formatMicroToUsd(amountMicro),
1801
+ amountMicro: amountMicro.toString(),
1802
+ txHash,
1803
+ status: "pending",
1804
+ callsId
1805
+ };
1806
+ }
1807
+ if (e instanceof WalletTimeoutError) throw e;
1438
1808
  const msg = e?.message ?? String(e);
1439
1809
  if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1440
1810
  throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
@@ -1454,7 +1824,8 @@ var Playmos = class {
1454
1824
  amount: formatMicroToUsd(amountMicro),
1455
1825
  amountMicro: amountMicro.toString(),
1456
1826
  txHash,
1457
- status
1827
+ status,
1828
+ ...status === "pending" && callsId ? { callsId } : {}
1458
1829
  };
1459
1830
  }
1460
1831
  };
@@ -1701,13 +2072,99 @@ var Playmos = class {
1701
2072
  }
1702
2073
  }
1703
2074
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
2075
+ if (!config.mock) {
2076
+ try {
2077
+ resolveProvider(config.wallet, this.walletTimeoutPolicy());
2078
+ } catch {
2079
+ }
2080
+ }
2081
+ }
2082
+ /**
2083
+ * Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
2084
+ */
2085
+ findMockRound(idOrKey) {
2086
+ const direct = this.mockRounds.get(idOrKey);
2087
+ if (direct) return direct;
2088
+ for (const r of this.mockRounds.values()) {
2089
+ if (r.roundId === idOrKey || r.roundKey === idOrKey) return r;
2090
+ }
2091
+ return void 0;
2092
+ }
2093
+ /**
2094
+ * 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
2095
+ * (would zero 0.135 from top-n 30% of $0.45).
2096
+ */
2097
+ parseUsdToMicroLoose(amount) {
2098
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2099
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2100
+ }
2101
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
2102
+ const whole = wholeRaw ?? "0";
2103
+ const fracPadded = (frac + "000000").slice(0, 6);
2104
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2105
+ if (micro < 0n) throw new PayoutError(`amount must be >= 0, got: ${JSON.stringify(amount)}`);
2106
+ return micro;
2107
+ }
2108
+ /** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
2109
+ mockPayoutApiError(e) {
2110
+ const msg = e instanceof Error ? e.message : String(e);
2111
+ throw new ApiError(msg, { status: 400, code: "payout_invalid" });
2112
+ }
2113
+ /**
2114
+ * Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
2115
+ * No-op when no matching open/locked round — never 404 (C4).
2116
+ */
2117
+ mockAccumulateEntry(idOrKey, amountMicro) {
2118
+ const round = this.findMockRound(idOrKey);
2119
+ if (!round) return;
2120
+ if (round.status !== "open" && round.status !== "locked") return;
2121
+ const rake = amountMicro * BigInt(RAKE_BPS) / 10000n;
2122
+ const escrowed = amountMicro - rake;
2123
+ const pot = (this.mockPotMicro.get(round.roundId) ?? 0n) + escrowed;
2124
+ this.mockPotMicro.set(round.roundId, pot);
2125
+ const entrants = (round.entrants ?? 0) + 1;
2126
+ const updated = { ...round, entrants };
2127
+ this.mockRounds.set(round.roundId, updated);
2128
+ }
2129
+ /**
2130
+ * Payable pool micro for mock lock/settle.
2131
+ * With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
2132
+ * Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
2133
+ */
2134
+ mockPayablePoolMicro(round) {
2135
+ const pot = this.mockPotMicro.get(round.roundId) ?? 0n;
2136
+ if (pot > 0n) {
2137
+ return pot * BigInt(POOL_BPS) / BigInt(POOL_BPS + SEED_BPS);
2138
+ }
2139
+ try {
2140
+ return this.parseUsdToMicroLoose(round.entryAmount);
2141
+ } catch {
2142
+ return 0n;
2143
+ }
2144
+ }
2145
+ /**
2146
+ * Wallet timeout policy (#462 / PR #463 residual).
2147
+ * - connectTimeoutMs → eth_requestAccounts only
2148
+ * - machine RPCs → 20s default
2149
+ * - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
2150
+ */
2151
+ walletTimeoutPolicy() {
2152
+ const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
2153
+ return {
2154
+ connectTimeoutMs: connect,
2155
+ requestTimeoutMs: 2e4,
2156
+ sendCallsTimeoutMs: 0
2157
+ };
2158
+ }
2159
+ walletProvider() {
2160
+ return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
1704
2161
  }
1705
2162
  /** Connect the player's wallet and return their address. */
1706
2163
  async connect() {
1707
2164
  if (this.config.mock) {
1708
2165
  return "0x0000000000000000000000000000000000000001";
1709
2166
  }
1710
- return getAccount(resolveProvider(this.config.wallet));
2167
+ return getAccount(this.walletProvider());
1711
2168
  }
1712
2169
  /**
1713
2170
  * A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
@@ -1767,7 +2224,7 @@ var Playmos = class {
1767
2224
  })
1768
2225
  );
1769
2226
  }
1770
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2227
+ if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
1771
2228
  const res = await this.http.post(
1772
2229
  "/payments",
1773
2230
  {
@@ -1805,7 +2262,7 @@ var Playmos = class {
1805
2262
  },
1806
2263
  { idempotencyKey }
1807
2264
  );
1808
- const provider = resolveProvider(this.config.wallet);
2265
+ const provider = this.walletProvider();
1809
2266
  if (this.config.gas?.mode === "player") {
1810
2267
  const from0 = await getAccount(provider);
1811
2268
  await assertEnoughGas(provider, from0);
@@ -1833,6 +2290,7 @@ var Playmos = class {
1833
2290
  const metadata = validateMetadata(input.metadata);
1834
2291
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1835
2292
  if (this.config.mock) {
2293
+ this.mockAccumulateEntry(input.roundId, amountMicro);
1836
2294
  return this.rememberMock(
1837
2295
  mockEntryPayment({
1838
2296
  amountMicro,
@@ -1850,7 +2308,17 @@ var Playmos = class {
1850
2308
  })
1851
2309
  );
1852
2310
  }
1853
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2311
+ const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
2312
+ if (!serverSettleNoWallet) {
2313
+ const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
2314
+ if (!pinned) {
2315
+ throw new ConfigError(
2316
+ '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).',
2317
+ { field: "identity" }
2318
+ );
2319
+ }
2320
+ }
2321
+ if (serverSettleNoWallet) {
1854
2322
  const pinnedRoundKey = input.roundKey ?? input.roundId;
1855
2323
  const res = await this.http.post(
1856
2324
  "/payments",
@@ -1895,7 +2363,7 @@ var Playmos = class {
1895
2363
  },
1896
2364
  { idempotencyKey }
1897
2365
  );
1898
- const provider = resolveProvider(this.config.wallet);
2366
+ const provider = this.walletProvider();
1899
2367
  if (this.config.gas?.mode === "player") {
1900
2368
  const from0 = await getAccount(provider);
1901
2369
  await assertEnoughGas(provider, from0);
@@ -1906,12 +2374,28 @@ var Playmos = class {
1906
2374
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
1907
2375
  const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
1908
2376
  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}`;
2377
+ const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
2378
+ if (!identity || !String(identity).trim()) {
2379
+ throw new ConfigError(
2380
+ "enterRound: missing on-chain identity after create-intent (pass input.identity).",
2381
+ { field: "identity" }
2382
+ );
2383
+ }
1910
2384
  const amountUnits = this.resolveUnits(intent, amountMicro);
1911
2385
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
1912
- const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
2386
+ let callsId;
2387
+ try {
2388
+ ({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
2389
+ } catch (e) {
2390
+ throw mapAlreadyEntered(e);
2391
+ }
1913
2392
  const { txHash } = await waitForCalls(provider, callsId);
1914
- const payment = await this.settle(intent.payment.id, txHash);
2393
+ let payment;
2394
+ try {
2395
+ payment = await this.settle(intent.payment.id, txHash);
2396
+ } catch (e) {
2397
+ throw mapAlreadyEntered(e);
2398
+ }
1915
2399
  payment.identity = identity;
1916
2400
  payment.prizePoolAddress = prizePool.toLowerCase();
1917
2401
  payment.roundKey = roundKey;
@@ -2108,8 +2592,8 @@ var Playmos = class {
2108
2592
  });
2109
2593
  let raw;
2110
2594
  try {
2111
- if (await walletAvailable(this.config.wallet)) {
2112
- const provider = resolveProvider(this.config.wallet);
2595
+ if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
2596
+ const provider = this.walletProvider();
2113
2597
  raw = await provider.request({
2114
2598
  method: "eth_call",
2115
2599
  params: [{ to: prizePool, data }, "latest"]
@@ -2136,7 +2620,7 @@ var Playmos = class {
2136
2620
  raw = json.result;
2137
2621
  }
2138
2622
  } catch (e) {
2139
- if (e instanceof ApiError) throw e;
2623
+ if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
2140
2624
  throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
2141
2625
  prizePool,
2142
2626
  wallet
@@ -2227,112 +2711,6 @@ function previewPoolSplit(amount) {
2227
2711
  };
2228
2712
  }
2229
2713
 
2230
- // src/payout.ts
2231
- var PayoutError = class extends Error {
2232
- constructor(message) {
2233
- super(message);
2234
- this.code = "payout_invalid";
2235
- this.name = "PayoutError";
2236
- }
2237
- };
2238
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
2239
- var BPS = 10000n;
2240
- function requireAddress2(w, i) {
2241
- if (!ADDRESS_RE3.test(w)) {
2242
- throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
2243
- }
2244
- return w.toLowerCase();
2245
- }
2246
- function parseUsdToMicroLoose(amount) {
2247
- if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2248
- throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2249
- }
2250
- const [wholeRaw, frac = ""] = amount.trim().split(".");
2251
- const whole = wholeRaw ?? "0";
2252
- const fracPadded = (frac + "000000").slice(0, 6);
2253
- const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2254
- if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
2255
- return micro;
2256
- }
2257
- function computePayout(pool, ranking, rule) {
2258
- if (typeof pool !== "bigint" || pool <= 0n) {
2259
- throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
2260
- }
2261
- if (!Array.isArray(ranking) || ranking.length === 0) {
2262
- throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
2263
- }
2264
- const wallets = ranking.map((w, i) => requireAddress2(w, i));
2265
- const seen = /* @__PURE__ */ new Set();
2266
- for (const w of wallets) {
2267
- if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
2268
- seen.add(w);
2269
- }
2270
- if (rule.kind === "winner-take-all") {
2271
- return [{ wallet: wallets[0], amount: pool }];
2272
- }
2273
- if (rule.kind === "top-n") {
2274
- const splits = rule.splitsBps;
2275
- if (!Array.isArray(splits) || splits.length === 0) {
2276
- throw new PayoutError("top-n splitsBps must be a non-empty array");
2277
- }
2278
- if (splits.length > wallets.length) {
2279
- throw new PayoutError(
2280
- `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
2281
- );
2282
- }
2283
- let sumBps = 0;
2284
- for (const b of splits) {
2285
- if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
2286
- throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
2287
- }
2288
- sumBps += b;
2289
- }
2290
- if (sumBps !== 1e4) {
2291
- throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
2292
- }
2293
- const out = [];
2294
- let allocated = 0n;
2295
- for (let i = 0; i < splits.length; i++) {
2296
- const amt = pool * BigInt(splits[i]) / BPS;
2297
- out.push({ wallet: wallets[i], amount: amt });
2298
- allocated += amt;
2299
- }
2300
- const remainder = pool - allocated;
2301
- if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
2302
- out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
2303
- const filtered = out.filter((x) => x.amount > 0n);
2304
- if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
2305
- const total = filtered.reduce((s, x) => s + x.amount, 0n);
2306
- if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
2307
- return filtered;
2308
- }
2309
- if (rule.kind === "custom") {
2310
- const amounts = rule.amounts;
2311
- if (!Array.isArray(amounts) || amounts.length === 0) {
2312
- throw new PayoutError("custom amounts must be a non-empty array of USD strings");
2313
- }
2314
- if (amounts.length > wallets.length) {
2315
- throw new PayoutError(
2316
- `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
2317
- );
2318
- }
2319
- const out = [];
2320
- let total = 0n;
2321
- for (let i = 0; i < amounts.length; i++) {
2322
- const amt = parseUsdToMicroLoose(amounts[i]);
2323
- out.push({ wallet: wallets[i], amount: amt });
2324
- total += amt;
2325
- }
2326
- if (total !== pool) {
2327
- throw new PayoutError(
2328
- `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
2329
- );
2330
- }
2331
- return out;
2332
- }
2333
- throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
2334
- }
2335
-
2336
2714
  // src/settlement.ts
2337
2715
  var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
2338
2716
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
@@ -2426,6 +2804,7 @@ function isX402PayloadAuthorization(auth) {
2426
2804
  return auth.kind === "x402-payload";
2427
2805
  }
2428
2806
 
2807
+ exports.AlreadyEnteredError = AlreadyEnteredError;
2429
2808
  exports.ApiError = ApiError;
2430
2809
  exports.AuthError = AuthError;
2431
2810
  exports.CHAIN_ID = CHAIN_ID;
@@ -2443,6 +2822,7 @@ exports.PlaymosError = PlaymosError;
2443
2822
  exports.USDC_ADDRESS = USDC_ADDRESS;
2444
2823
  exports.USDC_DECIMALS = USDC_DECIMALS;
2445
2824
  exports.WalletConnectionError = WalletConnectionError;
2825
+ exports.WalletTimeoutError = WalletTimeoutError;
2446
2826
  exports.clientRuntimeSignals = clientRuntimeSignals;
2447
2827
  exports.computeIapSplit = computeIapSplit;
2448
2828
  exports.computePayout = computePayout;