@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.
@@ -493,6 +493,19 @@ interface RoundOpenInput {
493
493
  */
494
494
  roundKey?: string;
495
495
  }
496
+ /**
497
+ * Service GET /v1/rounds/:id read-path honesty (#497 / #501).
498
+ * Transport meta — not chain lifecycle. Prefer `rounds.getWithMeta` over stuffing onto RoundState.
499
+ */
500
+ type RoundGetVia = "chain" | "cache" | "stale" | "unknown";
501
+ /** Full GET round response including transport meta (#501 — Claude pick C / getWithMeta). */
502
+ interface RoundGetResult {
503
+ round: RoundState;
504
+ /** Present on live service responses after #499. Omitted under `mock: true`. */
505
+ via?: RoundGetVia;
506
+ /** Present only when eth_call itself threw (#497 C1). */
507
+ chainReadFailed?: boolean;
508
+ }
496
509
  interface RoundState {
497
510
  roundId: string;
498
511
  gameId: string;
@@ -715,4 +728,4 @@ declare class NothingToWithdrawError extends PlaymosError {
715
728
  constructor(detail?: Record<string, unknown>);
716
729
  }
717
730
 
718
- export { type RoundStatus as $, type ActiveSeriesRound as A, type Eip1193Provider as B, type CancelRoundResult as C, type GasMode as D, type EscrowHoldInput as E, InvalidAmountError as F, type GasConfig as G, type ListingStatus as H, InsufficientGasError as I, type MarketplaceItem as J, type MarketplaceSale as K, type Listing as L, type MarketplaceListInput as M, type Network as N, MissingFieldError as O, type PlaymosConfig as P, NothingToWithdrawError as Q, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, PaymentFailedError as U, type VerifyResult as V, type WebhookEvent as W, type PaymentStatus as X, PlaymosError as Y, type PlaymosErrorCode as Z, type RetryOptions as _, type RoundState as a, type WalletConfig as a0, WalletConnectionError as a1, type WalletConnector as a2, WalletTimeoutError as a3, type WebhookEventType as a4, type RoundSettleInput as b, type RoundCancelInput as c, type PrizeBalance as d, type WithdrawResult as e, type AgentWallet as f, type AgentFundResult as g, type EscrowHoldResult as h, type EscrowResolveResult as i, type MarketplaceSaleResult as j, type MarketplaceGetResult as k, type PayInput as l, type Payment as m, type EnterRoundInput as n, type TransferReconcile as o, type WaitOptions as p, type TransferInput as q, type TransferConfirmOptions as r, type PayoutRule as s, type ActiveForSeriesResult as t, type AgentEconomyConfig as u, AlreadyEnteredError as v, ApiError as w, AuthError as x, ConfigError as y, type ContractConfig as z };
731
+ export { type RetryOptions as $, type ActiveSeriesRound as A, type ContractConfig as B, type CancelRoundResult as C, type Eip1193Provider as D, type EscrowHoldInput as E, type GasMode as F, type GasConfig as G, InvalidAmountError as H, InsufficientGasError as I, type ListingStatus as J, type MarketplaceItem as K, type Listing as L, type MarketplaceListInput as M, type Network as N, type MarketplaceSale as O, type PlaymosConfig as P, MissingFieldError as Q, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, NothingToWithdrawError as U, type VerifyResult as V, type WebhookEvent as W, PaymentFailedError as X, type PaymentStatus as Y, PlaymosError as Z, type PlaymosErrorCode as _, type RoundState as a, type RoundGetVia as a0, type RoundStatus as a1, type WalletConfig as a2, WalletConnectionError as a3, type WalletConnector as a4, WalletTimeoutError as a5, type WebhookEventType as a6, type RoundSettleInput as b, type RoundCancelInput as c, type RoundGetResult as d, type PrizeBalance as e, type WithdrawResult as f, type AgentWallet as g, type AgentFundResult as h, type EscrowHoldResult as i, type EscrowResolveResult as j, type MarketplaceSaleResult as k, type MarketplaceGetResult as l, type PayInput as m, type Payment as n, type EnterRoundInput as o, type TransferReconcile as p, type WaitOptions as q, type TransferInput as r, type TransferConfirmOptions as s, type PayoutRule as t, type ActiveForSeriesResult as u, type AgentEconomyConfig as v, AlreadyEnteredError as w, ApiError as x, AuthError as y, ConfigError as z };
@@ -493,6 +493,19 @@ interface RoundOpenInput {
493
493
  */
494
494
  roundKey?: string;
495
495
  }
496
+ /**
497
+ * Service GET /v1/rounds/:id read-path honesty (#497 / #501).
498
+ * Transport meta — not chain lifecycle. Prefer `rounds.getWithMeta` over stuffing onto RoundState.
499
+ */
500
+ type RoundGetVia = "chain" | "cache" | "stale" | "unknown";
501
+ /** Full GET round response including transport meta (#501 — Claude pick C / getWithMeta). */
502
+ interface RoundGetResult {
503
+ round: RoundState;
504
+ /** Present on live service responses after #499. Omitted under `mock: true`. */
505
+ via?: RoundGetVia;
506
+ /** Present only when eth_call itself threw (#497 C1). */
507
+ chainReadFailed?: boolean;
508
+ }
496
509
  interface RoundState {
497
510
  roundId: string;
498
511
  gameId: string;
@@ -715,4 +728,4 @@ declare class NothingToWithdrawError extends PlaymosError {
715
728
  constructor(detail?: Record<string, unknown>);
716
729
  }
717
730
 
718
- export { type RoundStatus as $, type ActiveSeriesRound as A, type Eip1193Provider as B, type CancelRoundResult as C, type GasMode as D, type EscrowHoldInput as E, InvalidAmountError as F, type GasConfig as G, type ListingStatus as H, InsufficientGasError as I, type MarketplaceItem as J, type MarketplaceSale as K, type Listing as L, type MarketplaceListInput as M, type Network as N, MissingFieldError as O, type PlaymosConfig as P, NothingToWithdrawError as Q, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, PaymentFailedError as U, type VerifyResult as V, type WebhookEvent as W, type PaymentStatus as X, PlaymosError as Y, type PlaymosErrorCode as Z, type RetryOptions as _, type RoundState as a, type WalletConfig as a0, WalletConnectionError as a1, type WalletConnector as a2, WalletTimeoutError as a3, type WebhookEventType as a4, type RoundSettleInput as b, type RoundCancelInput as c, type PrizeBalance as d, type WithdrawResult as e, type AgentWallet as f, type AgentFundResult as g, type EscrowHoldResult as h, type EscrowResolveResult as i, type MarketplaceSaleResult as j, type MarketplaceGetResult as k, type PayInput as l, type Payment as m, type EnterRoundInput as n, type TransferReconcile as o, type WaitOptions as p, type TransferInput as q, type TransferConfirmOptions as r, type PayoutRule as s, type ActiveForSeriesResult as t, type AgentEconomyConfig as u, AlreadyEnteredError as v, ApiError as w, AuthError as x, ConfigError as y, type ContractConfig as z };
731
+ export { type RetryOptions as $, type ActiveSeriesRound as A, type ContractConfig as B, type CancelRoundResult as C, type Eip1193Provider as D, type EscrowHoldInput as E, type GasMode as F, type GasConfig as G, InvalidAmountError as H, InsufficientGasError as I, type ListingStatus as J, type MarketplaceItem as K, type Listing as L, type MarketplaceListInput as M, type Network as N, type MarketplaceSale as O, type PlaymosConfig as P, MissingFieldError as Q, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, NothingToWithdrawError as U, type VerifyResult as V, type WebhookEvent as W, PaymentFailedError as X, type PaymentStatus as Y, PlaymosError as Z, type PlaymosErrorCode as _, type RoundState as a, type RoundGetVia as a0, type RoundStatus as a1, type WalletConfig as a2, WalletConnectionError as a3, type WalletConnector as a4, WalletTimeoutError as a5, type WebhookEventType as a6, type RoundSettleInput as b, type RoundCancelInput as c, type RoundGetResult as d, type PrizeBalance as e, type WithdrawResult as f, type AgentWallet as g, type AgentFundResult as h, type EscrowHoldResult as i, type EscrowResolveResult as j, type MarketplaceSaleResult as k, type MarketplaceGetResult as l, type PayInput as m, type Payment as n, type EnterRoundInput as o, type TransferReconcile as p, type WaitOptions as q, type TransferInput as r, type TransferConfirmOptions as s, type PayoutRule as t, type ActiveForSeriesResult as u, type AgentEconomyConfig as v, AlreadyEnteredError as w, ApiError as x, AuthError as y, ConfigError as z };
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,
@@ -1465,19 +1614,34 @@ var Playmos = class {
1465
1614
  }
1466
1615
  return { ...result, txHash: result.txHash ?? firstTx ?? null };
1467
1616
  },
1617
+ /**
1618
+ * Round state only (stable). Prefer {@link getWithMeta} when you need service
1619
+ * read-path honesty (`via` / `chainReadFailed` — #497 / #501).
1620
+ */
1468
1621
  get: async (input) => {
1622
+ const meta = await this.rounds.getWithMeta(input);
1623
+ return meta.round;
1624
+ },
1625
+ /**
1626
+ * Round state + transport meta from GET /v1/rounds/:id (#501).
1627
+ * Does **not** attach `via` onto RoundState (Claude rejected option B — hub would
1628
+ * silently pick it up and leak into settle/cancel paths that call get()).
1629
+ * Under `mock: true`, meta fields are **omitted** (no eth_call; do not invent "cache").
1630
+ */
1631
+ getWithMeta: async (input) => {
1469
1632
  requireField(input?.roundId, "roundId");
1470
1633
  if (this.config.mock) {
1471
1634
  const existing = this.mockRounds.get(input.roundId);
1472
1635
  if (!existing) {
1473
1636
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1474
1637
  }
1475
- return existing;
1638
+ return { round: existing };
1476
1639
  }
1477
- const { round } = await this.http.get(
1478
- `/rounds/${encodeURIComponent(input.roundId)}`
1479
- );
1480
- return round;
1640
+ const body = await this.http.get(`/rounds/${encodeURIComponent(input.roundId)}`);
1641
+ const out = { round: body.round };
1642
+ if (body.via != null) out.via = body.via;
1643
+ if (body.chainReadFailed === true) out.chainReadFailed = true;
1644
+ return out;
1481
1645
  },
1482
1646
  /**
1483
1647
  * Authoritative series latch (#351) — `null` means **proven free**.
@@ -1582,19 +1746,26 @@ var Playmos = class {
1582
1746
  */
1583
1747
  withdraw: async (input) => {
1584
1748
  if (this.config.mock) {
1585
- let amountMicro2 = 0n;
1586
1749
  let prizePool2 = input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001";
1587
1750
  if (input.roundId) {
1588
1751
  const existing = this.mockRounds.get(input.roundId);
1589
1752
  if (existing?.prizePoolAddress) prizePool2 = existing.prizePoolAddress;
1590
- for (const [k, v] of this.mockWithdrawable) {
1591
- if (k.startsWith(`${input.roundId}:`) && v > 0n) {
1592
- amountMicro2 = v;
1593
- this.mockWithdrawable.set(k, 0n);
1594
- break;
1595
- }
1596
- }
1597
1753
  }
1754
+ if (!input.wallet || !ADDRESS_RE3.test(input.wallet)) {
1755
+ throw new ConfigError(
1756
+ "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)"
1757
+ );
1758
+ }
1759
+ const wallet = input.wallet.toLowerCase();
1760
+ if (!input.roundId) {
1761
+ throw new ConfigError("mock rounds.withdraw requires roundId to locate claimable credits");
1762
+ }
1763
+ const key = `${input.roundId}:${wallet}`;
1764
+ const amountMicro2 = this.mockWithdrawable.get(key) ?? 0n;
1765
+ if (amountMicro2 === 0n) {
1766
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool2, wallet });
1767
+ }
1768
+ this.mockWithdrawable.set(key, 0n);
1598
1769
  return {
1599
1770
  prizePoolAddress: prizePool2,
1600
1771
  amount: formatMicroToUsd(amountMicro2),
@@ -1930,6 +2101,69 @@ var Playmos = class {
1930
2101
  }
1931
2102
  }
1932
2103
  }
