@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.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
@@ -181,6 +181,112 @@ function validateMetadata(metadata) {
181
181
  return out;
182
182
  }
183
183
 
184
+ // src/payout.ts
185
+ var PayoutError = class extends Error {
186
+ constructor(message) {
187
+ super(message);
188
+ this.code = "payout_invalid";
189
+ this.name = "PayoutError";
190
+ }
191
+ };
192
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
193
+ var BPS = 10000n;
194
+ function requireAddress(w, i) {
195
+ if (!ADDRESS_RE.test(w)) {
196
+ throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
197
+ }
198
+ return w.toLowerCase();
199
+ }
200
+ function parseUsdToMicroLoose(amount) {
201
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
202
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
203
+ }
204
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
205
+ const whole = wholeRaw ?? "0";
206
+ const fracPadded = (frac + "000000").slice(0, 6);
207
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
208
+ if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
209
+ return micro;
210
+ }
211
+ function computePayout(pool, ranking, rule) {
212
+ if (typeof pool !== "bigint" || pool <= 0n) {
213
+ throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
214
+ }
215
+ if (!Array.isArray(ranking) || ranking.length === 0) {
216
+ throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
217
+ }
218
+ const wallets = ranking.map((w, i) => requireAddress(w, i));
219
+ const seen = /* @__PURE__ */ new Set();
220
+ for (const w of wallets) {
221
+ if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
222
+ seen.add(w);
223
+ }
224
+ if (rule.kind === "winner-take-all") {
225
+ return [{ wallet: wallets[0], amount: pool }];
226
+ }
227
+ if (rule.kind === "top-n") {
228
+ const splits = rule.splitsBps;
229
+ if (!Array.isArray(splits) || splits.length === 0) {
230
+ throw new PayoutError("top-n splitsBps must be a non-empty array");
231
+ }
232
+ if (splits.length > wallets.length) {
233
+ throw new PayoutError(
234
+ `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
235
+ );
236
+ }
237
+ let sumBps = 0;
238
+ for (const b of splits) {
239
+ if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
240
+ throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
241
+ }
242
+ sumBps += b;
243
+ }
244
+ if (sumBps !== 1e4) {
245
+ throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
246
+ }
247
+ const out = [];
248
+ let allocated = 0n;
249
+ for (let i = 0; i < splits.length; i++) {
250
+ const amt = pool * BigInt(splits[i]) / BPS;
251
+ out.push({ wallet: wallets[i], amount: amt });
252
+ allocated += amt;
253
+ }
254
+ const remainder = pool - allocated;
255
+ if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
256
+ out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
257
+ const filtered = out.filter((x) => x.amount > 0n);
258
+ if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
259
+ const total = filtered.reduce((s, x) => s + x.amount, 0n);
260
+ if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
261
+ return filtered;
262
+ }
263
+ if (rule.kind === "custom") {
264
+ const amounts = rule.amounts;
265
+ if (!Array.isArray(amounts) || amounts.length === 0) {
266
+ throw new PayoutError("custom amounts must be a non-empty array of USD strings");
267
+ }
268
+ if (amounts.length > wallets.length) {
269
+ throw new PayoutError(
270
+ `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
271
+ );
272
+ }
273
+ const out = [];
274
+ let total = 0n;
275
+ for (let i = 0; i < amounts.length; i++) {
276
+ const amt = parseUsdToMicroLoose(amounts[i]);
277
+ out.push({ wallet: wallets[i], amount: amt });
278
+ total += amt;
279
+ }
280
+ if (total !== pool) {
281
+ throw new PayoutError(
282
+ `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
283
+ );
284
+ }
285
+ return out;
286
+ }
287
+ throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
288
+ }
289
+
184
290
  // src/http.ts
185
291
  function resolveRetry(retry) {
186
292
  const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
@@ -329,7 +435,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
329
435
  const why = res.__playmosSignerBelowReason ?? "low_balance";
330
436
  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
437
  } else if (gateway) {
332
- 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).`;
438
+ 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
439
  } else {
334
440
  msg = `Non-JSON response (${res.status}) from ${res.url}`;
335
441
  }
@@ -371,7 +477,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
371
477
  }
372
478
 
373
479
  // src/wallet.ts
374
- function resolveProvider(wallet) {
480
+ var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
481
+ var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
482
+ "wallet_sendCalls",
483
+ "eth_sendTransaction",
484
+ "eth_sendRawTransaction"
485
+ ]);
486
+ var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
487
+ var TIMED_FLAG = "__playmosTimedRequest";
488
+ function resolvePolicy(opts) {
489
+ const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
490
+ const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
491
+ const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
492
+ return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
493
+ }
494
+ function timeoutMsForMethod(method, opts) {
495
+ const p = resolvePolicy(opts);
496
+ if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
497
+ if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
498
+ return p.requestTimeoutMs;
499
+ }
500
+ async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
501
+ if (timeoutMs <= 0) {
502
+ return provider.request(args);
503
+ }
504
+ let timer;
505
+ try {
506
+ return await Promise.race([
507
+ provider.request(args),
508
+ new Promise((_, reject) => {
509
+ timer = setTimeout(() => {
510
+ reject(
511
+ new WalletTimeoutError(
512
+ `Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
513
+ { method: args.method, timeoutMs }
514
+ )
515
+ );
516
+ }, timeoutMs);
517
+ })
518
+ ]);
519
+ } finally {
520
+ if (timer !== void 0) clearTimeout(timer);
521
+ }
522
+ }
523
+ function withTimeoutProvider(provider, opts) {
524
+ const flagged = provider;
525
+ if (flagged[TIMED_FLAG]) return provider;
526
+ const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
527
+ const wrapped = {
528
+ request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
529
+ };
530
+ Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
531
+ return wrapped;
532
+ }
533
+ function resolveRawProvider(wallet) {
375
534
  if (wallet?.provider) return wallet.provider;
376
535
  const injected = globalThis.ethereum;
377
536
  const connector = wallet?.connector ?? "base-account";
@@ -388,18 +547,23 @@ function resolveProvider(wallet) {
388
547
  "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."
389
548
  );
390
549
  }
391
- async function walletAvailable(wallet) {
550
+ function resolveProvider(wallet, opts) {
551
+ const raw = resolveRawProvider(wallet);
552
+ return withTimeoutProvider(raw, opts);
553
+ }
554
+ async function walletAvailable(wallet, opts) {
392
555
  if (wallet?.provider) return true;
393
556
  let provider;
394
557
  try {
395
- provider = resolveProvider(wallet);
558
+ provider = resolveProvider(wallet, opts);
396
559
  } catch {
397
560
  return false;
398
561
  }
399
562
  try {
400
563
  await getAccount(provider);
401
564
  return true;
402
- } catch {
565
+ } catch (e) {
566
+ if (e instanceof WalletTimeoutError) throw e;
403
567
  return false;
404
568
  }
405
569
  }
@@ -410,6 +574,7 @@ async function getAccount(provider) {
410
574
  if (!addr) throw new Error("no account");
411
575
  return addr;
412
576
  } catch (e) {
577
+ if (e instanceof WalletTimeoutError) throw e;
413
578
  throw new WalletConnectionError("Could not read the player's wallet account.", {
414
579
  cause: e?.message
415
580
  });
@@ -483,6 +648,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
483
648
  try {
484
649
  result = await provider.request({ method: "wallet_sendCalls", params: [params] });
485
650
  } catch (e) {
651
+ if (e instanceof WalletTimeoutError) throw e;
486
652
  const msg = e?.message ?? String(e);
487
653
  if (/reject|denied|cancel|closed/i.test(msg)) {
488
654
  throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
@@ -497,14 +663,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
497
663
  if (!id) return { status: "FAILED" };
498
664
  const deadline = Date.now() + timeoutMs;
499
665
  while (Date.now() < deadline) {
500
- const res = await provider.request({
501
- method: "wallet_getCallsStatus",
502
- params: [id]
503
- });
504
- const s = String(res?.status ?? "").toUpperCase();
505
- const txHash = res?.receipts?.[0]?.transactionHash;
506
- if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
507
- if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
666
+ try {
667
+ const res = await provider.request({
668
+ method: "wallet_getCallsStatus",
669
+ params: [id]
670
+ });
671
+ const s = String(res?.status ?? "").toUpperCase();
672
+ const txHash = res?.receipts?.[0]?.transactionHash;
673
+ if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
674
+ if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
675
+ } catch (e) {
676
+ if (e instanceof WalletTimeoutError) {
677
+ await new Promise((r) => setTimeout(r, 900));
678
+ continue;
679
+ }
680
+ throw e;
681
+ }
508
682
  await new Promise((r) => setTimeout(r, 900));
509
683
  }
510
684
  return { status: "PENDING" };
@@ -636,12 +810,12 @@ function mockVerifyResult(payment) {
636
810
  }
637
811
 
638
812
  // src/x402.ts
639
- var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
640
- function requireAddress(value, field) {
813
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
814
+ function requireAddress2(value, field) {
641
815
  if (typeof value !== "string" || value.trim() === "") {
642
816
  throw new MissingFieldError(field);
643
817
  }
644
- if (!ADDRESS_RE.test(value)) {
818
+ if (!ADDRESS_RE2.test(value)) {
645
819
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
646
820
  field,
647
821
  value
@@ -711,7 +885,7 @@ function createX402Challenge(requirement, extras) {
711
885
  if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
712
886
  throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
713
887
  }
714
- requireAddress(requirement.payTo, "payTo");
888
+ requireAddress2(requirement.payTo, "payTo");
715
889
  parseUsdToMicro(requirement.amount);
716
890
  const feeBps = extras?.feeBps ?? 0;
717
891
  if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
@@ -721,7 +895,7 @@ function createX402Challenge(requirement, extras) {
721
895
  );
722
896
  }
723
897
  const feeSink = extras?.feeSink ?? null;
724
- if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
898
+ if (feeBps > 0 && feeSink) requireAddress2(feeSink, "feeSink");
725
899
  const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
726
900
  return {
727
901
  status: 402,
@@ -749,7 +923,7 @@ function validateX402ChallengeInput(input) {
749
923
  { field: "url" }
750
924
  );
751
925
  }
752
- const payTo = requireAddress(input.payTo, "payTo");
926
+ const payTo = requireAddress2(input.payTo, "payTo");
753
927
  parseUsdToMicro(input.amount);
754
928
  const intent = input.intent ?? "transfer";
755
929
  if (intent !== "transfer" && intent !== "marketplace.buy") {
@@ -765,7 +939,7 @@ function validateX402ChallengeInput(input) {
765
939
  { feeBps: input.feeBps }
766
940
  );
767
941
  }
768
- const feeSink = input.feeSink === void 0 ? void 0 : requireAddress(input.feeSink, "feeSink");
942
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddress2(input.feeSink, "feeSink");
769
943
  return {
770
944
  payTo,
771
945
  amount: input.amount,
@@ -779,12 +953,55 @@ function validateX402ChallengeInput(input) {
779
953
  }
780
954
 
781
955
  // src/client.ts
782
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
956
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
957
+ function mapAlreadyEntered(e) {
958
+ const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
959
+ const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
960
+ const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
961
+ if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
962
+ throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
963
+ }
964
+ throw e;
965
+ }
966
+ var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
967
+ var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
968
+ function isSoftRoundPollError(e, kind) {
969
+ if (e instanceof AuthError || e instanceof ConfigError) return false;
970
+ if (e instanceof ApiError) {
971
+ const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
972
+ const msg = e.message;
973
+ if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
974
+ return true;
975
+ }
976
+ if (kind === "settle") {
977
+ if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
978
+ return false;
979
+ }
980
+ } else {
981
+ if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
982
+ msg
983
+ )) {
984
+ return false;
985
+ }
986
+ }
987
+ if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
988
+ return true;
989
+ }
990
+ if (status !== void 0 && status >= 400 && status < 500) return false;
991
+ if (status !== void 0 && status >= 500) return true;
992
+ }
993
+ return true;
994
+ }
995
+ function isSoftSettlePollError(e) {
996
+ return isSoftRoundPollError(e, "settle");
997
+ }
998
+ var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
999
+ var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
783
1000
  function requireAddressField(value, field) {
784
1001
  if (typeof value !== "string" || value.trim() === "") {
785
1002
  throw new MissingFieldError(field);
786
1003
  }
787
- if (!ADDRESS_RE2.test(value)) {
1004
+ if (!ADDRESS_RE3.test(value)) {
788
1005
  throw new ConfigError(
789
1006
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
790
1007
  { field, value }
@@ -972,11 +1189,22 @@ var Playmos = class {
972
1189
  * rounds.settle({ ranking }) → contract pays winners.
973
1190
  *
974
1191
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
1192
+ *
1193
+ * Offline `mock: true`: settle applies the stored payout rule and the 60/30/10
1194
+ * pool leg across entries (#490). No series seed bank — mock pool is a floor,
1195
+ * not a forecast of a live series with inherited seed.
975
1196
  */
976
1197
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
977
1198
  this.mockRounds = /* @__PURE__ */ new Map();
978
1199
  /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
979
1200
  this.mockWithdrawable = /* @__PURE__ */ new Map();
1201
+ /**
1202
+ * Mock pot after per-entry rake (PrizePool escrowed sum) — keyed by roundId (#490).
1203
+ * Payable pool at lock = pot × POOL_BPS / (POOL_BPS + SEED_BPS); inherited seed = 0 offline.
1204
+ */
1205
+ this.mockPotMicro = /* @__PURE__ */ new Map();
1206
+ /** Persisted settle winners for re-settle replay (#490 C2) — never re-invent pool-to-everyone. */
1207
+ this.mockSettleWinners = /* @__PURE__ */ new Map();
980
1208
  this.rounds = {
981
1209
  open: async (input) => {
982
1210
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
@@ -1015,6 +1243,8 @@ var Playmos = class {
1015
1243
  prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1016
1244
  };
1017
1245
  this.mockRounds.set(input.roundId, round);
1246
+ this.mockPotMicro.set(input.roundId, 0n);
1247
+ this.mockSettleWinners.delete(input.roundId);
1018
1248
  return round;
1019
1249
  }
1020
1250
  const body = await this.http.post(
@@ -1047,11 +1277,12 @@ var Playmos = class {
1047
1277
  code: "conflict"
1048
1278
  });
1049
1279
  }
1280
+ const payableMicro = this.mockPayablePoolMicro(existing);
1281
+ const poolUsd = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
1050
1282
  const locked = {
1051
1283
  ...existing,
1052
1284
  status: "locked",
1053
- // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1054
- pool: existing.pool ?? existing.entryAmount,
1285
+ pool: poolUsd,
1055
1286
  entrants: existing.entrants ?? 0,
1056
1287
  lockTxHash: MOCK_TX
1057
1288
  };
@@ -1074,14 +1305,15 @@ var Playmos = class {
1074
1305
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1075
1306
  }
1076
1307
  if (existing.status === "settled" && existing.settleTxHash) {
1308
+ const stored = this.mockSettleWinners.get(input.roundId);
1077
1309
  return {
1078
1310
  roundId: input.roundId,
1079
1311
  txHash: existing.settleTxHash,
1080
1312
  poolPaid: existing.pool ?? "0",
1081
- winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1313
+ winners: stored ?? ("winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1082
1314
  wallet,
1083
- amount: existing.pool ?? existing.entryAmount
1084
- })),
1315
+ amount: "0"
1316
+ }))),
1085
1317
  status: "settled"
1086
1318
  };
1087
1319
  }
@@ -1091,13 +1323,45 @@ var Playmos = class {
1091
1323
  code: "conflict"
1092
1324
  });
1093
1325
  }
1094
- const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1095
- wallet,
1096
- // Winner-take-all mock shape when ranking only.
1097
- amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1098
- }));
1326
+ const payableMicro = existing.pool != null ? this.parseUsdToMicroLoose(existing.pool) : this.mockPayablePoolMicro(existing);
1327
+ const poolPaid = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
1328
+ let rows;
1329
+ if ("winners" in input.results) {
1330
+ rows = input.results.winners.map((w) => {
1331
+ let micro;
1332
+ try {
1333
+ micro = this.parseUsdToMicroLoose(String(w.amount ?? "0"));
1334
+ } catch (e) {
1335
+ this.mockPayoutApiError(e);
1336
+ }
1337
+ return {
1338
+ wallet: w.wallet,
1339
+ amount: formatMicroToUsd(micro),
1340
+ micro
1341
+ };
1342
+ });
1343
+ } else {
1344
+ if (payableMicro <= 0n) {
1345
+ rows = [];
1346
+ } else {
1347
+ try {
1348
+ const computed = computePayout(
1349
+ payableMicro,
1350
+ input.results.ranking,
1351
+ existing.payout
1352
+ );
1353
+ rows = computed.map((c) => ({
1354
+ wallet: c.wallet,
1355
+ amount: formatMicroToUsd(c.amount),
1356
+ micro: c.amount
1357
+ }));
1358
+ } catch (e) {
1359
+ this.mockPayoutApiError(e);
1360
+ }
1361
+ }
1362
+ }
1363
+ const winners = rows.map(({ wallet, amount }) => ({ wallet, amount }));
1099
1364
  const settleTxHash = MOCK_TX;
1100
- const poolPaid = existing.pool ?? existing.entryAmount;
1101
1365
  const settled = {
1102
1366
  ...existing,
1103
1367
  status: "settled",
@@ -1105,18 +1369,11 @@ var Playmos = class {
1105
1369
  pool: poolPaid
1106
1370
  };
1107
1371
  this.mockRounds.set(input.roundId, settled);
1108
- for (const w of winners) {
1109
- const wAddr = String(w.wallet).toLowerCase();
1110
- let micro = 0n;
1111
- try {
1112
- micro = validateAmount(String(w.amount ?? "0"));
1113
- } catch {
1114
- micro = 0n;
1115
- }
1116
- if (micro > 0n) {
1117
- const k = `${input.roundId}:${wAddr}`;
1118
- this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
1119
- }
1372
+ this.mockSettleWinners.set(input.roundId, winners);
1373
+ for (const w of rows) {
1374
+ if (w.micro <= 0n) continue;
1375
+ const k = `${input.roundId}:${w.wallet.toLowerCase()}`;
1376
+ this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + w.micro);
1120
1377
  }
1121
1378
  return {
1122
1379
  roundId: input.roundId,
@@ -1126,17 +1383,61 @@ var Playmos = class {
1126
1383
  status: "settled"
1127
1384
  };
1128
1385
  }
1129
- const { settle } = await this.http.post(
1386
+ const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
1387
+ const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
1388
+ const body = { gameId: input.gameId, results: input.results };
1389
+ const idempotencyKey = `settle:${input.roundId}`;
1390
+ const postSettle = () => this.http.post(
1130
1391
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1131
- { gameId: input.gameId, results: input.results },
1132
- { acceptStatuses: [202] }
1392
+ body,
1393
+ { acceptStatuses: [200, 202], idempotencyKey }
1133
1394
  );
1134
- return settle;
1395
+ let { settle } = await postSettle();
1396
+ if (settle.status === "settled") return settle;
1397
+ const firstWinners = settle.winners;
1398
+ const firstPoolPaid = settle.poolPaid ?? null;
1399
+ const withFirstExtras = (s) => ({
1400
+ ...s,
1401
+ winners: s.winners ?? firstWinners,
1402
+ poolPaid: s.poolPaid ?? firstPoolPaid
1403
+ });
1404
+ const deadline = Date.now() + timeoutMs;
1405
+ while (Date.now() < deadline && settle.status === "settling") {
1406
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1407
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1408
+ if (Date.now() >= deadline) break;
1409
+ try {
1410
+ const again = await postSettle();
1411
+ settle = withFirstExtras(again.settle);
1412
+ if (settle.status === "settled") return settle;
1413
+ } catch (e) {
1414
+ if (!isSoftSettlePollError(e)) throw e;
1415
+ }
1416
+ try {
1417
+ const round = await this.rounds.get({ roundId: input.roundId });
1418
+ if (round.status === "settled") {
1419
+ return {
1420
+ roundId: input.roundId,
1421
+ txHash: round.settleTxHash ?? settle.txHash ?? null,
1422
+ poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
1423
+ winners: settle.winners ?? firstWinners,
1424
+ status: "settled"
1425
+ };
1426
+ }
1427
+ } catch (e) {
1428
+ if (!isSoftSettlePollError(e)) throw e;
1429
+ }
1430
+ }
1431
+ return withFirstExtras(settle);
1135
1432
  },
1136
1433
  /**
1137
1434
  * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
1138
- * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
1139
- * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1435
+ * and frees the series latch (#346 / #376).
1436
+ *
1437
+ * **Default is submit-only:** may return `status: "cancelling"` after broadcast —
1438
+ * that is **not** success. Only `status: "cancelled"` means the chain is terminal.
1439
+ * Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
1440
+ * if still pending — never invents terminal success).
1140
1441
  */
1141
1442
  cancel: async (input) => {
1142
1443
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
@@ -1164,9 +1465,12 @@ var Playmos = class {
1164
1465
  ...existing,
1165
1466
  status: "cancelled",
1166
1467
  cancelTxHash: MOCK_TX,
1167
- pool: null
1468
+ pool: null,
1469
+ entrants: 0
1168
1470
  };
1169
1471
  this.mockRounds.set(input.roundId, cancelled);
1472
+ this.mockPotMicro.set(input.roundId, 0n);
1473
+ this.mockSettleWinners.delete(input.roundId);
1170
1474
  return {
1171
1475
  roundId: input.roundId,
1172
1476
  txHash: MOCK_TX,
@@ -1174,18 +1478,57 @@ var Playmos = class {
1174
1478
  round: cancelled
1175
1479
  };
1176
1480
  }
1177
- const body = await this.http.post(
1178
- `/rounds/${encodeURIComponent(input.roundId)}/cancel`,
1179
- { gameId: input.gameId },
1180
- { acceptStatuses: [202] }
1181
- );
1182
- const status = body.status === "cancelling" || body.round?.status === "cancelling" ? "cancelling" : "cancelled";
1183
- return {
1184
- roundId: input.roundId,
1185
- txHash: body.txHash ?? body.round?.cancelTxHash ?? null,
1186
- status,
1187
- round: body.round
1481
+ const bodyPayload = { gameId: input.gameId };
1482
+ const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
1483
+ acceptStatuses: [200, 202]
1484
+ });
1485
+ const toResult = (body, priorTx) => {
1486
+ const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
1487
+ const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
1488
+ const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
1489
+ const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
1490
+ return {
1491
+ roundId: input.roundId,
1492
+ txHash: tx,
1493
+ status,
1494
+ round: body.round
1495
+ };
1188
1496
  };
1497
+ let result = toResult(await postCancel());
1498
+ if (result.status === "cancelled") return result;
1499
+ if (!input.confirm) return result;
1500
+ const timeoutMs = Math.min(
1501
+ Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
1502
+ CANCEL_RECONCILE_TIMEOUT_MS
1503
+ );
1504
+ const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
1505
+ const deadline = Date.now() + timeoutMs;
1506
+ const firstTx = result.txHash;
1507
+ while (Date.now() < deadline && result.status === "cancelling") {
1508
+ const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
1509
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
1510
+ if (Date.now() >= deadline) break;
1511
+ try {
1512
+ result = toResult(await postCancel(), firstTx);
1513
+ if (result.status === "cancelled") return result;
1514
+ } catch (e) {
1515
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1516
+ }
1517
+ try {
1518
+ const round = await this.rounds.get({ roundId: input.roundId });
1519
+ if (round.status === "cancelled") {
1520
+ return {
1521
+ roundId: input.roundId,
1522
+ txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
1523
+ status: "cancelled",
1524
+ round
1525
+ };
1526
+ }
1527
+ } catch (e) {
1528
+ if (!isSoftRoundPollError(e, "cancel")) throw e;
1529
+ }
1530
+ }
1531
+ return { ...result, txHash: result.txHash ?? firstTx ?? null };
1189
1532
  },
1190
1533
  get: async (input) => {
1191
1534
  requireField(input?.roundId, "roundId");
@@ -1336,7 +1679,7 @@ var Playmos = class {
1336
1679
  }
1337
1680
  prizePool = round.prizePoolAddress;
1338
1681
  }
1339
- const provider = resolveProvider(this.config.wallet);
1682
+ const provider = this.walletProvider();
1340
1683
  if (this.config.gas?.mode === "player") {
1341
1684
  const from0 = await getAccount(provider);
1342
1685
  await assertEnoughGas(provider, from0);
@@ -1353,18 +1696,31 @@ var Playmos = class {
1353
1696
  const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
1354
1697
  let txHash;
1355
1698
  let status = "pending";
1699
+ let callsId;
1356
1700
  try {
1357
- const { id: callsId } = await sendCalls(
1701
+ const sent = await sendCalls(
1358
1702
  provider,
1359
1703
  from,
1360
1704
  this.env.chainId,
1361
1705
  [call],
1362
1706
  paymasterUrl
1363
1707
  );
1708
+ callsId = sent.id;
1364
1709
  const waited = await waitForCalls(provider, callsId);
1365
1710
  txHash = waited.txHash;
1366
1711
  status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
1367
1712
  } catch (e) {
1713
+ if (e instanceof WalletTimeoutError && callsId) {
1714
+ return {
1715
+ prizePoolAddress: prizePool,
1716
+ amount: formatMicroToUsd(amountMicro),
1717
+ amountMicro: amountMicro.toString(),
1718
+ txHash,
1719
+ status: "pending",
1720
+ callsId
1721
+ };
1722
+ }
1723
+ if (e instanceof WalletTimeoutError) throw e;
1368
1724
  const msg = e?.message ?? String(e);
1369
1725
  if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
1370
1726
  throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
@@ -1384,7 +1740,8 @@ var Playmos = class {
1384
1740
  amount: formatMicroToUsd(amountMicro),
1385
1741
  amountMicro: amountMicro.toString(),
1386
1742
  txHash,
1387
- status
1743
+ status,
1744
+ ...status === "pending" && callsId ? { callsId } : {}
1388
1745
  };
1389
1746
  }
1390
1747
  };
@@ -1631,13 +1988,99 @@ var Playmos = class {
1631
1988
  }
1632
1989
  }
1633
1990
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
1991
+ if (!config.mock) {
1992
+ try {
1993
+ resolveProvider(config.wallet, this.walletTimeoutPolicy());
1994
+ } catch {
1995
+ }
1996
+ }
1997
+ }
1998
+ /**
1999
+ * Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
2000
+ */
2001
+ findMockRound(idOrKey) {
2002
+ const direct = this.mockRounds.get(idOrKey);
2003
+ if (direct) return direct;
2004
+ for (const r of this.mockRounds.values()) {
2005
+ if (r.roundId === idOrKey || r.roundKey === idOrKey) return r;
2006
+ }
2007
+ return void 0;
2008
+ }
2009
+ /**
2010
+ * 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
2011
+ * (would zero 0.135 from top-n 30% of $0.45).
2012
+ */
2013
+ parseUsdToMicroLoose(amount) {
2014
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2015
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2016
+ }
2017
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
2018
+ const whole = wholeRaw ?? "0";
2019
+ const fracPadded = (frac + "000000").slice(0, 6);
2020
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2021
+ if (micro < 0n) throw new PayoutError(`amount must be >= 0, got: ${JSON.stringify(amount)}`);
2022
+ return micro;
2023
+ }
2024
+ /** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
2025
+ mockPayoutApiError(e) {
2026
+ const msg = e instanceof Error ? e.message : String(e);
2027
+ throw new ApiError(msg, { status: 400, code: "payout_invalid" });
2028
+ }
2029
+ /**
2030
+ * Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
2031
+ * No-op when no matching open/locked round — never 404 (C4).
2032
+ */
2033
+ mockAccumulateEntry(idOrKey, amountMicro) {
2034
+ const round = this.findMockRound(idOrKey);
2035
+ if (!round) return;
2036
+ if (round.status !== "open" && round.status !== "locked") return;
2037
+ const rake = amountMicro * BigInt(RAKE_BPS) / 10000n;
2038
+ const escrowed = amountMicro - rake;
2039
+ const pot = (this.mockPotMicro.get(round.roundId) ?? 0n) + escrowed;
2040
+ this.mockPotMicro.set(round.roundId, pot);
2041
+ const entrants = (round.entrants ?? 0) + 1;
2042
+ const updated = { ...round, entrants };
2043
+ this.mockRounds.set(round.roundId, updated);
2044
+ }
2045
+ /**
2046
+ * Payable pool micro for mock lock/settle.
2047
+ * With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
2048
+ * Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
2049
+ */
2050
+ mockPayablePoolMicro(round) {
2051
+ const pot = this.mockPotMicro.get(round.roundId) ?? 0n;
2052
+ if (pot > 0n) {
2053
+ return pot * BigInt(POOL_BPS) / BigInt(POOL_BPS + SEED_BPS);
2054
+ }
2055
+ try {
2056
+ return this.parseUsdToMicroLoose(round.entryAmount);
2057
+ } catch {
2058
+ return 0n;
2059
+ }
2060
+ }
2061
+ /**
2062
+ * Wallet timeout policy (#462 / PR #463 residual).
2063
+ * - connectTimeoutMs → eth_requestAccounts only
2064
+ * - machine RPCs → 20s default
2065
+ * - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
2066
+ */
2067
+ walletTimeoutPolicy() {
2068
+ const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
2069
+ return {
2070
+ connectTimeoutMs: connect,
2071
+ requestTimeoutMs: 2e4,
2072
+ sendCallsTimeoutMs: 0
2073
+ };
2074
+ }
2075
+ walletProvider() {
2076
+ return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
1634
2077
  }
1635
2078
  /** Connect the player's wallet and return their address. */
1636
2079
  async connect() {
1637
2080
  if (this.config.mock) {
1638
2081
  return "0x0000000000000000000000000000000000000001";
1639
2082
  }
1640
- return getAccount(resolveProvider(this.config.wallet));
2083
+ return getAccount(this.walletProvider());
1641
2084
  }
1642
2085
  /**
1643
2086
  * A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
@@ -1697,7 +2140,7 @@ var Playmos = class {
1697
2140
  })
1698
2141
  );
1699
2142
  }
1700
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2143
+ if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
1701
2144
  const res = await this.http.post(
1702
2145
  "/payments",
1703
2146
  {
@@ -1735,7 +2178,7 @@ var Playmos = class {
1735
2178
  },
1736
2179
  { idempotencyKey }
1737
2180
  );
1738
- const provider = resolveProvider(this.config.wallet);
2181
+ const provider = this.walletProvider();
1739
2182
  if (this.config.gas?.mode === "player") {
1740
2183
  const from0 = await getAccount(provider);
1741
2184
  await assertEnoughGas(provider, from0);
@@ -1763,6 +2206,7 @@ var Playmos = class {
1763
2206
  const metadata = validateMetadata(input.metadata);
1764
2207
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1765
2208
  if (this.config.mock) {
2209
+ this.mockAccumulateEntry(input.roundId, amountMicro);
1766
2210
  return this.rememberMock(
1767
2211
  mockEntryPayment({
1768
2212
  amountMicro,
@@ -1780,7 +2224,17 @@ var Playmos = class {
1780
2224
  })
1781
2225
  );
1782
2226
  }
1783
- if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
2227
+ const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
2228
+ if (!serverSettleNoWallet) {
2229
+ const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
2230
+ if (!pinned) {
2231
+ throw new ConfigError(
2232
+ '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).',
2233
+ { field: "identity" }
2234
+ );
2235
+ }
2236
+ }
2237
+ if (serverSettleNoWallet) {
1784
2238
  const pinnedRoundKey = input.roundKey ?? input.roundId;
1785
2239
  const res = await this.http.post(
1786
2240
  "/payments",
@@ -1825,7 +2279,7 @@ var Playmos = class {
1825
2279
  },
1826
2280
  { idempotencyKey }
1827
2281
  );
1828
- const provider = resolveProvider(this.config.wallet);
2282
+ const provider = this.walletProvider();
1829
2283
  if (this.config.gas?.mode === "player") {
1830
2284
  const from0 = await getAccount(provider);
1831
2285
  await assertEnoughGas(provider, from0);
@@ -1836,12 +2290,28 @@ var Playmos = class {
1836
2290
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
1837
2291
  const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
1838
2292
  if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
1839
- const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity ?? `${from}#${idempotencyKey}`;
2293
+ const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
2294
+ if (!identity || !String(identity).trim()) {
2295
+ throw new ConfigError(
2296
+ "enterRound: missing on-chain identity after create-intent (pass input.identity).",
2297
+ { field: "identity" }
2298
+ );
2299
+ }
1840
2300
  const amountUnits = this.resolveUnits(intent, amountMicro);
1841
2301
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
1842
- const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
2302
+ let callsId;
2303
+ try {
2304
+ ({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
2305
+ } catch (e) {
2306
+ throw mapAlreadyEntered(e);
2307
+ }
1843
2308
  const { txHash } = await waitForCalls(provider, callsId);
1844
- const payment = await this.settle(intent.payment.id, txHash);
2309
+ let payment;
2310
+ try {
2311
+ payment = await this.settle(intent.payment.id, txHash);
2312
+ } catch (e) {
2313
+ throw mapAlreadyEntered(e);
2314
+ }
1845
2315
  payment.identity = identity;
1846
2316
  payment.prizePoolAddress = prizePool.toLowerCase();
1847
2317
  payment.roundKey = roundKey;
@@ -2038,8 +2508,8 @@ var Playmos = class {
2038
2508
  });
2039
2509
  let raw;
2040
2510
  try {
2041
- if (await walletAvailable(this.config.wallet)) {
2042
- const provider = resolveProvider(this.config.wallet);
2511
+ if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
2512
+ const provider = this.walletProvider();
2043
2513
  raw = await provider.request({
2044
2514
  method: "eth_call",
2045
2515
  params: [{ to: prizePool, data }, "latest"]
@@ -2066,7 +2536,7 @@ var Playmos = class {
2066
2536
  raw = json.result;
2067
2537
  }
2068
2538
  } catch (e) {
2069
- if (e instanceof ApiError) throw e;
2539
+ if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
2070
2540
  throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
2071
2541
  prizePool,
2072
2542
  wallet
@@ -2157,112 +2627,6 @@ function previewPoolSplit(amount) {
2157
2627
  };
2158
2628
  }
2159
2629
 
2160
- // src/payout.ts
2161
- var PayoutError = class extends Error {
2162
- constructor(message) {
2163
- super(message);
2164
- this.code = "payout_invalid";
2165
- this.name = "PayoutError";
2166
- }
2167
- };
2168
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
2169
- var BPS = 10000n;
2170
- function requireAddress2(w, i) {
2171
- if (!ADDRESS_RE3.test(w)) {
2172
- throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
2173
- }
2174
- return w.toLowerCase();
2175
- }
2176
- function parseUsdToMicroLoose(amount) {
2177
- if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2178
- throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2179
- }
2180
- const [wholeRaw, frac = ""] = amount.trim().split(".");
2181
- const whole = wholeRaw ?? "0";
2182
- const fracPadded = (frac + "000000").slice(0, 6);
2183
- const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2184
- if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
2185
- return micro;
2186
- }
2187
- function computePayout(pool, ranking, rule) {
2188
- if (typeof pool !== "bigint" || pool <= 0n) {
2189
- throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
2190
- }
2191
- if (!Array.isArray(ranking) || ranking.length === 0) {
2192
- throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
2193
- }
2194
- const wallets = ranking.map((w, i) => requireAddress2(w, i));
2195
- const seen = /* @__PURE__ */ new Set();
2196
- for (const w of wallets) {
2197
- if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
2198
- seen.add(w);
2199
- }
2200
- if (rule.kind === "winner-take-all") {
2201
- return [{ wallet: wallets[0], amount: pool }];
2202
- }
2203
- if (rule.kind === "top-n") {
2204
- const splits = rule.splitsBps;
2205
- if (!Array.isArray(splits) || splits.length === 0) {
2206
- throw new PayoutError("top-n splitsBps must be a non-empty array");
2207
- }
2208
- if (splits.length > wallets.length) {
2209
- throw new PayoutError(
2210
- `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
2211
- );
2212
- }
2213
- let sumBps = 0;
2214
- for (const b of splits) {
2215
- if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
2216
- throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
2217
- }
2218
- sumBps += b;
2219
- }
2220
- if (sumBps !== 1e4) {
2221
- throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
2222
- }
2223
- const out = [];
2224
- let allocated = 0n;
2225
- for (let i = 0; i < splits.length; i++) {
2226
- const amt = pool * BigInt(splits[i]) / BPS;
2227
- out.push({ wallet: wallets[i], amount: amt });
2228
- allocated += amt;
2229
- }
2230
- const remainder = pool - allocated;
2231
- if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
2232
- out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
2233
- const filtered = out.filter((x) => x.amount > 0n);
2234
- if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
2235
- const total = filtered.reduce((s, x) => s + x.amount, 0n);
2236
- if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
2237
- return filtered;
2238
- }
2239
- if (rule.kind === "custom") {
2240
- const amounts = rule.amounts;
2241
- if (!Array.isArray(amounts) || amounts.length === 0) {
2242
- throw new PayoutError("custom amounts must be a non-empty array of USD strings");
2243
- }
2244
- if (amounts.length > wallets.length) {
2245
- throw new PayoutError(
2246
- `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
2247
- );
2248
- }
2249
- const out = [];
2250
- let total = 0n;
2251
- for (let i = 0; i < amounts.length; i++) {
2252
- const amt = parseUsdToMicroLoose(amounts[i]);
2253
- out.push({ wallet: wallets[i], amount: amt });
2254
- total += amt;
2255
- }
2256
- if (total !== pool) {
2257
- throw new PayoutError(
2258
- `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
2259
- );
2260
- }
2261
- return out;
2262
- }
2263
- throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
2264
- }
2265
-
2266
2630
  // src/settlement.ts
2267
2631
  var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
2268
2632
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;