@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/{errors-BMlWHsMb.d.cts → errors-Chizbb96.d.cts} +14 -1
- package/dist/{errors-BMlWHsMb.d.ts → errors-Chizbb96.d.ts} +14 -1
- package/dist/index.cjs +281 -152
- package/dist/index.d.cts +54 -3
- package/dist/index.d.ts +54 -3
- package/dist/index.js +281 -152
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
|
792
|
-
function
|
|
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 (!
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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 :
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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:
|
|
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
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
|
1478
|
-
|
|
1479
|
-
);
|
|
1480
|
-
|
|
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;
|