2104
+ /**
2105
+ * Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
2106
+ */
2107
+ findMockRound(idOrKey) {
2108
+ const direct = this.mockRounds.get(idOrKey);
2109
+ if (direct) return direct;
2110
+ for (const r of this.mockRounds.values()) {
2111
+ if (r.roundId === idOrKey || r.roundKey === idOrKey) return r;
2112
+ }
2113
+ return void 0;
2114
+ }
2115
+ /**
2116
+ * 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
2117
+ * (would zero 0.135 from top-n 30% of $0.45).
2118
+ */
2119
+ parseUsdToMicroLoose(amount) {
2120
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
2121
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
2122
+ }
2123
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
2124
+ const whole = wholeRaw ?? "0";
2125
+ const fracPadded = (frac + "000000").slice(0, 6);
2126
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
2127
+ if (micro < 0n) throw new PayoutError(`amount must be >= 0, got: ${JSON.stringify(amount)}`);
2128
+ return micro;
2129
+ }
2130
+ /** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
2131
+ mockPayoutApiError(e) {
2132
+ const msg = e instanceof Error ? e.message : String(e);
2133
+ throw new ApiError(msg, { status: 400, code: "payout_invalid" });
2134
+ }
2135
+ /**
2136
+ * Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
2137
+ * No-op when no matching open/locked round — never 404 (C4).
2138
+ */
2139
+ mockAccumulateEntry(idOrKey, amountMicro) {
2140
+ const round = this.findMockRound(idOrKey);
2141
+ if (!round) return;
2142
+ if (round.status !== "open" && round.status !== "locked") return;
2143
+ const rake = amountMicro * BigInt(RAKE_BPS) / 10000n;
2144
+ const escrowed = amountMicro - rake;
2145
+ const pot = (this.mockPotMicro.get(round.roundId) ?? 0n) + escrowed;
2146
+ this.mockPotMicro.set(round.roundId, pot);
2147
+ const entrants = (round.entrants ?? 0) + 1;
2148
+ const updated = { ...round, entrants };
2149
+ this.mockRounds.set(round.roundId, updated);
2150
+ }
2151
+ /**
2152
+ * Payable pool micro for mock lock/settle.
2153
+ * With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
2154
+ * Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
2155
+ */
2156
+ mockPayablePoolMicro(round) {
2157
+ const pot = this.mockPotMicro.get(round.roundId) ?? 0n;
2158
+ if (pot > 0n) {
2159
+ return pot * BigInt(POOL_BPS) / BigInt(POOL_BPS + SEED_BPS);
2160
+ }
2161
+ try {
2162
+ return this.parseUsdToMicroLoose(round.entryAmount);
2163
+ } catch {
2164
+ return 0n;
2165
+ }
2166
+ }
1933
2167
  /**
1934
2168
  * Wallet timeout policy (#462 / PR #463 residual).
1935
2169
  * - connectTimeoutMs → eth_requestAccounts only
@@ -2078,6 +2312,7 @@ var Playmos = class {
2078
2312
  const metadata = validateMetadata(input.metadata);
2079
2313
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
2080
2314
  if (this.config.mock) {
2315
+ this.mockAccumulateEntry(input.roundId, amountMicro);
2081
2316
  return this.rememberMock(
2082
2317
  mockEntryPayment({
2083
2318
  amountMicro,
@@ -2498,112 +2733,6 @@ function previewPoolSplit(amount) {
2498
2733
  };
2499
2734
  }
2500
2735
 
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
2736
  // src/settlement.ts
2608
2737
  var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
2609
2738
  var DEFAULT_TTL_MS = 15 * 60 * 1e3;