@playmos/sdk 0.3.8 → 0.3.10

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
@@ -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,
@@ -1381,19 +1530,34 @@ var Playmos = class {
1381
1530
  }
1382
1531
  return { ...result, txHash: result.txHash ?? firstTx ?? null };
1383
1532
  },
1533
+ /**
1534
+ * Round state only (stable). Prefer {@link getWithMeta} when you need service
1535
+ * read-path honesty (`via` / `chainReadFailed` — #497 / #501).
1536
+ */
1384
1537
  get: async (input) => {
1538
+ const meta = await this.rounds.getWithMeta(input);
1539
+ return meta.round;
1540
+ },
1541
+ /**
1542
+ * Round state + transport meta from GET /v1/rounds/:id (#501).
1543
+ * Does **not** attach `via` onto RoundState (Claude rejected option B — hub would
1544
+ * silently pick it up and leak into settle/cancel paths that call get()).
1545
+ * Under `mock: true`, meta fields are **omitted** (no eth_call; do not invent "cache").
1546
+ */
1547
+ getWithMeta: async (input) => {
1385
1548
  requireField(input?.roundId, "roundId");
1386
1549
  if (this.config.mock) {
1387
1550
  const existing = this.mockRounds.get(input.roundId);
1388
1551
  if (!existing) {
1389
1552
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1390
1553
  }
1391
- return existing;
1554
+ return { round: existing };
1392
1555
  }
1393
- const { round } = await this.http.get(
1394
- `/rounds/${encodeURIComponent(input.roundId)}`
1395
- );
1396
- return round;
1556
+ const body = await this.http.get(`/rounds/${encodeURIComponent(input.roundId)}`);
1557
+ const out = { round: body.round };
1558
+ if (body.via != null) out.via = body.via;
1559
+ if (body.chainReadFailed === true) out.chainReadFailed = true;
1560
+ return out;
1397
1561
  },
1398
1562
  /**
1399
1563
  * Authoritative series latch (#351) — `null` means **proven free**.
@@ -1498,19 +1662,26 @@ var Playmos = class {
1498
1662
  */
1499
1663
  withdraw: async (input) => {
1500
1664
  if (this.config.mock) {
1501
- let amountMicro2 = 0n;
1502
1665
  let prizePool2 = input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001";
1503
1666
  if (input.roundId) {
1504
1667
  const existing = this.mockRounds.get(input.roundId);
1505
1668
  if (existing?.prizePoolAddress) prizePool2 = existing.prizePoolAddress;
1506
- for (const [k, v] of this.mockWithdrawable) {
1507
- if (k.startsWith(`${input.roundId}:`) && v > 0n) {
1508
- amountMicro2 = v;
1509
- this.mockWithdrawable.set(k, 0n);
1510
- break;
1511
- }
1512
- }
1513
1669
  }
1670
+ if (!input.wallet || !ADDRESS_RE3.test(input.wallet)) {
1671
+ throw new ConfigError(
1672
+ "mock rounds.withdraw requires wallet (0x\u2026) \u2014 live uses the connected provider; without wallet mock would pay the first non-zero credit to the wrong player (#495)"
1673
+ );
1674
+ }
1675
+ const wallet = input.wallet.toLowerCase();
1676
+ if (!input.roundId) {
1677
+ throw new ConfigError("mock rounds.withdraw requires roundId to locate claimable credits");
1678
+ }
1679
+ const key = `${input.roundId}:${wallet}`;
1680
+ const amountMicro2 = this.mockWithdrawable.get(key) ?? 0n;
1681
+ if (amountMicro2 === 0n) {
1682
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool2, wallet });
1683
+ }
1684
+ this.mockWithdrawable.set(key, 0n);
1514
1685
  return {
1515
1686
  prizePoolAddress: prizePool2,
1516
1687
  amount: formatMicroToUsd(amountMicro2),
@@ -1846,6 +2017,69 @@ var Playmos = class {
1846
2017
  }
1847
2018
  }
1848
2019
  }
