@playmos/sdk 0.3.6 → 0.3.8
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 +42 -18
- 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 +390 -49
- package/dist/index.d.cts +18 -9
- package/dist/index.d.ts +18 -9
- package/dist/index.js +376 -51
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/dist/server.js +1 -1
- package/package.json +6 -2
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);
|
|
@@ -315,12 +329,66 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
315
329
|
}
|
|
316
330
|
return false;
|
|
317
331
|
}
|
|
332
|
+
async function probeSignerBelowFloor() {
|
|
333
|
+
try {
|
|
334
|
+
const res = await send(`${base}/health`, { method: "GET" });
|
|
335
|
+
if (!res.ok) return { below: false };
|
|
336
|
+
const body = await res.json();
|
|
337
|
+
const bal = body?.capabilities?.payments?.signerBalance;
|
|
338
|
+
if (!bal || bal.ok !== false) return { below: false };
|
|
339
|
+
const reason = bal.reason ?? "";
|
|
340
|
+
const fundedFloorMiss = reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth";
|
|
341
|
+
if (!fundedFloorMiss) return { below: false, reason: reason || void 0 };
|
|
342
|
+
return { below: true, reason };
|
|
343
|
+
} catch {
|
|
344
|
+
return { below: false };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
318
347
|
async function sendWithRetry(url, init) {
|
|
319
348
|
let res = await send(url, init);
|
|
349
|
+
let signerBelowFloor = false;
|
|
350
|
+
let signerBelowReason;
|
|
351
|
+
let probed = false;
|
|
352
|
+
const maybeProbe = async () => {
|
|
353
|
+
if (probed || signerBelowFloor) return;
|
|
354
|
+
if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
|
|
355
|
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
356
|
+
if (ct.includes("application/json")) return;
|
|
357
|
+
probed = true;
|
|
358
|
+
const probe = await probeSignerBelowFloor();
|
|
359
|
+
signerBelowFloor = probe.below;
|
|
360
|
+
signerBelowReason = probe.reason;
|
|
361
|
+
};
|
|
362
|
+
const maybePeekJsonSignerLow = async () => {
|
|
363
|
+
if (signerBelowFloor) return;
|
|
364
|
+
if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
|
|
365
|
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
366
|
+
if (!ct.includes("application/json")) return;
|
|
367
|
+
try {
|
|
368
|
+
const peek = JSON.parse(await res.clone().text());
|
|
369
|
+
const code = peek?.error?.code;
|
|
370
|
+
const reason = peek?.error?.reason;
|
|
371
|
+
if (code === "signer_low_balance" || reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth") {
|
|
372
|
+
signerBelowFloor = true;
|
|
373
|
+
signerBelowReason = reason ?? code;
|
|
374
|
+
}
|
|
375
|
+
} catch {
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
const classifySignerBelow = async () => {
|
|
379
|
+
await maybeProbe();
|
|
380
|
+
await maybePeekJsonSignerLow();
|
|
381
|
+
};
|
|
382
|
+
await classifySignerBelow();
|
|
320
383
|
for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
|
|
384
|
+
if (signerBelowFloor) break;
|
|
321
385
|
await sleep(backoffMs(res, attempt, cfg));
|
|
322
386
|
res = await send(url, init);
|
|
387
|
+
await classifySignerBelow();
|
|
323
388
|
}
|
|
389
|
+
const flagged = res;
|
|
390
|
+
flagged.__playmosSignerBelowFloor = signerBelowFloor;
|
|
391
|
+
if (signerBelowReason) flagged.__playmosSignerBelowReason = signerBelowReason;
|
|
324
392
|
return res;
|
|
325
393
|
}
|
|
326
394
|
async function handle(res, acceptStatuses) {
|
|
@@ -339,8 +407,22 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
339
407
|
json = text ? JSON.parse(text) : {};
|
|
340
408
|
} catch {
|
|
341
409
|
const gateway = res.status === 502 || res.status === 503 || res.status === 504;
|
|
342
|
-
const
|
|
343
|
-
|
|
410
|
+
const below = res.__playmosSignerBelowFloor === true;
|
|
411
|
+
let msg;
|
|
412
|
+
if (gateway && below) {
|
|
413
|
+
const why = res.__playmosSignerBelowReason ?? "low_balance";
|
|
414
|
+
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.`;
|
|
415
|
+
} else if (gateway) {
|
|
416
|
+
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).`;
|
|
417
|
+
} else {
|
|
418
|
+
msg = `Non-JSON response (${res.status}) from ${res.url}`;
|
|
419
|
+
}
|
|
420
|
+
throw new ApiError(msg, {
|
|
421
|
+
status: res.status,
|
|
422
|
+
body: text.slice(0, 500),
|
|
423
|
+
gateway,
|
|
424
|
+
signerBelowFloor: below || void 0
|
|
425
|
+
});
|
|
344
426
|
}
|
|
345
427
|
if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
|
|
346
428
|
return json;
|
|
@@ -373,7 +455,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
373
455
|
}
|
|
374
456
|
|
|
375
457
|
// src/wallet.ts
|
|
376
|
-
|
|
458
|
+
var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
|
|
459
|
+
var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
|
|
460
|
+
"wallet_sendCalls",
|
|
461
|
+
"eth_sendTransaction",
|
|
462
|
+
"eth_sendRawTransaction"
|
|
463
|
+
]);
|
|
464
|
+
var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
|
|
465
|
+
var TIMED_FLAG = "__playmosTimedRequest";
|
|
466
|
+
function resolvePolicy(opts) {
|
|
467
|
+
const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
|
|
468
|
+
const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
|
|
469
|
+
const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
|
|
470
|
+
return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
|
|
471
|
+
}
|
|
472
|
+
function timeoutMsForMethod(method, opts) {
|
|
473
|
+
const p = resolvePolicy(opts);
|
|
474
|
+
if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
|
|
475
|
+
if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
|
|
476
|
+
return p.requestTimeoutMs;
|
|
477
|
+
}
|
|
478
|
+
async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
|
|
479
|
+
if (timeoutMs <= 0) {
|
|
480
|
+
return provider.request(args);
|
|
481
|
+
}
|
|
482
|
+
let timer;
|
|
483
|
+
try {
|
|
484
|
+
return await Promise.race([
|
|
485
|
+
provider.request(args),
|
|
486
|
+
new Promise((_, reject) => {
|
|
487
|
+
timer = setTimeout(() => {
|
|
488
|
+
reject(
|
|
489
|
+
new WalletTimeoutError(
|
|
490
|
+
`Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
|
|
491
|
+
{ method: args.method, timeoutMs }
|
|
492
|
+
)
|
|
493
|
+
);
|
|
494
|
+
}, timeoutMs);
|
|
495
|
+
})
|
|
496
|
+
]);
|
|
497
|
+
} finally {
|
|
498
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function withTimeoutProvider(provider, opts) {
|
|
502
|
+
const flagged = provider;
|
|
503
|
+
if (flagged[TIMED_FLAG]) return provider;
|
|
504
|
+
const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
|
|
505
|
+
const wrapped = {
|
|
506
|
+
request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
|
|
507
|
+
};
|
|
508
|
+
Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
|
|
509
|
+
return wrapped;
|
|
510
|
+
}
|
|
511
|
+
function resolveRawProvider(wallet) {
|
|
377
512
|
if (wallet?.provider) return wallet.provider;
|
|
378
513
|
const injected = globalThis.ethereum;
|
|
379
514
|
const connector = wallet?.connector ?? "base-account";
|
|
@@ -390,18 +525,23 @@ function resolveProvider(wallet) {
|
|
|
390
525
|
"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."
|
|
391
526
|
);
|
|
392
527
|
}
|
|
393
|
-
|
|
528
|
+
function resolveProvider(wallet, opts) {
|
|
529
|
+
const raw = resolveRawProvider(wallet);
|
|
530
|
+
return withTimeoutProvider(raw, opts);
|
|
531
|
+
}
|
|
532
|
+
async function walletAvailable(wallet, opts) {
|
|
394
533
|
if (wallet?.provider) return true;
|
|
395
534
|
let provider;
|
|
396
535
|
try {
|
|
397
|
-
provider = resolveProvider(wallet);
|
|
536
|
+
provider = resolveProvider(wallet, opts);
|
|
398
537
|
} catch {
|
|
399
538
|
return false;
|
|
400
539
|
}
|
|
401
540
|
try {
|
|
402
541
|
await getAccount(provider);
|
|
403
542
|
return true;
|
|
404
|
-
} catch {
|
|
543
|
+
} catch (e) {
|
|
544
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
405
545
|
return false;
|
|
406
546
|
}
|
|
407
547
|
}
|
|
@@ -412,6 +552,7 @@ async function getAccount(provider) {
|
|
|
412
552
|
if (!addr) throw new Error("no account");
|
|
413
553
|
return addr;
|
|
414
554
|
} catch (e) {
|
|
555
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
415
556
|
throw new WalletConnectionError("Could not read the player's wallet account.", {
|
|
416
557
|
cause: e?.message
|
|
417
558
|
});
|
|
@@ -485,6 +626,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
|
|
|
485
626
|
try {
|
|
486
627
|
result = await provider.request({ method: "wallet_sendCalls", params: [params] });
|
|
487
628
|
} catch (e) {
|
|
629
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
488
630
|
const msg = e?.message ?? String(e);
|
|
489
631
|
if (/reject|denied|cancel|closed/i.test(msg)) {
|
|
490
632
|
throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
|
|
@@ -499,14 +641,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
|
|
|
499
641
|
if (!id) return { status: "FAILED" };
|
|
500
642
|
const deadline = Date.now() + timeoutMs;
|
|
501
643
|
while (Date.now() < deadline) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
644
|
+
try {
|
|
645
|
+
const res = await provider.request({
|
|
646
|
+
method: "wallet_getCallsStatus",
|
|
647
|
+
params: [id]
|
|
648
|
+
});
|
|
649
|
+
const s = String(res?.status ?? "").toUpperCase();
|
|
650
|
+
const txHash = res?.receipts?.[0]?.transactionHash;
|
|
651
|
+
if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
|
|
652
|
+
if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
|
|
653
|
+
} catch (e) {
|
|
654
|
+
if (e instanceof WalletTimeoutError) {
|
|
655
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
throw e;
|
|
659
|
+
}
|
|
510
660
|
await new Promise((r) => setTimeout(r, 900));
|
|
511
661
|
}
|
|
512
662
|
return { status: "PENDING" };
|
|
@@ -782,6 +932,49 @@ function validateX402ChallengeInput(input) {
|
|
|
782
932
|
|
|
783
933
|
// src/client.ts
|
|
784
934
|
var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
935
|
+
function mapAlreadyEntered(e) {
|
|
936
|
+
const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
|
|
937
|
+
const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
|
|
938
|
+
const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
|
|
939
|
+
if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
|
|
940
|
+
throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
|
|
941
|
+
}
|
|
942
|
+
throw e;
|
|
943
|
+
}
|
|
944
|
+
var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
|
|
945
|
+
var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
|
|
946
|
+
function isSoftRoundPollError(e, kind) {
|
|
947
|
+
if (e instanceof AuthError || e instanceof ConfigError) return false;
|
|
948
|
+
if (e instanceof ApiError) {
|
|
949
|
+
const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
|
|
950
|
+
const msg = e.message;
|
|
951
|
+
if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
|
|
952
|
+
return true;
|
|
953
|
+
}
|
|
954
|
+
if (kind === "settle") {
|
|
955
|
+
if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
} else {
|
|
959
|
+
if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
|
|
960
|
+
msg
|
|
961
|
+
)) {
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
if (status !== void 0 && status >= 400 && status < 500) return false;
|
|
969
|
+
if (status !== void 0 && status >= 500) return true;
|
|
970
|
+
}
|
|
971
|
+
return true;
|
|
972
|
+
}
|
|
973
|
+
function isSoftSettlePollError(e) {
|
|
974
|
+
return isSoftRoundPollError(e, "settle");
|
|
975
|
+
}
|
|
976
|
+
var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
|
|
977
|
+
var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
|
|
785
978
|
function requireAddressField(value, field) {
|
|
786
979
|
if (typeof value !== "string" || value.trim() === "") {
|
|
787
980
|
throw new MissingFieldError(field);
|
|
@@ -981,7 +1174,7 @@ var Playmos = class {
|
|
|
981
1174
|
this.mockWithdrawable = /* @__PURE__ */ new Map();
|
|
982
1175
|
this.rounds = {
|
|
983
1176
|
open: async (input) => {
|
|
984
|
-
this.assertSecretKey("playmos.rounds.open");
|
|
1177
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
|
|
985
1178
|
requireField(input?.gameId, "gameId");
|
|
986
1179
|
requireField(input?.roundId, "roundId");
|
|
987
1180
|
requireField(input?.entryAmount, "entryAmount");
|
|
@@ -1035,7 +1228,7 @@ var Playmos = class {
|
|
|
1035
1228
|
return body.round;
|
|
1036
1229
|
},
|
|
1037
1230
|
lock: async (input) => {
|
|
1038
|
-
this.assertSecretKey("playmos.rounds.lock");
|
|
1231
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.lock");
|
|
1039
1232
|
requireField(input?.roundId, "roundId");
|
|
1040
1233
|
if (this.config.mock) {
|
|
1041
1234
|
const existing = this.mockRounds.get(input.roundId);
|
|
@@ -1067,7 +1260,7 @@ var Playmos = class {
|
|
|
1067
1260
|
return round;
|
|
1068
1261
|
},
|
|
1069
1262
|
settle: async (input) => {
|
|
1070
|
-
this.assertSecretKey("playmos.rounds.settle");
|
|
1263
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.settle");
|
|
1071
1264
|
requireField(input?.roundId, "roundId");
|
|
1072
1265
|
if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
|
|
1073
1266
|
if (this.config.mock) {
|
|
@@ -1128,20 +1321,64 @@ var Playmos = class {
|
|
|
1128
1321
|
status: "settled"
|
|
1129
1322
|
};
|
|
1130
1323
|
}
|
|
1131
|
-
const
|
|
1324
|
+
const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
|
|
1325
|
+
const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
|
|
1326
|
+
const body = { gameId: input.gameId, results: input.results };
|
|
1327
|
+
const idempotencyKey = `settle:${input.roundId}`;
|
|
1328
|
+
const postSettle = () => this.http.post(
|
|
1132
1329
|
`/rounds/${encodeURIComponent(input.roundId)}/settle`,
|
|
1133
|
-
|
|
1134
|
-
{ acceptStatuses: [202] }
|
|
1330
|
+
body,
|
|
1331
|
+
{ acceptStatuses: [200, 202], idempotencyKey }
|
|
1135
1332
|
);
|
|
1136
|
-
|
|
1333
|
+
let { settle } = await postSettle();
|
|
1334
|
+
if (settle.status === "settled") return settle;
|
|
1335
|
+
const firstWinners = settle.winners;
|
|
1336
|
+
const firstPoolPaid = settle.poolPaid ?? null;
|
|
1337
|
+
const withFirstExtras = (s) => ({
|
|
1338
|
+
...s,
|
|
1339
|
+
winners: s.winners ?? firstWinners,
|
|
1340
|
+
poolPaid: s.poolPaid ?? firstPoolPaid
|
|
1341
|
+
});
|
|
1342
|
+
const deadline = Date.now() + timeoutMs;
|
|
1343
|
+
while (Date.now() < deadline && settle.status === "settling") {
|
|
1344
|
+
const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
|
|
1345
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
1346
|
+
if (Date.now() >= deadline) break;
|
|
1347
|
+
try {
|
|
1348
|
+
const again = await postSettle();
|
|
1349
|
+
settle = withFirstExtras(again.settle);
|
|
1350
|
+
if (settle.status === "settled") return settle;
|
|
1351
|
+
} catch (e) {
|
|
1352
|
+
if (!isSoftSettlePollError(e)) throw e;
|
|
1353
|
+
}
|
|
1354
|
+
try {
|
|
1355
|
+
const round = await this.rounds.get({ roundId: input.roundId });
|
|
1356
|
+
if (round.status === "settled") {
|
|
1357
|
+
return {
|
|
1358
|
+
roundId: input.roundId,
|
|
1359
|
+
txHash: round.settleTxHash ?? settle.txHash ?? null,
|
|
1360
|
+
poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
|
|
1361
|
+
winners: settle.winners ?? firstWinners,
|
|
1362
|
+
status: "settled"
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
} catch (e) {
|
|
1366
|
+
if (!isSoftSettlePollError(e)) throw e;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return withFirstExtras(settle);
|
|
1137
1370
|
},
|
|
1138
1371
|
/**
|
|
1139
1372
|
* Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
|
|
1140
|
-
* and frees the series latch
|
|
1141
|
-
*
|
|
1373
|
+
* and frees the series latch (#346 / #376).
|
|
1374
|
+
*
|
|
1375
|
+
* **Default is submit-only:** may return `status: "cancelling"` after broadcast —
|
|
1376
|
+
* that is **not** success. Only `status: "cancelled"` means the chain is terminal.
|
|
1377
|
+
* Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
|
|
1378
|
+
* if still pending — never invents terminal success).
|
|
1142
1379
|
*/
|
|
1143
1380
|
cancel: async (input) => {
|
|
1144
|
-
this.assertSecretKey("playmos.rounds.cancel");
|
|
1381
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
|
|
1145
1382
|
requireField(input?.roundId, "roundId");
|
|
1146
1383
|
if (this.config.mock) {
|
|
1147
1384
|
const existing = this.mockRounds.get(input.roundId);
|
|
@@ -1176,18 +1413,57 @@ var Playmos = class {
|
|
|
1176
1413
|
round: cancelled
|
|
1177
1414
|
};
|
|
1178
1415
|
}
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
)
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1416
|
+
const bodyPayload = { gameId: input.gameId };
|
|
1417
|
+
const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
|
|
1418
|
+
acceptStatuses: [200, 202]
|
|
1419
|
+
});
|
|
1420
|
+
const toResult = (body, priorTx) => {
|
|
1421
|
+
const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
|
|
1422
|
+
const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
|
|
1423
|
+
const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
|
|
1424
|
+
const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
|
|
1425
|
+
return {
|
|
1426
|
+
roundId: input.roundId,
|
|
1427
|
+
txHash: tx,
|
|
1428
|
+
status,
|
|
1429
|
+
round: body.round
|
|
1430
|
+
};
|
|
1190
1431
|
};
|
|
1432
|
+
let result = toResult(await postCancel());
|
|
1433
|
+
if (result.status === "cancelled") return result;
|
|
1434
|
+
if (!input.confirm) return result;
|
|
1435
|
+
const timeoutMs = Math.min(
|
|
1436
|
+
Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
|
|
1437
|
+
CANCEL_RECONCILE_TIMEOUT_MS
|
|
1438
|
+
);
|
|
1439
|
+
const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
|
|
1440
|
+
const deadline = Date.now() + timeoutMs;
|
|
1441
|
+
const firstTx = result.txHash;
|
|
1442
|
+
while (Date.now() < deadline && result.status === "cancelling") {
|
|
1443
|
+
const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
|
|
1444
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
1445
|
+
if (Date.now() >= deadline) break;
|
|
1446
|
+
try {
|
|
1447
|
+
result = toResult(await postCancel(), firstTx);
|
|
1448
|
+
if (result.status === "cancelled") return result;
|
|
1449
|
+
} catch (e) {
|
|
1450
|
+
if (!isSoftRoundPollError(e, "cancel")) throw e;
|
|
1451
|
+
}
|
|
1452
|
+
try {
|
|
1453
|
+
const round = await this.rounds.get({ roundId: input.roundId });
|
|
1454
|
+
if (round.status === "cancelled") {
|
|
1455
|
+
return {
|
|
1456
|
+
roundId: input.roundId,
|
|
1457
|
+
txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
|
|
1458
|
+
status: "cancelled",
|
|
1459
|
+
round
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
} catch (e) {
|
|
1463
|
+
if (!isSoftRoundPollError(e, "cancel")) throw e;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
return { ...result, txHash: result.txHash ?? firstTx ?? null };
|
|
1191
1467
|
},
|
|
1192
1468
|
get: async (input) => {
|
|
1193
1469
|
requireField(input?.roundId, "roundId");
|
|
@@ -1338,7 +1614,7 @@ var Playmos = class {
|
|
|
1338
1614
|
}
|
|
1339
1615
|
prizePool = round.prizePoolAddress;
|
|
1340
1616
|
}
|
|
1341
|
-
const provider =
|
|
1617
|
+
const provider = this.walletProvider();
|
|
1342
1618
|
if (this.config.gas?.mode === "player") {
|
|
1343
1619
|
const from0 = await getAccount(provider);
|
|
1344
1620
|
await assertEnoughGas(provider, from0);
|
|
@@ -1355,18 +1631,31 @@ var Playmos = class {
|
|
|
1355
1631
|
const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
|
|
1356
1632
|
let txHash;
|
|
1357
1633
|
let status = "pending";
|
|
1634
|
+
let callsId;
|
|
1358
1635
|
try {
|
|
1359
|
-
const
|
|
1636
|
+
const sent = await sendCalls(
|
|
1360
1637
|
provider,
|
|
1361
1638
|
from,
|
|
1362
1639
|
this.env.chainId,
|
|
1363
1640
|
[call],
|
|
1364
1641
|
paymasterUrl
|
|
1365
1642
|
);
|
|
1643
|
+
callsId = sent.id;
|
|
1366
1644
|
const waited = await waitForCalls(provider, callsId);
|
|
1367
1645
|
txHash = waited.txHash;
|
|
1368
1646
|
status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
|
|
1369
1647
|
} catch (e) {
|
|
1648
|
+
if (e instanceof WalletTimeoutError && callsId) {
|
|
1649
|
+
return {
|
|
1650
|
+
prizePoolAddress: prizePool,
|
|
1651
|
+
amount: formatMicroToUsd(amountMicro),
|
|
1652
|
+
amountMicro: amountMicro.toString(),
|
|
1653
|
+
txHash,
|
|
1654
|
+
status: "pending",
|
|
1655
|
+
callsId
|
|
1656
|
+
};
|
|
1657
|
+
}
|
|
1658
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
1370
1659
|
const msg = e?.message ?? String(e);
|
|
1371
1660
|
if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
|
|
1372
1661
|
throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
|
|
@@ -1386,7 +1675,8 @@ var Playmos = class {
|
|
|
1386
1675
|
amount: formatMicroToUsd(amountMicro),
|
|
1387
1676
|
amountMicro: amountMicro.toString(),
|
|
1388
1677
|
txHash,
|
|
1389
|
-
status
|
|
1678
|
+
status,
|
|
1679
|
+
...status === "pending" && callsId ? { callsId } : {}
|
|
1390
1680
|
};
|
|
1391
1681
|
}
|
|
1392
1682
|
};
|
|
@@ -1633,13 +1923,36 @@ var Playmos = class {
|
|
|
1633
1923
|
}
|
|
1634
1924
|
}
|
|
1635
1925
|
this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
|
|
1926
|
+
if (!config.mock) {
|
|
1927
|
+
try {
|
|
1928
|
+
resolveProvider(config.wallet, this.walletTimeoutPolicy());
|
|
1929
|
+
} catch {
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Wallet timeout policy (#462 / PR #463 residual).
|
|
1935
|
+
* - connectTimeoutMs → eth_requestAccounts only
|
|
1936
|
+
* - machine RPCs → 20s default
|
|
1937
|
+
* - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
|
|
1938
|
+
*/
|
|
1939
|
+
walletTimeoutPolicy() {
|
|
1940
|
+
const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
|
|
1941
|
+
return {
|
|
1942
|
+
connectTimeoutMs: connect,
|
|
1943
|
+
requestTimeoutMs: 2e4,
|
|
1944
|
+
sendCallsTimeoutMs: 0
|
|
1945
|
+
};
|
|
1946
|
+
}
|
|
1947
|
+
walletProvider() {
|
|
1948
|
+
return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
|
|
1636
1949
|
}
|
|
1637
1950
|
/** Connect the player's wallet and return their address. */
|
|
1638
1951
|
async connect() {
|
|
1639
1952
|
if (this.config.mock) {
|
|
1640
1953
|
return "0x0000000000000000000000000000000000000001";
|
|
1641
1954
|
}
|
|
1642
|
-
return getAccount(
|
|
1955
|
+
return getAccount(this.walletProvider());
|
|
1643
1956
|
}
|
|
1644
1957
|
/**
|
|
1645
1958
|
* A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
|
|
@@ -1699,7 +2012,7 @@ var Playmos = class {
|
|
|
1699
2012
|
})
|
|
1700
2013
|
);
|
|
1701
2014
|
}
|
|
1702
|
-
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
2015
|
+
if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
|
|
1703
2016
|
const res = await this.http.post(
|
|
1704
2017
|
"/payments",
|
|
1705
2018
|
{
|
|
@@ -1737,7 +2050,7 @@ var Playmos = class {
|
|
|
1737
2050
|
},
|
|
1738
2051
|
{ idempotencyKey }
|
|
1739
2052
|
);
|
|
1740
|
-
const provider =
|
|
2053
|
+
const provider = this.walletProvider();
|
|
1741
2054
|
if (this.config.gas?.mode === "player") {
|
|
1742
2055
|
const from0 = await getAccount(provider);
|
|
1743
2056
|
await assertEnoughGas(provider, from0);
|
|
@@ -1782,7 +2095,17 @@ var Playmos = class {
|
|
|
1782
2095
|
})
|
|
1783
2096
|
);
|
|
1784
2097
|
}
|
|
1785
|
-
|
|
2098
|
+
const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
|
|
2099
|
+
if (!serverSettleNoWallet) {
|
|
2100
|
+
const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
|
|
2101
|
+
if (!pinned) {
|
|
2102
|
+
throw new ConfigError(
|
|
2103
|
+
'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).',
|
|
2104
|
+
{ field: "identity" }
|
|
2105
|
+
);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (serverSettleNoWallet) {
|
|
1786
2109
|
const pinnedRoundKey = input.roundKey ?? input.roundId;
|
|
1787
2110
|
const res = await this.http.post(
|
|
1788
2111
|
"/payments",
|
|
@@ -1827,7 +2150,7 @@ var Playmos = class {
|
|
|
1827
2150
|
},
|
|
1828
2151
|
{ idempotencyKey }
|
|
1829
2152
|
);
|
|
1830
|
-
const provider =
|
|
2153
|
+
const provider = this.walletProvider();
|
|
1831
2154
|
if (this.config.gas?.mode === "player") {
|
|
1832
2155
|
const from0 = await getAccount(provider);
|
|
1833
2156
|
await assertEnoughGas(provider, from0);
|
|
@@ -1838,12 +2161,28 @@ var Playmos = class {
|
|
|
1838
2161
|
if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
|
|
1839
2162
|
const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
|
|
1840
2163
|
if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
|
|
1841
|
-
const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity
|
|
2164
|
+
const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
|
|
2165
|
+
if (!identity || !String(identity).trim()) {
|
|
2166
|
+
throw new ConfigError(
|
|
2167
|
+
"enterRound: missing on-chain identity after create-intent (pass input.identity).",
|
|
2168
|
+
{ field: "identity" }
|
|
2169
|
+
);
|
|
2170
|
+
}
|
|
1842
2171
|
const amountUnits = this.resolveUnits(intent, amountMicro);
|
|
1843
2172
|
const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
|
|
1844
|
-
|
|
2173
|
+
let callsId;
|
|
2174
|
+
try {
|
|
2175
|
+
({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
|
|
2176
|
+
} catch (e) {
|
|
2177
|
+
throw mapAlreadyEntered(e);
|
|
2178
|
+
}
|
|
1845
2179
|
const { txHash } = await waitForCalls(provider, callsId);
|
|
1846
|
-
|
|
2180
|
+
let payment;
|
|
2181
|
+
try {
|
|
2182
|
+
payment = await this.settle(intent.payment.id, txHash);
|
|
2183
|
+
} catch (e) {
|
|
2184
|
+
throw mapAlreadyEntered(e);
|
|
2185
|
+
}
|
|
1847
2186
|
payment.identity = identity;
|
|
1848
2187
|
payment.prizePoolAddress = prizePool.toLowerCase();
|
|
1849
2188
|
payment.roundKey = roundKey;
|
|
@@ -2040,8 +2379,8 @@ var Playmos = class {
|
|
|
2040
2379
|
});
|
|
2041
2380
|
let raw;
|
|
2042
2381
|
try {
|
|
2043
|
-
if (await walletAvailable(this.config.wallet)) {
|
|
2044
|
-
const provider =
|
|
2382
|
+
if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
|
|
2383
|
+
const provider = this.walletProvider();
|
|
2045
2384
|
raw = await provider.request({
|
|
2046
2385
|
method: "eth_call",
|
|
2047
2386
|
params: [{ to: prizePool, data }, "latest"]
|
|
@@ -2068,7 +2407,7 @@ var Playmos = class {
|
|
|
2068
2407
|
raw = json.result;
|
|
2069
2408
|
}
|
|
2070
2409
|
} catch (e) {
|
|
2071
|
-
if (e instanceof ApiError) throw e;
|
|
2410
|
+
if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
|
|
2072
2411
|
throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
|
|
2073
2412
|
prizePool,
|
|
2074
2413
|
wallet
|
|
@@ -2358,6 +2697,7 @@ function isX402PayloadAuthorization(auth) {
|
|
|
2358
2697
|
return auth.kind === "x402-payload";
|
|
2359
2698
|
}
|
|
2360
2699
|
|
|
2700
|
+
exports.AlreadyEnteredError = AlreadyEnteredError;
|
|
2361
2701
|
exports.ApiError = ApiError;
|
|
2362
2702
|
exports.AuthError = AuthError;
|
|
2363
2703
|
exports.CHAIN_ID = CHAIN_ID;
|
|
@@ -2375,6 +2715,7 @@ exports.PlaymosError = PlaymosError;
|
|
|
2375
2715
|
exports.USDC_ADDRESS = USDC_ADDRESS;
|
|
2376
2716
|
exports.USDC_DECIMALS = USDC_DECIMALS;
|
|
2377
2717
|
exports.WalletConnectionError = WalletConnectionError;
|
|
2718
|
+
exports.WalletTimeoutError = WalletTimeoutError;
|
|
2378
2719
|
exports.clientRuntimeSignals = clientRuntimeSignals;
|
|
2379
2720
|
exports.computeIapSplit = computeIapSplit;
|
|
2380
2721
|
exports.computePayout = computePayout;
|