@playmos/sdk 0.3.8 → 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
@@ -265,6 +265,112 @@ function validateMetadata(metadata) {
265
265
  return out;
266
266
  }
267
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
+
268
374
  // src/http.ts
269
375
  function resolveRetry(retry) {
270
376
  const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
@@ -788,12 +894,12 @@ function mockVerifyResult(payment) {
788
894
  }
789
895
 
790
896
  // src/x402.ts
791
- var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
792
- function requireAddress(value, field) {
897
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
898
+ function requireAddress2(value, field) {
793
899
  if (typeof value !== "string" || value.trim() === "") {
794
900
  throw new MissingFieldError(field);
795
901
  }
796
- if (!ADDRESS_RE.test(value)) {
902
+ if (!ADDRESS_RE2.test(value)) {
797
903
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
798
904
  field,
799
905
  value
@@ -863,7 +969,7 @@ function createX402Challenge(requirement, extras) {
863
969
  if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
864
970
  throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
865
971
  }
866
- requireAddress(requirement.payTo, "payTo");
972
+ requireAddress2(requirement.payTo, "payTo");
867
973
  parseUsdToMicro(requirement.amount);
868
974
  const feeBps = extras?.feeBps ?? 0;
869
975
  if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
@@ -873,7 +979,7 @@ function createX402Challenge(requirement, extras) {
873
979
  );
874
980
  }
875
981
  const feeSink = extras?.feeSink ?? null;
876
- if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
982
+ if (feeBps > 0 && feeSink) requireAddress2(feeSink, "feeSink");
877
983
  const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
878
984
  return {
879
985
  status: 402,
@@ -901,7 +1007,7 @@ function validateX402ChallengeInput(input) {
901
1007
  { field: "url" }
902
1008
  );
903
1009
  }
904
- const payTo = requireAddress(input.payTo, "payTo");
1010
+ const payTo = requireAddress2(input.payTo, "payTo");
905
1011
  parseUsdToMicro(input.amount);
906
1012
  const intent = input.intent ?? "transfer";
907
1013
  if (intent !== "transfer" && intent !== "marketplace.buy") {
@@ -917,7 +1023,7 @@ function validateX402ChallengeInput(input) {
917
1023
  { feeBps: input.feeBps }
918
1024
  );
919
1025
  }
920
- 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");
921
1027
  return {
922
1028
  payTo,
923
1029
  amount: input.amount,
@@ -931,7 +1037,7 @@ function validateX402ChallengeInput(input) {
931
1037
  }
932
1038
 
933
1039
  // src/client.ts
934
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1040
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
935
1041
  function mapAlreadyEntered(e) {
936
1042
  const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
937
1043
  const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
@@ -979,7 +1085,7 @@ function requireAddressField(value, field) {
979
1085
  if (typeof value !== "string" || value.trim() === "") {
980
1086
  throw new MissingFieldError(field);
981
1087
  }
982
- if (!ADDRESS_RE2.test(value)) {
1088
+ if (!ADDRESS_RE3.test(value)) {
983
1089
  throw new ConfigError(
984
1090
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
985
1091
  { field, value }
@@ -1167,11 +1273,22 @@ var Playmos = class {
1167
1273
  * rounds.settle({ ranking }) → contract pays winners.
1168
1274
  *
1169
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.
1170
1280
  */
1171
1281
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
1172
1282
  this.mockRounds = /* @__PURE__ */ new Map();
1173
1283
  /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
1174
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();
1175
1292
  this.rounds = {
1176
1293
  open: async (input) => {
1177
1294
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
@@ -1210,6 +1327,8 @@ var Playmos = class {
1210
1327
  prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1211
1328
  };
1212
1329
  this.mockRounds.set(input.roundId, round);
1330
+ this.mockPotMicro.set(input.roundId, 0n);
1331
+ this.mockSettleWinners.delete(input.roundId);
1213
1332
  return round;
1214
1333
  }
1215
1334
  const body = await this.http.post(
@@ -1242,11 +1361,12 @@ var Playmos = class {
1242
1361
  code: "conflict"
1243
1362
  });
1244
1363
  }
1364
+ const payableMicro = this.mockPayablePoolMicro(existing);
1365
+ const poolUsd = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
1245
1366
  const locked = {
1246
1367
  ...existing,
1247
1368
  status: "locked",
1248
- // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1249
- pool: existing.pool ?? existing.entryAmount,
1369
+ pool: poolUsd,
1250
1370
  entrants: existing.entrants ?? 0,
1251
1371
  lockTxHash: MOCK_TX
1252
1372
  };
@@ -1269,14 +1389,15 @@ var Playmos = class {
1269
1389
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1270
1390
  }
1271
1391
  if (existing.status === "settled" && existing.settleTxHash) {
1392
+ const stored = this.mockSettleWinners.get(input.roundId);
1272
1393
  return {
1273
1394
  roundId: input.roundId,
1274
1395
  txHash: existing.settleTxHash,
1275
1396
  poolPaid: existing.pool ?? "0",
1276
- 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) => ({
1277
1398
  wallet,
1278
- amount: existing.pool ?? existing.entryAmount
1279
- })),
1399
+ amount: "0"
1400
+ }))),
1280
1401
  status: "settled"
1281
1402
  };
1282
1403
  }
@@ -1286,13 +1407,45 @@ var Playmos = class {
1286
1407
  code: "conflict"
1287
1408
  });
1288
1409
  }
1289
- const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1290
- wallet,
1291
- // Winner-take-all mock shape when ranking only.
1292
- amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1293
- }));
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 }));
1294
1448
  const settleTxHash = MOCK_TX;
