@playmos/sdk 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/{chunk-VYR6BBHF.js → chunk-UZECDT6F.js} +15 -1
- package/dist/{errors-CdVzHuhY.d.cts → errors-BMlWHsMb.d.cts} +72 -7
- package/dist/{errors-CdVzHuhY.d.ts → errors-BMlWHsMb.d.ts} +72 -7
- package/dist/index.cjs +563 -183
- package/dist/index.d.cts +51 -9
- package/dist/index.d.ts +51 -9
- package/dist/index.js +549 -185
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +5 -1
package/dist/index.cjs
CHANGED
|
@@ -42,11 +42,25 @@ var WalletConnectionError = class extends PlaymosError {
|
|
|
42
42
|
super("wallet_connection", message, detail);
|
|
43
43
|
}
|
|
44
44
|
};
|
|
45
|
+
var WalletTimeoutError = class extends PlaymosError {
|
|
46
|
+
constructor(message = "The wallet did not respond in time. Ask the player to approve the prompt, or retry.", detail) {
|
|
47
|
+
super("wallet_timeout", message, detail);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
45
50
|
var PaymentFailedError = class extends PlaymosError {
|
|
46
51
|
constructor(message = "The on-chain payment did not complete.", detail) {
|
|
47
52
|
super("payment_failed", message, detail);
|
|
48
53
|
}
|
|
49
54
|
};
|
|
55
|
+
var AlreadyEnteredError = class extends PlaymosError {
|
|
56
|
+
constructor(detail) {
|
|
57
|
+
super(
|
|
58
|
+
"already_entered",
|
|
59
|
+
"This identity already entered this round on-chain. For pay-per-play, pass a unique identity per attempt (not just the wallet address).",
|
|
60
|
+
detail
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
50
64
|
var AuthError = class extends PlaymosError {
|
|
51
65
|
constructor(message = "Invalid or missing API key.", detail) {
|
|
52
66
|
super("auth", message, detail);
|
|
@@ -251,6 +265,112 @@ function validateMetadata(metadata) {
|
|
|
251
265
|
return out;
|
|
252
266
|
}
|
|
253
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
|
+
|
|
254
374
|
// src/http.ts
|
|
255
375
|
function resolveRetry(retry) {
|
|
256
376
|
const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
|
|
@@ -399,7 +519,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
399
519
|
const why = res.__playmosSignerBelowReason ?? "low_balance";
|
|
400
520
|
msg = `Sandbox signer below funding floor (${why}) (${res.status} from ${res.url}) \u2014 top up Sepolia USDC (see GET /health capabilities.payments.signerBalance). Do not retry; retry cannot succeed until funded. Cross-ref: issue #205 recurrence \xB7 hub funding #15.`;
|
|
401
521
|
} else if (gateway) {
|
|
402
|
-
msg = `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while
|
|
522
|
+
msg = `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform edge limit while on-chain work ran. Retry with the same idempotency key after checking chain/service status (safe if settle already landed). Do not assume missing RPC \u2014 sandbox usually has one; prefer shorter async settle paths (BB-GATEB-002 / #422).`;
|
|
403
523
|
} else {
|
|
404
524
|
msg = `Non-JSON response (${res.status}) from ${res.url}`;
|
|
405
525
|
}
|
|
@@ -441,7 +561,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
441
561
|
}
|
|
442
562
|
|
|
443
563
|
// src/wallet.ts
|
|
444
|
-
|
|
564
|
+
var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
|
|
565
|
+
var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
|
|
566
|
+
"wallet_sendCalls",
|
|
567
|
+
"eth_sendTransaction",
|
|
568
|
+
"eth_sendRawTransaction"
|
|
569
|
+
]);
|
|
570
|
+
var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
|
|
571
|
+
var TIMED_FLAG = "__playmosTimedRequest";
|
|
572
|
+
function resolvePolicy(opts) {
|
|
573
|
+
const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
|
|
574
|
+
const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
|
|
575
|
+
const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
|
|
576
|
+
return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
|
|
577
|
+
}
|
|
578
|
+
function timeoutMsForMethod(method, opts) {
|
|
579
|
+
const p = resolvePolicy(opts);
|
|
580
|
+
if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
|
|
581
|
+
if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
|
|
582
|
+
return p.requestTimeoutMs;
|
|
583
|
+
}
|
|
584
|
+
async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
|
|
585
|
+
if (timeoutMs <= 0) {
|
|
586
|
+
return provider.request(args);
|
|
587
|
+
}
|
|
588
|
+
let timer;
|
|
589
|
+
try {
|
|
590
|
+
return await Promise.race([
|
|
591
|
+
provider.request(args),
|
|
592
|
+
new Promise((_, reject) => {
|
|
593
|
+
timer = setTimeout(() => {
|
|
594
|
+
reject(
|
|
595
|
+
new WalletTimeoutError(
|
|
596
|
+
`Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
|
|
597
|
+
{ method: args.method, timeoutMs }
|
|
598
|
+
)
|
|
599
|
+
);
|
|
600
|
+
}, timeoutMs);
|
|
601
|
+
})
|
|
602
|
+
]);
|
|
603
|
+
} finally {
|
|
604
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
function withTimeoutProvider(provider, opts) {
|
|
608
|
+
const flagged = provider;
|
|
609
|
+
if (flagged[TIMED_FLAG]) return provider;
|
|
610
|
+
const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
|
|
611
|
+
const wrapped = {
|
|
612
|
+
request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
|
|
613
|
+
};
|
|
614
|
+
Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
|
|
615
|
+
return wrapped;
|
|
616
|
+
}
|
|
617
|
+
function resolveRawProvider(wallet) {
|
|
445
618
|
if (wallet?.provider) return wallet.provider;
|
|
446
619
|
const injected = globalThis.ethereum;
|
|
447
620
|
const connector = wallet?.connector ?? "base-account";
|
|
@@ -458,18 +631,23 @@ function resolveProvider(wallet) {
|
|
|
458
631
|
"base-account connector needs a provider. In the Base App it is injected automatically; elsewhere, create one with @base-org/account and pass it as wallet.provider."
|
|
459
632
|
);
|
|
460
633
|
}
|
|
461
|
-
|
|
634
|
+
function resolveProvider(wallet, opts) {
|
|
635
|
+
const raw = resolveRawProvider(wallet);
|
|
636
|
+
return withTimeoutProvider(raw, opts);
|
|
637
|
+
}
|
|
638
|
+
async function walletAvailable(wallet, opts) {
|
|
462
639
|
if (wallet?.provider) return true;
|
|
463
640
|
let provider;
|
|
464
641
|
try {
|
|
465
|
-
provider = resolveProvider(wallet);
|
|
642
|
+
provider = resolveProvider(wallet, opts);
|
|
466
643
|
} catch {
|
|
467
644
|
return false;
|
|
468
645
|
}
|
|
469
646
|
try {
|
|
470
647
|
await getAccount(provider);
|
|
471
648
|
return true;
|
|
472
|
-
} catch {
|
|
649
|
+
} catch (e) {
|
|
650
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
473
651
|
return false;
|
|
474
652
|
}
|
|
475
653
|
}
|
|
@@ -480,6 +658,7 @@ async function getAccount(provider) {
|
|
|
480
658
|
if (!addr) throw new Error("no account");
|
|
481
659
|
return addr;
|
|
482
660
|
} catch (e) {
|
|
661
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
483
662
|
throw new WalletConnectionError("Could not read the player's wallet account.", {
|
|
484
663
|
cause: e?.message
|
|
485
664
|
});
|
|
@@ -553,6 +732,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
|
|
|
553
732
|
try {
|
|
554
733
|
result = await provider.request({ method: "wallet_sendCalls", params: [params] });
|
|
555
734
|
} catch (e) {
|
|
735
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
556
736
|
const msg = e?.message ?? String(e);
|
|
557
737
|
if (/reject|denied|cancel|closed/i.test(msg)) {
|
|
558
738
|
throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
|
|
@@ -567,14 +747,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
|
|
|
567
747
|
if (!id) return { status: "FAILED" };
|
|
568
748
|
const deadline = Date.now() + timeoutMs;
|
|
569
749
|
while (Date.now() < deadline) {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
750
|
+
try {
|
|
751
|
+
const res = await provider.request({
|
|
752
|
+
method: "wallet_getCallsStatus",
|
|
753
|
+
params: [id]
|
|
754
|
+
});
|
|
755
|
+
const s = String(res?.status ?? "").toUpperCase();
|
|
756
|
+
const txHash = res?.receipts?.[0]?.transactionHash;
|
|
757
|
+
if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
|
|
758
|
+
if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
|
|
759
|
+
} catch (e) {
|
|
760
|
+
if (e instanceof WalletTimeoutError) {
|
|
761
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
throw e;
|
|
765
|
+
}
|
|
578
766
|
await new Promise((r) => setTimeout(r, 900));
|
|
579
767
|
}
|
|
580
768
|
return { status: "PENDING" };
|
|
@@ -706,12 +894,12 @@ function mockVerifyResult(payment) {
|
|
|
706
894
|
}
|
|
707
895
|
|
|
708
896
|
// src/x402.ts
|
|
709
|
-
var
|
|
710
|
-
function
|
|
897
|
+
var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
898
|
+
function requireAddress2(value, field) {
|
|
711
899
|
if (typeof value !== "string" || value.trim() === "") {
|
|
712
900
|
throw new MissingFieldError(field);
|
|
713
901
|
}
|
|
714
|
-
if (!
|
|
902
|
+
if (!ADDRESS_RE2.test(value)) {
|
|
715
903
|
throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
|
|
716
904
|
field,
|
|
717
905
|
value
|
|
@@ -781,7 +969,7 @@ function createX402Challenge(requirement, extras) {
|
|
|
781
969
|
if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
|
|
782
970
|
throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
|
|
783
971
|
}
|
|
784
|
-
|
|
972
|
+
requireAddress2(requirement.payTo, "payTo");
|
|
785
973
|
parseUsdToMicro(requirement.amount);
|
|
786
974
|
const feeBps = extras?.feeBps ?? 0;
|
|
787
975
|
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
|
|
@@ -791,7 +979,7 @@ function createX402Challenge(requirement, extras) {
|
|
|
791
979
|
);
|
|
792
980
|
}
|
|
793
981
|
const feeSink = extras?.feeSink ?? null;
|
|
794
|
-
if (feeBps > 0 && feeSink)
|
|
982
|
+
if (feeBps > 0 && feeSink) requireAddress2(feeSink, "feeSink");
|
|
795
983
|
const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
|
|
796
984
|
return {
|
|
797
985
|
status: 402,
|
|
@@ -819,7 +1007,7 @@ function validateX402ChallengeInput(input) {
|
|
|
819
1007
|
{ field: "url" }
|
|
820
1008
|
);
|
|
821
1009
|
}
|
|
822
|
-
const payTo =
|
|
1010
|
+
const payTo = requireAddress2(input.payTo, "payTo");
|
|
823
1011
|
parseUsdToMicro(input.amount);
|
|
824
1012
|
const intent = input.intent ?? "transfer";
|
|
825
1013
|
if (intent !== "transfer" && intent !== "marketplace.buy") {
|
|
@@ -835,7 +1023,7 @@ function validateX402ChallengeInput(input) {
|
|
|
835
1023
|
{ feeBps: input.feeBps }
|
|
836
1024
|
);
|
|
837
1025
|
}
|
|
838
|
-
const feeSink = input.feeSink === void 0 ? void 0 :
|
|
1026
|
+
const feeSink = input.feeSink === void 0 ? void 0 : requireAddress2(input.feeSink, "feeSink");
|
|
839
1027
|
return {
|
|
840
1028
|
payTo,
|
|
841
1029
|
amount: input.amount,
|
|
@@ -849,12 +1037,55 @@ function validateX402ChallengeInput(input) {
|
|
|
849
1037
|
}
|
|
850
1038
|
|
|
851
1039
|
// src/client.ts
|
|
852
|
-
var
|
|
1040
|
+
var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
|
|
1041
|
+
function mapAlreadyEntered(e) {
|
|
1042
|
+
const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
|
|
1043
|
+
const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
|
|
1044
|
+
const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
|
|
1045
|
+
if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
|
|
1046
|
+
throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
|
|
1047
|
+
}
|
|
1048
|
+
throw e;
|
|
1049
|
+
}
|
|
1050
|
+
var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
|
|
1051
|
+
var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
|
|
1052
|
+
function isSoftRoundPollError(e, kind) {
|
|
1053
|
+
if (e instanceof AuthError || e instanceof ConfigError) return false;
|
|
1054
|
+
if (e instanceof ApiError) {
|
|
1055
|
+
const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
|
|
1056
|
+
const msg = e.message;
|
|
1057
|
+
if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
1060
|
+
if (kind === "settle") {
|
|
1061
|
+
if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
} else {
|
|
1065
|
+
if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
|
|
1066
|
+
msg
|
|
1067
|
+
)) {
|
|
1068
|
+
return false;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
|
|
1072
|
+
return true;
|
|
1073
|
+
}
|
|
1074
|
+
if (status !== void 0 && status >= 400 && status < 500) return false;
|
|
1075
|
+
if (status !== void 0 && status >= 500) return true;
|
|
1076
|
+
}
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
function isSoftSettlePollError(e) {
|
|
1080
|
+
return isSoftRoundPollError(e, "settle");
|
|
1081
|
+
}
|
|
1082
|
+
var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
|
|
1083
|
+
var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
|
|
853
1084
|
function requireAddressField(value, field) {
|
|
854
1085
|
if (typeof value !== "string" || value.trim() === "") {
|
|
855
1086
|
throw new MissingFieldError(field);
|
|
856
1087
|
}
|
|
857
|
-
if (!
|
|
1088
|
+
if (!ADDRESS_RE3.test(value)) {
|
|
858
1089
|
throw new ConfigError(
|
|
859
1090
|
`${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
|
|
860
1091
|
{ field, value }
|
|
@@ -1042,11 +1273,22 @@ var Playmos = class {
|
|
|
1042
1273
|
* rounds.settle({ ranking }) → contract pays winners.
|
|
1043
1274
|
*
|
|
1044
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.
|
|
1045
1280
|
*/
|
|
1046
1281
|
/** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
|
|
1047
1282
|
this.mockRounds = /* @__PURE__ */ new Map();
|
|
1048
1283
|
/** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
|
|
1049
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();
|
|
1050
1292
|
this.rounds = {
|
|
1051
1293
|
open: async (input) => {
|
|
1052
1294
|
if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
|
|
@@ -1085,6 +1327,8 @@ var Playmos = class {
|
|
|
1085
1327
|
prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
|
|
1086
1328
|
};
|
|
1087
1329
|
this.mockRounds.set(input.roundId, round);
|
|
1330
|
+
this.mockPotMicro.set(input.roundId, 0n);
|
|
1331
|
+
this.mockSettleWinners.delete(input.roundId);
|
|
1088
1332
|
return round;
|
|
1089
1333
|
}
|
|
1090
1334
|
const body = await this.http.post(
|
|
@@ -1117,11 +1361,12 @@ var Playmos = class {
|
|
|
1117
1361
|
code: "conflict"
|
|
1118
1362
|
});
|
|
1119
1363
|
}
|
|
1364
|
+
const payableMicro = this.mockPayablePoolMicro(existing);
|
|
1365
|
+
const poolUsd = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
|
|
1120
1366
|
const locked = {
|
|
1121
1367
|
...existing,
|
|
1122
1368
|
status: "locked",
|
|
1123
|
-
|
|
1124
|
-
pool: existing.pool ?? existing.entryAmount,
|
|
1369
|
+
pool: poolUsd,
|
|
1125
1370
|
entrants: existing.entrants ?? 0,
|
|
1126
1371
|
lockTxHash: MOCK_TX
|
|
1127
1372
|
};
|
|
@@ -1144,14 +1389,15 @@ var Playmos = class {
|
|
|
1144
1389
|
throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
|
|
1145
1390
|
}
|
|
1146
1391
|
if (existing.status === "settled" && existing.settleTxHash) {
|
|
1392
|
+
const stored = this.mockSettleWinners.get(input.roundId);
|
|
1147
1393
|
return {
|
|
1148
1394
|
roundId: input.roundId,
|
|
1149
1395
|
txHash: existing.settleTxHash,
|
|
1150
1396
|
poolPaid: existing.pool ?? "0",
|
|
1151
|
-
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) => ({
|
|
1152
1398
|
wallet,
|
|
1153
|
-
amount:
|
|
1154
|
-
})),
|
|
1399
|
+
amount: "0"
|
|
1400
|
+
}))),
|
|
1155
1401
|
status: "settled"
|
|
1156
1402
|
};
|
|
1157
1403
|
}
|
|
@@ -1161,13 +1407,45 @@ var Playmos = class {
|
|
|
1161
1407
|
code: "conflict"
|
|
1162
1408
|
});
|
|
1163
1409
|
}
|
|
1164
|
-
const
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
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 }));
|
|
1169
1448
|
const settleTxHash = MOCK_TX;
|
|
1170
|
-
const poolPaid = existing.pool ?? existing.entryAmount;
|
|
1171
1449
|
const settled = {
|
|
1172
1450
|
...existing,
|
|
1173
1451
|
status: "settled",
|
|
@@ -1175,18 +1453,11 @@ var Playmos = class {
|
|
|
1175
1453
|
pool: poolPaid
|
|
1176
1454
|
};
|
|
1177
1455
|
this.mockRounds.set(input.roundId, settled);
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
} catch {
|
|
1184
|
-
micro = 0n;
|
|
1185
|
-
}
|
|
1186
|
-
if (micro > 0n) {
|
|
1187
|
-
const k = `${input.roundId}:${wAddr}`;
|
|
1188
|
-
this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
|
|
1189
|
-
}
|
|
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);
|
|
1190
1461
|
}
|
|
1191
1462
|
return {
|
|
1192
1463
|
roundId: input.roundId,
|
|
@@ -1196,17 +1467,61 @@ var Playmos = class {
|
|
|
1196
1467
|
status: "settled"
|
|
1197
1468
|
};
|
|
1198
1469
|
}
|
|
1199
|
-
const
|
|
1470
|
+
const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
|
|
1471
|
+
const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
|
|
1472
|
+
const body = { gameId: input.gameId, results: input.results };
|
|
1473
|
+
const idempotencyKey = `settle:${input.roundId}`;
|
|
1474
|
+
const postSettle = () => this.http.post(
|
|
1200
1475
|
`/rounds/${encodeURIComponent(input.roundId)}/settle`,
|
|
1201
|
-
|
|
1202
|
-
{ acceptStatuses: [202] }
|
|
1476
|
+
body,
|
|
1477
|
+
{ acceptStatuses: [200, 202], idempotencyKey }
|
|
1203
1478
|
);
|
|
1204
|
-
|
|
1479
|
+
let { settle } = await postSettle();
|
|
1480
|
+
if (settle.status === "settled") return settle;
|
|
1481
|
+
const firstWinners = settle.winners;
|
|
1482
|
+
const firstPoolPaid = settle.poolPaid ?? null;
|
|
1483
|
+
const withFirstExtras = (s) => ({
|
|
1484
|
+
...s,
|
|
1485
|
+
winners: s.winners ?? firstWinners,
|
|
1486
|
+
poolPaid: s.poolPaid ?? firstPoolPaid
|
|
1487
|
+
});
|
|
1488
|
+
const deadline = Date.now() + timeoutMs;
|
|
1489
|
+
while (Date.now() < deadline && settle.status === "settling") {
|
|
1490
|
+
const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
|
|
1491
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
1492
|
+
if (Date.now() >= deadline) break;
|
|
1493
|
+
try {
|
|
1494
|
+
const again = await postSettle();
|
|
1495
|
+
settle = withFirstExtras(again.settle);
|
|
1496
|
+
if (settle.status === "settled") return settle;
|
|
1497
|
+
} catch (e) {
|
|
1498
|
+
if (!isSoftSettlePollError(e)) throw e;
|
|
1499
|
+
}
|
|
1500
|
+
try {
|
|
1501
|
+
const round = await this.rounds.get({ roundId: input.roundId });
|
|
1502
|
+
if (round.status === "settled") {
|
|
1503
|
+
return {
|
|
1504
|
+
roundId: input.roundId,
|
|
1505
|
+
txHash: round.settleTxHash ?? settle.txHash ?? null,
|
|
1506
|
+
poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
|
|
1507
|
+
winners: settle.winners ?? firstWinners,
|
|
1508
|
+
status: "settled"
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
if (!isSoftSettlePollError(e)) throw e;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return withFirstExtras(settle);
|
|
1205
1516
|
},
|
|
1206
1517
|
/**
|
|
1207
1518
|
* Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
|
|
1208
|
-
* and frees the series latch
|
|
1209
|
-
*
|
|
1519
|
+
* and frees the series latch (#346 / #376).
|
|
1520
|
+
*
|
|
1521
|
+
* **Default is submit-only:** may return `status: "cancelling"` after broadcast —
|
|
1522
|
+
* that is **not** success. Only `status: "cancelled"` means the chain is terminal.
|
|
1523
|
+
* Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
|
|
1524
|
+
* if still pending — never invents terminal success).
|
|
1210
1525
|
*/
|
|
1211
1526
|
cancel: async (input) => {
|
|
1212
1527
|
if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
|
|
@@ -1234,9 +1549,12 @@ var Playmos = class {
|
|
|
1234
1549
|
...existing,
|
|
1235
1550
|
status: "cancelled",
|
|
1236
1551
|
cancelTxHash: MOCK_TX,
|
|
1237
|
-
pool: null
|
|
1552
|
+
pool: null,
|
|
1553
|
+
entrants: 0
|
|
1238
1554
|
};
|
|
1239
1555
|
this.mockRounds.set(input.roundId, cancelled);
|
|
1556
|
+
this.mockPotMicro.set(input.roundId, 0n);
|
|
1557
|
+
this.mockSettleWinners.delete(input.roundId);
|
|
1240
1558
|
return {
|
|
1241
1559
|
roundId: input.roundId,
|
|
1242
1560
|
txHash: MOCK_TX,
|
|
@@ -1244,18 +1562,57 @@ var Playmos = class {
|
|
|
1244
1562
|
round: cancelled
|
|
1245
1563
|
};
|
|
1246
1564
|
}
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
)
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1565
|
+
const bodyPayload = { gameId: input.gameId };
|
|
1566
|
+
const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
|
|
1567
|
+
acceptStatuses: [200, 202]
|
|
1568
|
+
});
|
|
1569
|
+
const toResult = (body, priorTx) => {
|
|
1570
|
+
const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
|
|
1571
|
+
const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
|
|
1572
|
+
const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
|
|
1573
|
+
const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
|
|
1574
|
+
return {
|
|
1575
|
+
roundId: input.roundId,
|
|
1576
|
+
txHash: tx,
|
|
1577
|
+
status,
|
|
1578
|
+
round: body.round
|
|
1579
|
+
};
|
|
1258
1580
|
};
|
|
1581
|
+
let result = toResult(await postCancel());
|
|
1582
|
+
if (result.status === "cancelled") return result;
|
|
1583
|
+
if (!input.confirm) return result;
|
|
1584
|
+
const timeoutMs = Math.min(
|
|
1585
|
+
Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
|
|
1586
|
+
CANCEL_RECONCILE_TIMEOUT_MS
|
|
1587
|
+
);
|
|
1588
|
+
const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
|
|
1589
|
+
const deadline = Date.now() + timeoutMs;
|
|
1590
|
+
const firstTx = result.txHash;
|
|
1591
|
+
while (Date.now() < deadline && result.status === "cancelling") {
|
|
1592
|
+
const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
|
|
1593
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
1594
|
+
if (Date.now() >= deadline) break;
|
|
1595
|
+
try {
|
|
1596
|
+
result = toResult(await postCancel(), firstTx);
|
|
1597
|
+
if (result.status === "cancelled") return result;
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
if (!isSoftRoundPollError(e, "cancel")) throw e;
|
|
1600
|
+
}
|
|
1601
|
+
try {
|
|
1602
|
+
const round = await this.rounds.get({ roundId: input.roundId });
|
|
1603
|
+
if (round.status === "cancelled") {
|
|
1604
|
+
return {
|
|
1605
|
+
roundId: input.roundId,
|
|
1606
|
+
txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
|
|
1607
|
+
status: "cancelled",
|
|
1608
|
+
round
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
} catch (e) {
|
|
1612
|
+
if (!isSoftRoundPollError(e, "cancel")) throw e;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
return { ...result, txHash: result.txHash ?? firstTx ?? null };
|
|
1259
1616
|
},
|
|
1260
1617
|
get: async (input) => {
|
|
1261
1618
|
requireField(input?.roundId, "roundId");
|
|
@@ -1406,7 +1763,7 @@ var Playmos = class {
|
|
|
1406
1763
|
}
|
|
1407
1764
|
prizePool = round.prizePoolAddress;
|
|
1408
1765
|
}
|
|
1409
|
-
const provider =
|
|
1766
|
+
const provider = this.walletProvider();
|
|
1410
1767
|
if (this.config.gas?.mode === "player") {
|
|
1411
1768
|
const from0 = await getAccount(provider);
|
|
1412
1769
|
await assertEnoughGas(provider, from0);
|
|
@@ -1423,18 +1780,31 @@ var Playmos = class {
|
|
|
1423
1780
|
const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
|
|
1424
1781
|
let txHash;
|
|
1425
1782
|
let status = "pending";
|
|
1783
|
+
let callsId;
|
|
1426
1784
|
try {
|
|
1427
|
-
const
|
|
1785
|
+
const sent = await sendCalls(
|
|
1428
1786
|
provider,
|
|
1429
1787
|
from,
|
|
1430
1788
|
this.env.chainId,
|
|
1431
1789
|
[call],
|
|
1432
1790
|
paymasterUrl
|
|
1433
1791
|
);
|
|
1792
|
+
callsId = sent.id;
|
|
1434
1793
|
const waited = await waitForCalls(provider, callsId);
|
|
1435
1794
|
txHash = waited.txHash;
|
|
1436
1795
|
status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
|
|
1437
1796
|
} catch (e) {
|
|
1797
|
+
if (e instanceof WalletTimeoutError && callsId) {
|
|
1798
|
+
return {
|
|
1799
|
+
prizePoolAddress: prizePool,
|
|
1800
|
+
amount: formatMicroToUsd(amountMicro),
|
|
1801
|
+
amountMicro: amountMicro.toString(),
|
|
1802
|
+
txHash,
|
|
1803
|
+
status: "pending",
|
|
1804
|
+
callsId
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
1438
1808
|
const msg = e?.message ?? String(e);
|
|
1439
1809
|
if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
|
|
1440
1810
|
throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
|
|
@@ -1454,7 +1824,8 @@ var Playmos = class {
|
|
|
1454
1824
|
amount: formatMicroToUsd(amountMicro),
|
|
1455
1825
|
amountMicro: amountMicro.toString(),
|
|
1456
1826
|
txHash,
|
|
1457
|
-
status
|
|
1827
|
+
status,
|
|
1828
|
+
...status === "pending" && callsId ? { callsId } : {}
|
|
1458
1829
|
};
|
|
1459
1830
|
}
|
|
1460
1831
|
};
|
|
@@ -1701,13 +2072,99 @@ var Playmos = class {
|
|
|
1701
2072
|
}
|
|
1702
2073
|
}
|
|
1703
2074
|
this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
|
|
2075
|
+
if (!config.mock) {
|
|
2076
|
+
try {
|
|
2077
|
+
resolveProvider(config.wallet, this.walletTimeoutPolicy());
|
|
2078
|
+
} catch {
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
/**
|
|
2083
|
+
* Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
|
|
2084
|
+
*/
|
|
2085
|
+
findMockRound(idOrKey) {
|
|
2086
|
+
const direct = this.mockRounds.get(idOrKey);
|
|
2087
|
+
if (direct) return direct;
|
|
2088
|
+
for (const r of this.mockRounds.values()) {
|
|
2089
|
+
if (r.roundId === idOrKey || r.roundKey === idOrKey) return r;
|
|
2090
|
+
}
|
|
2091
|
+
return void 0;
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
|
|
2095
|
+
* (would zero 0.135 from top-n 30% of $0.45).
|
|
2096
|
+
*/
|
|
2097
|
+
parseUsdToMicroLoose(amount) {
|
|
2098
|
+
if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
|
|
2099
|
+
throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
|
|
2100
|
+
}
|
|
2101
|
+
const [wholeRaw, frac = ""] = amount.trim().split(".");
|
|
2102
|
+
const whole = wholeRaw ?? "0";
|
|
2103
|
+
const fracPadded = (frac + "000000").slice(0, 6);
|
|
2104
|
+
const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
|
|
2105
|
+
if (micro < 0n) throw new PayoutError(`amount must be >= 0, got: ${JSON.stringify(amount)}`);
|
|
2106
|
+
return micro;
|
|
2107
|
+
}
|
|
2108
|
+
/** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
|
|
2109
|
+
mockPayoutApiError(e) {
|
|
2110
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2111
|
+
throw new ApiError(msg, { status: 400, code: "payout_invalid" });
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
|
|
2115
|
+
* No-op when no matching open/locked round — never 404 (C4).
|
|
2116
|
+
*/
|
|
2117
|
+
mockAccumulateEntry(idOrKey, amountMicro) {
|
|
2118
|
+
const round = this.findMockRound(idOrKey);
|
|
2119
|
+
if (!round) return;
|
|
2120
|
+
if (round.status !== "open" && round.status !== "locked") return;
|
|
2121
|
+
const rake = amountMicro * BigInt(RAKE_BPS) / 10000n;
|
|
2122
|
+
const escrowed = amountMicro - rake;
|
|
2123
|
+
const pot = (this.mockPotMicro.get(round.roundId) ?? 0n) + escrowed;
|
|
2124
|
+
this.mockPotMicro.set(round.roundId, pot);
|
|
2125
|
+
const entrants = (round.entrants ?? 0) + 1;
|
|
2126
|
+
const updated = { ...round, entrants };
|
|
2127
|
+
this.mockRounds.set(round.roundId, updated);
|
|
2128
|
+
}
|
|
2129
|
+
/**
|
|
2130
|
+
* Payable pool micro for mock lock/settle.
|
|
2131
|
+
* With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
|
|
2132
|
+
* Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
|
|
2133
|
+
*/
|
|
2134
|
+
mockPayablePoolMicro(round) {
|
|
2135
|
+
const pot = this.mockPotMicro.get(round.roundId) ?? 0n;
|
|
2136
|
+
if (pot > 0n) {
|
|
2137
|
+
return pot * BigInt(POOL_BPS) / BigInt(POOL_BPS + SEED_BPS);
|
|
2138
|
+
}
|
|
2139
|
+
try {
|
|
2140
|
+
return this.parseUsdToMicroLoose(round.entryAmount);
|
|
2141
|
+
} catch {
|
|
2142
|
+
return 0n;
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Wallet timeout policy (#462 / PR #463 residual).
|
|
2147
|
+
* - connectTimeoutMs → eth_requestAccounts only
|
|
2148
|
+
* - machine RPCs → 20s default
|
|
2149
|
+
* - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
|
|
2150
|
+
*/
|
|
2151
|
+
walletTimeoutPolicy() {
|
|
2152
|
+
const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
|
|
2153
|
+
return {
|
|
2154
|
+
connectTimeoutMs: connect,
|
|
2155
|
+
requestTimeoutMs: 2e4,
|
|
2156
|
+
sendCallsTimeoutMs: 0
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
walletProvider() {
|
|
2160
|
+
return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
|
|
1704
2161
|
}
|
|
1705
2162
|
/** Connect the player's wallet and return their address. */
|
|
1706
2163
|
async connect() {
|
|
1707
2164
|
if (this.config.mock) {
|
|
1708
2165
|
return "0x0000000000000000000000000000000000000001";
|
|
1709
2166
|
}
|
|
1710
|
-
return getAccount(
|
|
2167
|
+
return getAccount(this.walletProvider());
|
|
1711
2168
|
}
|
|
1712
2169
|
/**
|
|
1713
2170
|
* A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
|
|
@@ -1767,7 +2224,7 @@ var Playmos = class {
|
|
|
1767
2224
|
})
|
|
1768
2225
|
);
|
|
1769
2226
|
}
|
|
1770
|
-
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
2227
|
+
if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
|
|
1771
2228
|
const res = await this.http.post(
|
|
1772
2229
|
"/payments",
|
|
1773
2230
|
{
|
|
@@ -1805,7 +2262,7 @@ var Playmos = class {
|
|
|
1805
2262
|
},
|
|
1806
2263
|
{ idempotencyKey }
|
|
1807
2264
|
);
|
|
1808
|
-
const provider =
|
|
2265
|
+
const provider = this.walletProvider();
|
|
1809
2266
|
if (this.config.gas?.mode === "player") {
|
|
1810
2267
|
const from0 = await getAccount(provider);
|
|
1811
2268
|
await assertEnoughGas(provider, from0);
|
|
@@ -1833,6 +2290,7 @@ var Playmos = class {
|
|
|
1833
2290
|
const metadata = validateMetadata(input.metadata);
|
|
1834
2291
|
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
1835
2292
|
if (this.config.mock) {
|
|
2293
|
+
this.mockAccumulateEntry(input.roundId, amountMicro);
|
|
1836
2294
|
return this.rememberMock(
|
|
1837
2295
|
mockEntryPayment({
|
|
1838
2296
|
amountMicro,
|
|
@@ -1850,7 +2308,17 @@ var Playmos = class {
|
|
|
1850
2308
|
})
|
|
1851
2309
|
);
|
|
1852
2310
|
}
|
|
1853
|
-
|
|
2311
|
+
const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
|
|
2312
|
+
if (!serverSettleNoWallet) {
|
|
2313
|
+
const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
|
|
2314
|
+
if (!pinned) {
|
|
2315
|
+
throw new ConfigError(
|
|
2316
|
+
'enterRound: "identity" is required when using a player wallet. Pass the same identity your game server will use for hasEntered / entry confirm. One entry per identity \u2014 use a unique value per paid attempt (pay-per-play). Silent invent caused paid-but-not-admitted failures (#374 / #466). No-wallet sandbox smoke may omit identity (server-settle derives one).',
|
|
2317
|
+
{ field: "identity" }
|
|
2318
|
+
);
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
if (serverSettleNoWallet) {
|
|
1854
2322
|
const pinnedRoundKey = input.roundKey ?? input.roundId;
|
|
1855
2323
|
const res = await this.http.post(
|
|
1856
2324
|
"/payments",
|
|
@@ -1895,7 +2363,7 @@ var Playmos = class {
|
|
|
1895
2363
|
},
|
|
1896
2364
|
{ idempotencyKey }
|
|
1897
2365
|
);
|
|
1898
|
-
const provider =
|
|
2366
|
+
const provider = this.walletProvider();
|
|
1899
2367
|
if (this.config.gas?.mode === "player") {
|
|
1900
2368
|
const from0 = await getAccount(provider);
|
|
1901
2369
|
await assertEnoughGas(provider, from0);
|
|
@@ -1906,12 +2374,28 @@ var Playmos = class {
|
|
|
1906
2374
|
if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
|
|
1907
2375
|
const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
|
|
1908
2376
|
if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
|
|
1909
|
-
const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity
|
|
2377
|
+
const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
|
|
2378
|
+
if (!identity || !String(identity).trim()) {
|
|
2379
|
+
throw new ConfigError(
|
|
2380
|
+
"enterRound: missing on-chain identity after create-intent (pass input.identity).",
|
|
2381
|
+
{ field: "identity" }
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
1910
2384
|
const amountUnits = this.resolveUnits(intent, amountMicro);
|
|
1911
2385
|
const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
|
|
1912
|
-
|
|
2386
|
+
let callsId;
|
|
2387
|
+
try {
|
|
2388
|
+
({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
|
|
2389
|
+
} catch (e) {
|
|
2390
|
+
throw mapAlreadyEntered(e);
|
|
2391
|
+
}
|
|
1913
2392
|
const { txHash } = await waitForCalls(provider, callsId);
|
|
1914
|
-
|
|
2393
|
+
let payment;
|
|
2394
|
+
try {
|
|
2395
|
+
payment = await this.settle(intent.payment.id, txHash);
|
|
2396
|
+
} catch (e) {
|
|
2397
|
+
throw mapAlreadyEntered(e);
|
|
2398
|
+
}
|
|
1915
2399
|
payment.identity = identity;
|
|
1916
2400
|
payment.prizePoolAddress = prizePool.toLowerCase();
|
|
1917
2401
|
payment.roundKey = roundKey;
|
|
@@ -2108,8 +2592,8 @@ var Playmos = class {
|
|
|
2108
2592
|
});
|
|
2109
2593
|
let raw;
|
|
2110
2594
|
try {
|
|
2111
|
-
if (await walletAvailable(this.config.wallet)) {
|
|
2112
|
-
const provider =
|
|
2595
|
+
if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
|
|
2596
|
+
const provider = this.walletProvider();
|
|
2113
2597
|
raw = await provider.request({
|
|
2114
2598
|
method: "eth_call",
|
|
2115
2599
|
params: [{ to: prizePool, data }, "latest"]
|
|
@@ -2136,7 +2620,7 @@ var Playmos = class {
|
|
|
2136
2620
|
raw = json.result;
|
|
2137
2621
|
}
|
|
2138
2622
|
} catch (e) {
|
|
2139
|
-
if (e instanceof ApiError) throw e;
|
|
2623
|
+
if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
|
|
2140
2624
|
throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
|
|
2141
2625
|
prizePool,
|
|
2142
2626
|
wallet
|
|
@@ -2227,112 +2711,6 @@ function previewPoolSplit(amount) {
|
|
|
2227
2711
|
};
|
|
2228
2712
|
}
|
|
2229
2713
|
|
|
2230
|
-
// src/payout.ts
|
|
2231
|
-
var PayoutError = class extends Error {
|
|
2232
|
-
constructor(message) {
|
|
2233
|
-
super(message);
|
|
2234
|
-
this.code = "payout_invalid";
|
|
2235
|
-
this.name = "PayoutError";
|
|
2236
|
-
}
|
|
2237
|
-
};
|
|
2238
|
-
var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
|
|
2239
|
-
var BPS = 10000n;
|
|
2240
|
-
function requireAddress2(w, i) {
|
|
2241
|
-
if (!ADDRESS_RE3.test(w)) {
|
|
2242
|
-
throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
|
|
2243
|
-
}
|
|
2244
|
-
return w.toLowerCase();
|
|
2245
|
-
}
|
|
2246
|
-
function parseUsdToMicroLoose(amount) {
|
|
2247
|
-
if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
|
|
2248
|
-
throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
|
|
2249
|
-
}
|
|
2250
|
-
const [wholeRaw, frac = ""] = amount.trim().split(".");
|
|
2251
|
-
const whole = wholeRaw ?? "0";
|
|
2252
|
-
const fracPadded = (frac + "000000").slice(0, 6);
|
|
2253
|
-
const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
|
|
2254
|
-
if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
|
|
2255
|
-
return micro;
|
|
2256
|
-
}
|
|
2257
|
-
function computePayout(pool, ranking, rule) {
|
|
2258
|
-
if (typeof pool !== "bigint" || pool <= 0n) {
|
|
2259
|
-
throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
|
|
2260
|
-
}
|
|
2261
|
-
if (!Array.isArray(ranking) || ranking.length === 0) {
|
|
2262
|
-
throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
|
|
2263
|
-
}
|
|
2264
|
-
const wallets = ranking.map((w, i) => requireAddress2(w, i));
|
|
2265
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2266
|
-
for (const w of wallets) {
|
|
2267
|
-
if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
|
|
2268
|
-
seen.add(w);
|
|
2269
|
-
}
|
|
2270
|
-
if (rule.kind === "winner-take-all") {
|
|
2271
|
-
return [{ wallet: wallets[0], amount: pool }];
|
|
2272
|
-
}
|
|
2273
|
-
if (rule.kind === "top-n") {
|
|
2274
|
-
const splits = rule.splitsBps;
|
|
2275
|
-
if (!Array.isArray(splits) || splits.length === 0) {
|
|
2276
|
-
throw new PayoutError("top-n splitsBps must be a non-empty array");
|
|
2277
|
-
}
|
|
2278
|
-
if (splits.length > wallets.length) {
|
|
2279
|
-
throw new PayoutError(
|
|
2280
|
-
`top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
|
|
2281
|
-
);
|
|
2282
|
-
}
|
|
2283
|
-
let sumBps = 0;
|
|
2284
|
-
for (const b of splits) {
|
|
2285
|
-
if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
|
|
2286
|
-
throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
|
|
2287
|
-
}
|
|
2288
|
-
sumBps += b;
|
|
2289
|
-
}
|
|
2290
|
-
if (sumBps !== 1e4) {
|
|
2291
|
-
throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
|
|
2292
|
-
}
|
|
2293
|
-
const out = [];
|
|
2294
|
-
let allocated = 0n;
|
|
2295
|
-
for (let i = 0; i < splits.length; i++) {
|
|
2296
|
-
const amt = pool * BigInt(splits[i]) / BPS;
|
|
2297
|
-
out.push({ wallet: wallets[i], amount: amt });
|
|
2298
|
-
allocated += amt;
|
|
2299
|
-
}
|
|
2300
|
-
const remainder = pool - allocated;
|
|
2301
|
-
if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
|
|
2302
|
-
out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
|
|
2303
|
-
const filtered = out.filter((x) => x.amount > 0n);
|
|
2304
|
-
if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
|
|
2305
|
-
const total = filtered.reduce((s, x) => s + x.amount, 0n);
|
|
2306
|
-
if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
|
|
2307
|
-
return filtered;
|
|
2308
|
-
}
|
|
2309
|
-
if (rule.kind === "custom") {
|
|
2310
|
-
const amounts = rule.amounts;
|
|
2311
|
-
if (!Array.isArray(amounts) || amounts.length === 0) {
|
|
2312
|
-
throw new PayoutError("custom amounts must be a non-empty array of USD strings");
|
|
2313
|
-
}
|
|
2314
|
-
if (amounts.length > wallets.length) {
|
|
2315
|
-
throw new PayoutError(
|
|
2316
|
-
`custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
|
|
2317
|
-
);
|
|
2318
|
-
}
|
|
2319
|
-
const out = [];
|
|
2320
|
-
let total = 0n;
|
|
2321
|
-
for (let i = 0; i < amounts.length; i++) {
|
|
2322
|
-
const amt = parseUsdToMicroLoose(amounts[i]);
|
|
2323
|
-
out.push({ wallet: wallets[i], amount: amt });
|
|
2324
|
-
total += amt;
|
|
2325
|
-
}
|
|
2326
|
-
if (total !== pool) {
|
|
2327
|
-
throw new PayoutError(
|
|
2328
|
-
`custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
|
|
2329
|
-
);
|
|
2330
|
-
}
|
|
2331
|
-
return out;
|
|
2332
|
-
}
|
|
2333
|
-
throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
2714
|
// src/settlement.ts
|
|
2337
2715
|
var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
|
|
2338
2716
|
var DEFAULT_TTL_MS = 15 * 60 * 1e3;
|
|
@@ -2426,6 +2804,7 @@ function isX402PayloadAuthorization(auth) {
|
|
|
2426
2804
|
return auth.kind === "x402-payload";
|
|
2427
2805
|
}
|
|
2428
2806
|
|
|
2807
|
+
exports.AlreadyEnteredError = AlreadyEnteredError;
|
|
2429
2808
|
exports.ApiError = ApiError;
|
|
2430
2809
|
exports.AuthError = AuthError;
|
|
2431
2810
|
exports.CHAIN_ID = CHAIN_ID;
|
|
@@ -2443,6 +2822,7 @@ exports.PlaymosError = PlaymosError;
|
|
|
2443
2822
|
exports.USDC_ADDRESS = USDC_ADDRESS;
|
|
2444
2823
|
exports.USDC_DECIMALS = USDC_DECIMALS;
|
|
2445
2824
|
exports.WalletConnectionError = WalletConnectionError;
|
|
2825
|
+
exports.WalletTimeoutError = WalletTimeoutError;
|
|
2446
2826
|
exports.clientRuntimeSignals = clientRuntimeSignals;
|
|
2447
2827
|
exports.computeIapSplit = computeIapSplit;
|
|
2448
2828
|
exports.computePayout = computePayout;
|