2020
+ /**
2021
+ * Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
2022
+ */
2023
+ findMockRound(idOrKey) {
2024
+ const direct = this.mockRounds.get(idOrKey);
2025
+ if (direct) return direct;
2026
+ for (const r of this.mockRounds.values()) {
2027
+ if (r.roundId === idOrKey || r.roundKey === idOrKey) return r;
2028
+ }
2029
+ return void 0;
2030
+ }
2031
+ /**
2032
+ * 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
2033
+ * (would zero 0.135 from top-n 30% of $0.45).
2034
+ */
2035
+ parseUsdToMicroLoose(amount) {
2036
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2037
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2038
+ }
2039
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
2040
+ const whole = wholeRaw ?? "0";
2041
+ const fracPadded = (frac + "000000").slice(0, 6);
2042
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2043
+ if (micro < 0n) throw new PayoutError(`amount must be >= 0, got: ${JSON.stringify(amount)}`);
2044
+ return micro;
2045
+ }
2046
+ /** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
2047
+ mockPayoutApiError(e) {
2048
+ const msg = e instanceof Error ? e.message : String(e);
2049
+ throw new ApiError(msg, { status: 400, code: "payout_invalid" });
2050
+ }
2051
+ /**
2052
+ * Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
2053
+ * No-op when no matching open/locked round — never 404 (C4).
2054
+ */
2055
+ mockAccumulateEntry(idOrKey, amountMicro) {
2056
+ const round = this.findMockRound(idOrKey);
2057
+ if (!round) return;
2058
+ if (round.status !== "open" && round.status !== "locked") return;
2059
+ const rake = amountMicro * BigInt(RAKE_BPS) / 10000n;
2060
+ const escrowed = amountMicro - rake;
2061
+ const pot = (this.mockPotMicro.get(round.roundId) ?? 0n) + escrowed;
2062
+ this.mockPotMicro.set(round.roundId, pot);
2063
+ const entrants = (round.entrants ?? 0) + 1;
2064
+ const updated = { ...round, entrants };
2065
+ this.mockRounds.set(round.roundId, updated);
2066
+ }
2067
+ /**
2068
+ * Payable pool micro for mock lock/settle.
2069
+ * With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
2070
+ * Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
2071
+ */
2072
+ mockPayablePoolMicro(round) {
2073
+ const pot = this.mockPotMicro.get(round.roundId) ?? 0n;
2074
+ if (pot > 0n) {
2075
+ return pot * BigInt(POOL_BPS) / BigInt(POOL_BPS + SEED_BPS);
2076
+ }
2077
+ try {
2078
+ return this.parseUsdToMicroLoose(round.entryAmount);
2079
+ } catch {
2080
+ return 0n;
2081
+ }
2082
+ }
1849
2083
  /**
1850
2084
  * Wallet timeout policy (#462 / PR #463 residual).
1851
2085
  * - connectTimeoutMs → eth_requestAccounts only
@@ -1994,6 +2228,7 @@ var Playmos = class {
1994
2228
  const metadata = validateMetadata(input.metadata);
1995
2229
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1996
2230
  if (this.config.mock) {
2231
+ this.mockAccumulateEntry(input.roundId, amountMicro);
1997
2232
  return this.rememberMock(
1998
2233
  mockEntryPayment({
1999
2234
  amountMicro,
@@ -2414,112 +2649,6 @@ function previewPoolSplit(amount) {
2414
2649
  };
2415
2650
  }
2416
2651
 
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
2652
  // src/settlement.ts
2524
2653
  var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
2525
2654
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;
package/dist/server.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { Y as PlaymosError, W as WebhookEvent } from './errors-BMlWHsMb.cjs';
1
+ import { Z as PlaymosError, W as WebhookEvent } from './errors-Chizbb96.cjs';
2
2
 
3
3
  /**
4
4
  * Webhook signature verification (spec §6.2) — server-side only.
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Y as PlaymosError, W as WebhookEvent } from './errors-BMlWHsMb.js';
1
+ import { Z as PlaymosError, W as WebhookEvent } from './errors-Chizbb96.js';
2
2
 
3
3
  /**
4
4
  * Webhook signature verification (spec §6.2) — server-side only.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playmos/sdk",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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,