1295
- const poolPaid = existing.pool ?? existing.entryAmount;
1296
1449
  const settled = {
1297
1450
  ...existing,
1298
1451
  status: "settled",
@@ -1300,18 +1453,11 @@ var Playmos = class {
1300
1453
  pool: poolPaid
1301
1454
  };
1302
1455
  this.mockRounds.set(input.roundId, settled);
1303
- for (const w of winners) {
1304
- const wAddr = String(w.wallet).toLowerCase();
1305
- let micro = 0n;
1306
- try {
1307
- micro = validateAmount(String(w.amount ?? "0"));
1308
- } catch {
1309
- micro = 0n;
1310
- }
1311
- if (micro > 0n) {
1312
- const k = `${input.roundId}:${wAddr}`;
1313
- this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
1314
- }
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);
1315
1461
  }
1316
1462
  return {
1317
1463
  roundId: input.roundId,
@@ -1403,9 +1549,12 @@ var Playmos = class {
1403
1549
  ...existing,
1404
1550
  status: "cancelled",
1405
1551
  cancelTxHash: MOCK_TX,
1406
- pool: null
1552
+ pool: null,
1553
+ entrants: 0
1407
1554
  };
1408
1555
  this.mockRounds.set(input.roundId, cancelled);
1556
+ this.mockPotMicro.set(input.roundId, 0n);
1557
+ this.mockSettleWinners.delete(input.roundId);
1409
1558
  return {
1410
1559
  roundId: input.roundId,
1411
1560
  txHash: MOCK_TX,
@@ -1930,6 +2079,69 @@ var Playmos = class {
1930
2079
  }
1931
2080
  }
1932
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
+ }
1933
2145
  /**
1934
2146
  * Wallet timeout policy (#462 / PR #463 residual).
1935
2147
  * - connectTimeoutMs → eth_requestAccounts only
@@ -2078,6 +2290,7 @@ var Playmos = class {
2078
2290
  const metadata = validateMetadata(input.metadata);
2079
2291
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
2080
2292
  if (this.config.mock) {
2293
+ this.mockAccumulateEntry(input.roundId, amountMicro);
2081
2294
  return this.rememberMock(
2082
2295
  mockEntryPayment({
2083
2296
  amountMicro,
@@ -2498,112 +2711,6 @@ function previewPoolSplit(amount) {
2498
2711
  };
2499
2712
  }
2500
2713
 
2501
- // src/payout.ts
2502
- var PayoutError = class extends Error {
2503
- constructor(message) {
2504
- super(message);
2505
- this.code = "payout_invalid";
2506
- this.name = "PayoutError";
2507
- }
2508
- };
2509
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
2510
- var BPS = 10000n;
2511
- function requireAddress2(w, i) {
2512
- if (!ADDRESS_RE3.test(w)) {
2513
- throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
2514
- }
2515
- return w.toLowerCase();
2516
- }
2517
- function parseUsdToMicroLoose(amount) {
2518
- if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2519
- throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2520
- }
2521
- const [wholeRaw, frac = ""] = amount.trim().split(".");
2522
- const whole = wholeRaw ?? "0";
2523
- const fracPadded = (frac + "000000").slice(0, 6);
2524
- const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2525
- if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
2526
- return micro;
2527
- }
2528
- function computePayout(pool, ranking, rule) {
2529
- if (typeof pool !== "bigint" || pool <= 0n) {
2530
- throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
2531
- }
2532
- if (!Array.isArray(ranking) || ranking.length === 0) {
2533
- throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
2534
- }
2535
- const wallets = ranking.map((w, i) => requireAddress2(w, i));
2536
- const seen = /* @__PURE__ */ new Set();
2537
- for (const w of wallets) {
2538
- if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
2539
- seen.add(w);
2540
- }
2541
- if (rule.kind === "winner-take-all") {
2542
- return [{ wallet: wallets[0], amount: pool }];
2543
- }
2544
- if (rule.kind === "top-n") {
2545
- const splits = rule.splitsBps;
2546
- if (!Array.isArray(splits) || splits.length === 0) {
2547
- throw new PayoutError("top-n splitsBps must be a non-empty array");
2548
- }
2549
- if (splits.length > wallets.length) {
2550
- throw new PayoutError(
2551
- `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
2552
- );
2553
- }
2554
- let sumBps = 0;
2555
- for (const b of splits) {
2556
- if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
2557
- throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
2558
- }
2559
- sumBps += b;
2560
- }
2561
- if (sumBps !== 1e4) {
2562
- throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
2563
- }
2564
- const out = [];
2565
- let allocated = 0n;
2566
- for (let i = 0; i < splits.length; i++) {
2567
- const amt = pool * BigInt(splits[i]) / BPS;
2568
- out.push({ wallet: wallets[i], amount: amt });
2569
- allocated += amt;
2570
- }
2571
- const remainder = pool - allocated;
2572
- if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
2573
- out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
2574
- const filtered = out.filter((x) => x.amount > 0n);
2575
- if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
2576
- const total = filtered.reduce((s, x) => s + x.amount, 0n);
2577
- if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
2578
- return filtered;
2579
- }
2580
- if (rule.kind === "custom") {
2581
- const amounts = rule.amounts;
2582
- if (!Array.isArray(amounts) || amounts.length === 0) {
2583
- throw new PayoutError("custom amounts must be a non-empty array of USD strings");
2584
- }
2585
- if (amounts.length > wallets.length) {
2586
- throw new PayoutError(
2587
- `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
2588
- );
2589
- }
2590
- const out = [];
2591
- let total = 0n;
2592
- for (let i = 0; i < amounts.length; i++) {
2593
- const amt = parseUsdToMicroLoose(amounts[i]);
2594
- out.push({ wallet: wallets[i], amount: amt });
2595
- total += amt;
2596
- }
2597
- if (total !== pool) {
2598
- throw new PayoutError(
2599
- `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
2600
- );
2601
- }
2602
- return out;
2603
- }
2604
- throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
2605
- }
2606
-
2607
2714
  // src/settlement.ts
2608
2715
  var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
2609
2716
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
package/dist/index.d.cts CHANGED
@@ -463,11 +463,44 @@ declare class Playmos {
463
463
  * rounds.settle({ ranking }) → contract pays winners.
464
464
  *
465
465
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
466
+ *
467
+ * Offline `mock: true`: settle applies the stored payout rule and the 60/30/10
468
+ * pool leg across entries (#490). No series seed bank — mock pool is a floor,
469
+ * not a forecast of a live series with inherited seed.
466
470
  */
467
471
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
468
472
  private readonly mockRounds;
469
473
  /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
470
474
  private readonly mockWithdrawable;
475
+ /**
476
+ * Mock pot after per-entry rake (PrizePool escrowed sum) — keyed by roundId (#490).
477
+ * Payable pool at lock = pot × POOL_BPS / (POOL_BPS + SEED_BPS); inherited seed = 0 offline.
478
+ */
479
+ private readonly mockPotMicro;
480
+ /** Persisted settle winners for re-settle replay (#490 C2) — never re-invent pool-to-everyone. */
481
+ private readonly mockSettleWinners;
482
+ /**
483
+ * Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
484
+ */
485
+ private findMockRound;
486
+ /**
487
+ * 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
488
+ * (would zero 0.135 from top-n 30% of $0.45).
489
+ */
490
+ private parseUsdToMicroLoose;
491
+ /** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
492
+ private mockPayoutApiError;
493
+ /**
494
+ * Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
495
+ * No-op when no matching open/locked round — never 404 (C4).
496
+ */
497
+ private mockAccumulateEntry;
498
+ /**
499
+ * Payable pool micro for mock lock/settle.
500
+ * With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
501
+ * Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
502
+ */
503
+ private mockPayablePoolMicro;
471
504
  readonly rounds: {
472
505
  open: (input: RoundOpenInput) => Promise<RoundState>;
473
506
  lock: (input: {
package/dist/index.d.ts CHANGED
@@ -463,11 +463,44 @@ declare class Playmos {
463
463
  * rounds.settle({ ranking }) → contract pays winners.
464
464
  *
465
465
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
466
+ *
467
+ * Offline `mock: true`: settle applies the stored payout rule and the 60/30/10
468
+ * pool leg across entries (#490). No series seed bank — mock pool is a floor,
469
+ * not a forecast of a live series with inherited seed.
466
470
  */
467
471
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
468
472
  private readonly mockRounds;
469
473
  /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
470
474
  private readonly mockWithdrawable;
475
+ /**
476
+ * Mock pot after per-entry rake (PrizePool escrowed sum) — keyed by roundId (#490).
477
+ * Payable pool at lock = pot × POOL_BPS / (POOL_BPS + SEED_BPS); inherited seed = 0 offline.
478
+ */
479
+ private readonly mockPotMicro;
480
+ /** Persisted settle winners for re-settle replay (#490 C2) — never re-invent pool-to-everyone. */
481
+ private readonly mockSettleWinners;
482
+ /**
483
+ * Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
484
+ */
485
+ private findMockRound;
486
+ /**
487
+ * 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
488
+ * (would zero 0.135 from top-n 30% of $0.45).
489
+ */
490
+ private parseUsdToMicroLoose;
491
+ /** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
492
+ private mockPayoutApiError;
493
+ /**
494
+ * Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
495
+ * No-op when no matching open/locked round — never 404 (C4).
496
+ */
497
+ private mockAccumulateEntry;
498
+ /**
499
+ * Payable pool micro for mock lock/settle.
500
+ * With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
501
+ * Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
502
+ */
503
+ private mockPayablePoolMicro;
471
504
  readonly rounds: {
472
505
  open: (input: RoundOpenInput) => Promise<RoundState>;
473
506
  lock: (input: {
package/dist/index.js CHANGED
@@ -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 };
@@ -704,12 +810,12 @@ function mockVerifyResult(payment) {
704
810
  }
705
811
 
706
812
  // src/x402.ts
707
- var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
708
- function requireAddress(value, field) {
813
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
814
+ function requireAddress2(value, field) {
709
815
  if (typeof value !== "string" || value.trim() === "") {
710
816
  throw new MissingFieldError(field);
711
817
  }
712
- if (!ADDRESS_RE.test(value)) {
818
+ if (!ADDRESS_RE2.test(value)) {
713
819
  throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
714
820
  field,
715
821
  value
@@ -779,7 +885,7 @@ function createX402Challenge(requirement, extras) {
779
885
  if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
780
886
  throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
781
887
  }
782
- requireAddress(requirement.payTo, "payTo");
888
+ requireAddress2(requirement.payTo, "payTo");
783
889
  parseUsdToMicro(requirement.amount);
784
890
  const feeBps = extras?.feeBps ?? 0;
785
891
  if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
@@ -789,7 +895,7 @@ function createX402Challenge(requirement, extras) {
789
895
  );
790
896
  }
791
897
  const feeSink = extras?.feeSink ?? null;
792
- if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
898
+ if (feeBps > 0 && feeSink) requireAddress2(feeSink, "feeSink");
793
899
  const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
794
900
  return {
795
901
  status: 402,
@@ -817,7 +923,7 @@ function validateX402ChallengeInput(input) {
817
923
  { field: "url" }
818
924
  );
819
925
  }
820
- const payTo = requireAddress(input.payTo, "payTo");
926
+ const payTo = requireAddress2(input.payTo, "payTo");
821
927
  parseUsdToMicro(input.amount);
822
928
  const intent = input.intent ?? "transfer";
823
929
  if (intent !== "transfer" && intent !== "marketplace.buy") {
@@ -833,7 +939,7 @@ function validateX402ChallengeInput(input) {
833
939
  { feeBps: input.feeBps }
834
940
  );
835
941
  }
836
- 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");
837
943
  return {
838
944
  payTo,
839
945
  amount: input.amount,
@@ -847,7 +953,7 @@ function validateX402ChallengeInput(input) {
847
953
  }
848
954
 
849
955
  // src/client.ts
850
- var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
956
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
851
957
  function mapAlreadyEntered(e) {
852
958
  const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
853
959
  const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
@@ -895,7 +1001,7 @@ function requireAddressField(value, field) {
895
1001
  if (typeof value !== "string" || value.trim() === "") {
896
1002
  throw new MissingFieldError(field);
897
1003
  }
898
- if (!ADDRESS_RE2.test(value)) {
1004
+ if (!ADDRESS_RE3.test(value)) {
899
1005
  throw new ConfigError(
900
1006
  `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
901
1007
  { field, value }
@@ -1083,11 +1189,22 @@ var Playmos = class {
1083
1189
  * rounds.settle({ ranking }) → contract pays winners.
1084
1190
  *
1085
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.
1086
1196
  */
1087
1197
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
1088
1198
  this.mockRounds = /* @__PURE__ */ new Map();
1089
1199
  /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
1090
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();
1091
1208
  this.rounds = {
1092
1209
  open: async (input) => {
1093
1210
  if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
@@ -1126,6 +1243,8 @@ var Playmos = class {
1126
1243
  prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1127
1244
  };
1128
1245
  this.mockRounds.set(input.roundId, round);
1246
+ this.mockPotMicro.set(input.roundId, 0n);
1247
+ this.mockSettleWinners.delete(input.roundId);
1129
1248
  return round;
1130
1249
  }
1131
1250
  const body = await this.http.post(
@@ -1158,11 +1277,12 @@ var Playmos = class {
1158
1277
  code: "conflict"
1159
1278
  });
1160
1279
  }
1280
+ const payableMicro = this.mockPayablePoolMicro(existing);
1281
+ const poolUsd = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
1161
1282
  const locked = {
1162
1283
  ...existing,
1163
1284
  status: "locked",
1164
- // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1165
- pool: existing.pool ?? existing.entryAmount,
1285
+ pool: poolUsd,
1166
1286
  entrants: existing.entrants ?? 0,
1167
1287
  lockTxHash: MOCK_TX
1168
1288
  };
@@ -1185,14 +1305,15 @@ var Playmos = class {
1185
1305
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1186
1306
  }
1187
1307
  if (existing.status === "settled" && existing.settleTxHash) {
1308
+ const stored = this.mockSettleWinners.get(input.roundId);
1188
1309
  return {
1189
1310
  roundId: input.roundId,
1190
1311
  txHash: existing.settleTxHash,
1191
1312
  poolPaid: existing.pool ?? "0",
1192
- 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) => ({
1193
1314
  wallet,
1194
- amount: existing.pool ?? existing.entryAmount
1195
- })),
1315
+ amount: "0"
1316
+ }))),
1196
1317
  status: "settled"
1197
1318
  };
1198
1319
  }
@@ -1202,13 +1323,45 @@ var Playmos = class {
1202
1323
  code: "conflict"
1203
1324
  });
1204
1325
  }
1205
- const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1206
- wallet,
1207
- // Winner-take-all mock shape when ranking only.
1208
- amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1209
- }));
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 }));
1210
1364
  const settleTxHash = MOCK_TX;
1211
- const poolPaid = existing.pool ?? existing.entryAmount;
1212
1365
  const settled = {
1213
1366
  ...existing,
1214
1367
  status: "settled",
@@ -1216,18 +1369,11 @@ var Playmos = class {
1216
1369
  pool: poolPaid
1217
1370
  };
1218
1371
  this.mockRounds.set(input.roundId, settled);
1219
- for (const w of winners) {
1220
- const wAddr = String(w.wallet).toLowerCase();
1221
- let micro = 0n;
1222
- try {
1223
- micro = validateAmount(String(w.amount ?? "0"));
1224
- } catch {
1225
- micro = 0n;
1226
- }
1227
- if (micro > 0n) {
1228
- const k = `${input.roundId}:${wAddr}`;
1229
- this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
1230
- }
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);
1231
1377
  }
1232
1378
  return {
1233
1379
  roundId: input.roundId,
@@ -1319,9 +1465,12 @@ var Playmos = class {
1319
1465
  ...existing,
1320
1466
  status: "cancelled",
1321
1467
  cancelTxHash: MOCK_TX,
1322
- pool: null
1468
+ pool: null,
1469
+ entrants: 0
1323
1470
  };
1324
1471
  this.mockRounds.set(input.roundId, cancelled);
1472
+ this.mockPotMicro.set(input.roundId, 0n);
1473
+ this.mockSettleWinners.delete(input.roundId);
1325
1474
  return {
1326
1475
  roundId: input.roundId,
1327
1476
  txHash: MOCK_TX,
@@ -1846,6 +1995,69 @@ var Playmos = class {
1846
1995
  }
1847
1996
  }
1848
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
+ }
1849
2061
  /**
1850
2062
  * Wallet timeout policy (#462 / PR #463 residual).
1851
2063
  * - connectTimeoutMs → eth_requestAccounts only
@@ -1994,6 +2206,7 @@ var Playmos = class {
1994
2206
  const metadata = validateMetadata(input.metadata);
1995
2207
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1996
2208
  if (this.config.mock) {
2209
+ this.mockAccumulateEntry(input.roundId, amountMicro);
1997
2210
  return this.rememberMock(
1998
2211
  mockEntryPayment({
1999
2212
  amountMicro,
@@ -2414,112 +2627,6 @@ function previewPoolSplit(amount) {
2414
2627
  };
2415
2628
  }
2416
2629
 
2417
- // src/payout.ts
2418
- var PayoutError = class extends Error {
2419
- constructor(message) {
2420
- super(message);
2421
- this.code = "payout_invalid";
2422
- this.name = "PayoutError";
2423
- }
2424
- };
2425
- var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
2426
- var BPS = 10000n;
2427
- function requireAddress2(w, i) {
2428
- if (!ADDRESS_RE3.test(w)) {
2429
- throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
2430
- }
2431
- return w.toLowerCase();
2432
- }
2433
- function parseUsdToMicroLoose(amount) {
2434
- if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2435
- throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2436
- }
2437
- const [wholeRaw, frac = ""] = amount.trim().split(".");
2438
- const whole = wholeRaw ?? "0";
2439
- const fracPadded = (frac + "000000").slice(0, 6);
2440
- const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2441
- if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
2442
- return micro;
2443
- }
2444
- function computePayout(pool, ranking, rule) {
2445
- if (typeof pool !== "bigint" || pool <= 0n) {
2446
- throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
2447
- }
2448
- if (!Array.isArray(ranking) || ranking.length === 0) {
2449
- throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
2450
- }
2451
- const wallets = ranking.map((w, i) => requireAddress2(w, i));
2452
- const seen = /* @__PURE__ */ new Set();
2453
- for (const w of wallets) {
2454
- if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
2455
- seen.add(w);
2456
- }
2457
- if (rule.kind === "winner-take-all") {
2458
- return [{ wallet: wallets[0], amount: pool }];
2459
- }
2460
- if (rule.kind === "top-n") {
2461
- const splits = rule.splitsBps;
2462
- if (!Array.isArray(splits) || splits.length === 0) {
2463
- throw new PayoutError("top-n splitsBps must be a non-empty array");
2464
- }
2465
- if (splits.length > wallets.length) {
2466
- throw new PayoutError(
2467
- `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
2468
- );
2469
- }
2470
- let sumBps = 0;
2471
- for (const b of splits) {
2472
- if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
2473
- throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
2474
- }
2475
- sumBps += b;
2476
- }
2477
- if (sumBps !== 1e4) {
2478
- throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
2479
- }
2480
- const out = [];
2481
- let allocated = 0n;
2482
- for (let i = 0; i < splits.length; i++) {
2483
- const amt = pool * BigInt(splits[i]) / BPS;
2484
- out.push({ wallet: wallets[i], amount: amt });
2485
- allocated += amt;
2486
- }
2487
- const remainder = pool - allocated;
2488
- if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
2489
- out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
2490
- const filtered = out.filter((x) => x.amount > 0n);
2491
- if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
2492
- const total = filtered.reduce((s, x) => s + x.amount, 0n);
2493
- if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
2494
- return filtered;
2495
- }
2496
- if (rule.kind === "custom") {
2497
- const amounts = rule.amounts;
2498
- if (!Array.isArray(amounts) || amounts.length === 0) {
2499
- throw new PayoutError("custom amounts must be a non-empty array of USD strings");
2500
- }
2501
- if (amounts.length > wallets.length) {
2502
- throw new PayoutError(
2503
- `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
2504
- );
2505
- }
2506
- const out = [];
2507
- let total = 0n;
2508
- for (let i = 0; i < amounts.length; i++) {
2509
- const amt = parseUsdToMicroLoose(amounts[i]);
2510
- out.push({ wallet: wallets[i], amount: amt });
2511
- total += amt;
2512
- }
2513
- if (total !== pool) {
2514
- throw new PayoutError(
2515
- `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
2516
- );
2517
- }
2518
- return out;
2519
- }
2520
- throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
2521
- }
2522
-
2523
2630
  // src/settlement.ts
2524
2631
  var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
2525
2632
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playmos/sdk",
3
- "version": "0.3.8",
3
+ "version": "0.3.9",
4
4
  "description": "Playmos SDK — stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,