@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.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError } from './chunk-
|
|
2
|
-
export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-
|
|
1
|
+
import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, WalletTimeoutError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError, AlreadyEnteredError } from './chunk-UZECDT6F.js';
|
|
2
|
+
export { AlreadyEnteredError, ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError, WalletTimeoutError } from './chunk-UZECDT6F.js';
|
|
3
3
|
import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
|
|
4
4
|
|
|
5
5
|
// src/config.ts
|
|
@@ -245,12 +245,66 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
245
245
|
}
|
|
246
246
|
return false;
|
|
247
247
|
}
|
|
248
|
+
async function probeSignerBelowFloor() {
|
|
249
|
+
try {
|
|
250
|
+
const res = await send(`${base}/health`, { method: "GET" });
|
|
251
|
+
if (!res.ok) return { below: false };
|
|
252
|
+
const body = await res.json();
|
|
253
|
+
const bal = body?.capabilities?.payments?.signerBalance;
|
|
254
|
+
if (!bal || bal.ok !== false) return { below: false };
|
|
255
|
+
const reason = bal.reason ?? "";
|
|
256
|
+
const fundedFloorMiss = reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth";
|
|
257
|
+
if (!fundedFloorMiss) return { below: false, reason: reason || void 0 };
|
|
258
|
+
return { below: true, reason };
|
|
259
|
+
} catch {
|
|
260
|
+
return { below: false };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
248
263
|
async function sendWithRetry(url, init) {
|
|
249
264
|
let res = await send(url, init);
|
|
265
|
+
let signerBelowFloor = false;
|
|
266
|
+
let signerBelowReason;
|
|
267
|
+
let probed = false;
|
|
268
|
+
const maybeProbe = async () => {
|
|
269
|
+
if (probed || signerBelowFloor) return;
|
|
270
|
+
if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
|
|
271
|
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
272
|
+
if (ct.includes("application/json")) return;
|
|
273
|
+
probed = true;
|
|
274
|
+
const probe = await probeSignerBelowFloor();
|
|
275
|
+
signerBelowFloor = probe.below;
|
|
276
|
+
signerBelowReason = probe.reason;
|
|
277
|
+
};
|
|
278
|
+
const maybePeekJsonSignerLow = async () => {
|
|
279
|
+
if (signerBelowFloor) return;
|
|
280
|
+
if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
|
|
281
|
+
const ct = (res.headers.get("content-type") || "").toLowerCase();
|
|
282
|
+
if (!ct.includes("application/json")) return;
|
|
283
|
+
try {
|
|
284
|
+
const peek = JSON.parse(await res.clone().text());
|
|
285
|
+
const code = peek?.error?.code;
|
|
286
|
+
const reason = peek?.error?.reason;
|
|
287
|
+
if (code === "signer_low_balance" || reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth") {
|
|
288
|
+
signerBelowFloor = true;
|
|
289
|
+
signerBelowReason = reason ?? code;
|
|
290
|
+
}
|
|
291
|
+
} catch {
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const classifySignerBelow = async () => {
|
|
295
|
+
await maybeProbe();
|
|
296
|
+
await maybePeekJsonSignerLow();
|
|
297
|
+
};
|
|
298
|
+
await classifySignerBelow();
|
|
250
299
|
for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
|
|
300
|
+
if (signerBelowFloor) break;
|
|
251
301
|
await sleep(backoffMs(res, attempt, cfg));
|
|
252
302
|
res = await send(url, init);
|
|
303
|
+
await classifySignerBelow();
|
|
253
304
|
}
|
|
305
|
+
const flagged = res;
|
|
306
|
+
flagged.__playmosSignerBelowFloor = signerBelowFloor;
|
|
307
|
+
if (signerBelowReason) flagged.__playmosSignerBelowReason = signerBelowReason;
|
|
254
308
|
return res;
|
|
255
309
|
}
|
|
256
310
|
async function handle(res, acceptStatuses) {
|
|
@@ -269,8 +323,22 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
269
323
|
json = text ? JSON.parse(text) : {};
|
|
270
324
|
} catch {
|
|
271
325
|
const gateway = res.status === 502 || res.status === 503 || res.status === 504;
|
|
272
|
-
const
|
|
273
|
-
|
|
326
|
+
const below = res.__playmosSignerBelowFloor === true;
|
|
327
|
+
let msg;
|
|
328
|
+
if (gateway && below) {
|
|
329
|
+
const why = res.__playmosSignerBelowReason ?? "low_balance";
|
|
330
|
+
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.`;
|
|
331
|
+
} else if (gateway) {
|
|
332
|
+
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).`;
|
|
333
|
+
} else {
|
|
334
|
+
msg = `Non-JSON response (${res.status}) from ${res.url}`;
|
|
335
|
+
}
|
|
336
|
+
throw new ApiError(msg, {
|
|
337
|
+
status: res.status,
|
|
338
|
+
body: text.slice(0, 500),
|
|
339
|
+
gateway,
|
|
340
|
+
signerBelowFloor: below || void 0
|
|
341
|
+
});
|
|
274
342
|
}
|
|
275
343
|
if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
|
|
276
344
|
return json;
|
|
@@ -303,7 +371,60 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
303
371
|
}
|
|
304
372
|
|
|
305
373
|
// src/wallet.ts
|
|
306
|
-
|
|
374
|
+
var DEFAULT_WALLET_REQUEST_TIMEOUT_MS = 2e4;
|
|
375
|
+
var MONEY_MOVING_METHODS = /* @__PURE__ */ new Set([
|
|
376
|
+
"wallet_sendCalls",
|
|
377
|
+
"eth_sendTransaction",
|
|
378
|
+
"eth_sendRawTransaction"
|
|
379
|
+
]);
|
|
380
|
+
var CONNECT_METHODS = /* @__PURE__ */ new Set(["eth_requestAccounts"]);
|
|
381
|
+
var TIMED_FLAG = "__playmosTimedRequest";
|
|
382
|
+
function resolvePolicy(opts) {
|
|
383
|
+
const requestTimeoutMs = typeof opts?.requestTimeoutMs === "number" && opts.requestTimeoutMs > 0 ? opts.requestTimeoutMs : DEFAULT_WALLET_REQUEST_TIMEOUT_MS;
|
|
384
|
+
const connectTimeoutMs = typeof opts?.connectTimeoutMs === "number" && opts.connectTimeoutMs > 0 ? opts.connectTimeoutMs : requestTimeoutMs;
|
|
385
|
+
const sendCallsTimeoutMs = typeof opts?.sendCallsTimeoutMs === "number" && opts.sendCallsTimeoutMs >= 0 ? opts.sendCallsTimeoutMs : 0;
|
|
386
|
+
return { requestTimeoutMs, connectTimeoutMs, sendCallsTimeoutMs };
|
|
387
|
+
}
|
|
388
|
+
function timeoutMsForMethod(method, opts) {
|
|
389
|
+
const p = resolvePolicy(opts);
|
|
390
|
+
if (MONEY_MOVING_METHODS.has(method)) return p.sendCallsTimeoutMs;
|
|
391
|
+
if (CONNECT_METHODS.has(method)) return p.connectTimeoutMs;
|
|
392
|
+
return p.requestTimeoutMs;
|
|
393
|
+
}
|
|
394
|
+
async function timedRequest(provider, args, timeoutMs = DEFAULT_WALLET_REQUEST_TIMEOUT_MS) {
|
|
395
|
+
if (timeoutMs <= 0) {
|
|
396
|
+
return provider.request(args);
|
|
397
|
+
}
|
|
398
|
+
let timer;
|
|
399
|
+
try {
|
|
400
|
+
return await Promise.race([
|
|
401
|
+
provider.request(args),
|
|
402
|
+
new Promise((_, reject) => {
|
|
403
|
+
timer = setTimeout(() => {
|
|
404
|
+
reject(
|
|
405
|
+
new WalletTimeoutError(
|
|
406
|
+
`Wallet request "${args.method}" timed out after ${timeoutMs}ms.`,
|
|
407
|
+
{ method: args.method, timeoutMs }
|
|
408
|
+
)
|
|
409
|
+
);
|
|
410
|
+
}, timeoutMs);
|
|
411
|
+
})
|
|
412
|
+
]);
|
|
413
|
+
} finally {
|
|
414
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function withTimeoutProvider(provider, opts) {
|
|
418
|
+
const flagged = provider;
|
|
419
|
+
if (flagged[TIMED_FLAG]) return provider;
|
|
420
|
+
const policy = typeof opts === "number" ? { requestTimeoutMs: opts, connectTimeoutMs: opts } : opts ?? {};
|
|
421
|
+
const wrapped = {
|
|
422
|
+
request: (args) => timedRequest(provider, args, timeoutMsForMethod(args.method, policy))
|
|
423
|
+
};
|
|
424
|
+
Object.defineProperty(wrapped, TIMED_FLAG, { value: true, enumerable: false });
|
|
425
|
+
return wrapped;
|
|
426
|
+
}
|
|
427
|
+
function resolveRawProvider(wallet) {
|
|
307
428
|
if (wallet?.provider) return wallet.provider;
|
|
308
429
|
const injected = globalThis.ethereum;
|
|
309
430
|
const connector = wallet?.connector ?? "base-account";
|
|
@@ -320,18 +441,23 @@ function resolveProvider(wallet) {
|
|
|
320
441
|
"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."
|
|
321
442
|
);
|
|
322
443
|
}
|
|
323
|
-
|
|
444
|
+
function resolveProvider(wallet, opts) {
|
|
445
|
+
const raw = resolveRawProvider(wallet);
|
|
446
|
+
return withTimeoutProvider(raw, opts);
|
|
447
|
+
}
|
|
448
|
+
async function walletAvailable(wallet, opts) {
|
|
324
449
|
if (wallet?.provider) return true;
|
|
325
450
|
let provider;
|
|
326
451
|
try {
|
|
327
|
-
provider = resolveProvider(wallet);
|
|
452
|
+
provider = resolveProvider(wallet, opts);
|
|
328
453
|
} catch {
|
|
329
454
|
return false;
|
|
330
455
|
}
|
|
331
456
|
try {
|
|
332
457
|
await getAccount(provider);
|
|
333
458
|
return true;
|
|
334
|
-
} catch {
|
|
459
|
+
} catch (e) {
|
|
460
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
335
461
|
return false;
|
|
336
462
|
}
|
|
337
463
|
}
|
|
@@ -342,6 +468,7 @@ async function getAccount(provider) {
|
|
|
342
468
|
if (!addr) throw new Error("no account");
|
|
343
469
|
return addr;
|
|
344
470
|
} catch (e) {
|
|
471
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
345
472
|
throw new WalletConnectionError("Could not read the player's wallet account.", {
|
|
346
473
|
cause: e?.message
|
|
347
474
|
});
|
|
@@ -415,6 +542,7 @@ async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
|
|
|
415
542
|
try {
|
|
416
543
|
result = await provider.request({ method: "wallet_sendCalls", params: [params] });
|
|
417
544
|
} catch (e) {
|
|
545
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
418
546
|
const msg = e?.message ?? String(e);
|
|
419
547
|
if (/reject|denied|cancel|closed/i.test(msg)) {
|
|
420
548
|
throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
|
|
@@ -429,14 +557,22 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
|
|
|
429
557
|
if (!id) return { status: "FAILED" };
|
|
430
558
|
const deadline = Date.now() + timeoutMs;
|
|
431
559
|
while (Date.now() < deadline) {
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
560
|
+
try {
|
|
561
|
+
const res = await provider.request({
|
|
562
|
+
method: "wallet_getCallsStatus",
|
|
563
|
+
params: [id]
|
|
564
|
+
});
|
|
565
|
+
const s = String(res?.status ?? "").toUpperCase();
|
|
566
|
+
const txHash = res?.receipts?.[0]?.transactionHash;
|
|
567
|
+
if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
|
|
568
|
+
if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
|
|
569
|
+
} catch (e) {
|
|
570
|
+
if (e instanceof WalletTimeoutError) {
|
|
571
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
throw e;
|
|
575
|
+
}
|
|
440
576
|
await new Promise((r) => setTimeout(r, 900));
|
|
441
577
|
}
|
|
442
578
|
return { status: "PENDING" };
|
|
@@ -712,6 +848,49 @@ function validateX402ChallengeInput(input) {
|
|
|
712
848
|
|
|
713
849
|
// src/client.ts
|
|
714
850
|
var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
851
|
+
function mapAlreadyEntered(e) {
|
|
852
|
+
const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
|
|
853
|
+
const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
|
|
854
|
+
const blob = `${msg} ${JSON.stringify(detail ?? {})}`;
|
|
855
|
+
if (/AlreadyEntered|already entered|already_entered/i.test(blob)) {
|
|
856
|
+
throw new AlreadyEnteredError({ cause: msg, ...detail ?? {} });
|
|
857
|
+
}
|
|
858
|
+
throw e;
|
|
859
|
+
}
|
|
860
|
+
var SETTLE_RECONCILE_TIMEOUT_MS = 2e4;
|
|
861
|
+
var SETTLE_RECONCILE_INTERVAL_MS = 1e3;
|
|
862
|
+
function isSoftRoundPollError(e, kind) {
|
|
863
|
+
if (e instanceof AuthError || e instanceof ConfigError) return false;
|
|
864
|
+
if (e instanceof ApiError) {
|
|
865
|
+
const status = typeof e.detail?.status === "number" ? e.detail.status : void 0;
|
|
866
|
+
const msg = e.message;
|
|
867
|
+
if (status === 409 && /already in progress|in progress — retry/i.test(msg)) {
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
if (kind === "settle") {
|
|
871
|
+
if (/cannot settle|not Locked on-chain|is already settled|nothing to settle/i.test(msg)) {
|
|
872
|
+
return false;
|
|
873
|
+
}
|
|
874
|
+
} else {
|
|
875
|
+
if (/cannot cancel|is already settled|is Settled on-chain|already settled — cannot cancel/i.test(
|
|
876
|
+
msg
|
|
877
|
+
)) {
|
|
878
|
+
return false;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (status === 502 || status === 503 || status === 504 || status === 429 || e.detail?.gateway === true) {
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
if (status !== void 0 && status >= 400 && status < 500) return false;
|
|
885
|
+
if (status !== void 0 && status >= 500) return true;
|
|
886
|
+
}
|
|
887
|
+
return true;
|
|
888
|
+
}
|
|
889
|
+
function isSoftSettlePollError(e) {
|
|
890
|
+
return isSoftRoundPollError(e, "settle");
|
|
891
|
+
}
|
|
892
|
+
var CANCEL_RECONCILE_TIMEOUT_MS = SETTLE_RECONCILE_TIMEOUT_MS;
|
|
893
|
+
var CANCEL_RECONCILE_INTERVAL_MS = SETTLE_RECONCILE_INTERVAL_MS;
|
|
715
894
|
function requireAddressField(value, field) {
|
|
716
895
|
if (typeof value !== "string" || value.trim() === "") {
|
|
717
896
|
throw new MissingFieldError(field);
|
|
@@ -911,7 +1090,7 @@ var Playmos = class {
|
|
|
911
1090
|
this.mockWithdrawable = /* @__PURE__ */ new Map();
|
|
912
1091
|
this.rounds = {
|
|
913
1092
|
open: async (input) => {
|
|
914
|
-
this.assertSecretKey("playmos.rounds.open");
|
|
1093
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
|
|
915
1094
|
requireField(input?.gameId, "gameId");
|
|
916
1095
|
requireField(input?.roundId, "roundId");
|
|
917
1096
|
requireField(input?.entryAmount, "entryAmount");
|
|
@@ -965,7 +1144,7 @@ var Playmos = class {
|
|
|
965
1144
|
return body.round;
|
|
966
1145
|
},
|
|
967
1146
|
lock: async (input) => {
|
|
968
|
-
this.assertSecretKey("playmos.rounds.lock");
|
|
1147
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.lock");
|
|
969
1148
|
requireField(input?.roundId, "roundId");
|
|
970
1149
|
if (this.config.mock) {
|
|
971
1150
|
const existing = this.mockRounds.get(input.roundId);
|
|
@@ -997,7 +1176,7 @@ var Playmos = class {
|
|
|
997
1176
|
return round;
|
|
998
1177
|
},
|
|
999
1178
|
settle: async (input) => {
|
|
1000
|
-
this.assertSecretKey("playmos.rounds.settle");
|
|
1179
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.settle");
|
|
1001
1180
|
requireField(input?.roundId, "roundId");
|
|
1002
1181
|
if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
|
|
1003
1182
|
if (this.config.mock) {
|
|
@@ -1058,20 +1237,64 @@ var Playmos = class {
|
|
|
1058
1237
|
status: "settled"
|
|
1059
1238
|
};
|
|
1060
1239
|
}
|
|
1061
|
-
const
|
|
1240
|
+
const timeoutMs = input.timeoutMs ?? SETTLE_RECONCILE_TIMEOUT_MS;
|
|
1241
|
+
const intervalMs = input.intervalMs ?? SETTLE_RECONCILE_INTERVAL_MS;
|
|
1242
|
+
const body = { gameId: input.gameId, results: input.results };
|
|
1243
|
+
const idempotencyKey = `settle:${input.roundId}`;
|
|
1244
|
+
const postSettle = () => this.http.post(
|
|
1062
1245
|
`/rounds/${encodeURIComponent(input.roundId)}/settle`,
|
|
1063
|
-
|
|
1064
|
-
{ acceptStatuses: [202] }
|
|
1246
|
+
body,
|
|
1247
|
+
{ acceptStatuses: [200, 202], idempotencyKey }
|
|
1065
1248
|
);
|
|
1066
|
-
|
|
1249
|
+
let { settle } = await postSettle();
|
|
1250
|
+
if (settle.status === "settled") return settle;
|
|
1251
|
+
const firstWinners = settle.winners;
|
|
1252
|
+
const firstPoolPaid = settle.poolPaid ?? null;
|
|
1253
|
+
const withFirstExtras = (s) => ({
|
|
1254
|
+
...s,
|
|
1255
|
+
winners: s.winners ?? firstWinners,
|
|
1256
|
+
poolPaid: s.poolPaid ?? firstPoolPaid
|
|
1257
|
+
});
|
|
1258
|
+
const deadline = Date.now() + timeoutMs;
|
|
1259
|
+
while (Date.now() < deadline && settle.status === "settling") {
|
|
1260
|
+
const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
|
|
1261
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
1262
|
+
if (Date.now() >= deadline) break;
|
|
1263
|
+
try {
|
|
1264
|
+
const again = await postSettle();
|
|
1265
|
+
settle = withFirstExtras(again.settle);
|
|
1266
|
+
if (settle.status === "settled") return settle;
|
|
1267
|
+
} catch (e) {
|
|
1268
|
+
if (!isSoftSettlePollError(e)) throw e;
|
|
1269
|
+
}
|
|
1270
|
+
try {
|
|
1271
|
+
const round = await this.rounds.get({ roundId: input.roundId });
|
|
1272
|
+
if (round.status === "settled") {
|
|
1273
|
+
return {
|
|
1274
|
+
roundId: input.roundId,
|
|
1275
|
+
txHash: round.settleTxHash ?? settle.txHash ?? null,
|
|
1276
|
+
poolPaid: round.pool ?? settle.poolPaid ?? firstPoolPaid,
|
|
1277
|
+
winners: settle.winners ?? firstWinners,
|
|
1278
|
+
status: "settled"
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
if (!isSoftSettlePollError(e)) throw e;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return withFirstExtras(settle);
|
|
1067
1286
|
},
|
|
1068
1287
|
/**
|
|
1069
1288
|
* Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
|
|
1070
|
-
* and frees the series latch
|
|
1071
|
-
*
|
|
1289
|
+
* and frees the series latch (#346 / #376).
|
|
1290
|
+
*
|
|
1291
|
+
* **Default is submit-only:** may return `status: "cancelling"` after broadcast —
|
|
1292
|
+
* that is **not** success. Only `status: "cancelled"` means the chain is terminal.
|
|
1293
|
+
* Pass `{ confirm: true }` to poll until cancelled or timeout (honest `cancelling`
|
|
1294
|
+
* if still pending — never invents terminal success).
|
|
1072
1295
|
*/
|
|
1073
1296
|
cancel: async (input) => {
|
|
1074
|
-
this.assertSecretKey("playmos.rounds.cancel");
|
|
1297
|
+
if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
|
|
1075
1298
|
requireField(input?.roundId, "roundId");
|
|
1076
1299
|
if (this.config.mock) {
|
|
1077
1300
|
const existing = this.mockRounds.get(input.roundId);
|
|
@@ -1106,18 +1329,57 @@ var Playmos = class {
|
|
|
1106
1329
|
round: cancelled
|
|
1107
1330
|
};
|
|
1108
1331
|
}
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
)
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1332
|
+
const bodyPayload = { gameId: input.gameId };
|
|
1333
|
+
const postCancel = () => this.http.post(`/rounds/${encodeURIComponent(input.roundId)}/cancel`, bodyPayload, {
|
|
1334
|
+
acceptStatuses: [200, 202]
|
|
1335
|
+
});
|
|
1336
|
+
const toResult = (body, priorTx) => {
|
|
1337
|
+
const explicitCancelled = body.status === "cancelled" || body.round?.status === "cancelled";
|
|
1338
|
+
const explicitCancelling = body.status === "cancelling" || body.round?.status === "cancelling";
|
|
1339
|
+
const status = explicitCancelled && !explicitCancelling ? "cancelled" : "cancelling";
|
|
1340
|
+
const tx = body.txHash ?? body.round?.cancelTxHash ?? priorTx ?? null;
|
|
1341
|
+
return {
|
|
1342
|
+
roundId: input.roundId,
|
|
1343
|
+
txHash: tx,
|
|
1344
|
+
status,
|
|
1345
|
+
round: body.round
|
|
1346
|
+
};
|
|
1120
1347
|
};
|
|
1348
|
+
let result = toResult(await postCancel());
|
|
1349
|
+
if (result.status === "cancelled") return result;
|
|
1350
|
+
if (!input.confirm) return result;
|
|
1351
|
+
const timeoutMs = Math.min(
|
|
1352
|
+
Math.max(1, input.timeoutMs ?? CANCEL_RECONCILE_TIMEOUT_MS),
|
|
1353
|
+
CANCEL_RECONCILE_TIMEOUT_MS
|
|
1354
|
+
);
|
|
1355
|
+
const intervalMs = Math.max(1, input.intervalMs ?? CANCEL_RECONCILE_INTERVAL_MS);
|
|
1356
|
+
const deadline = Date.now() + timeoutMs;
|
|
1357
|
+
const firstTx = result.txHash;
|
|
1358
|
+
while (Date.now() < deadline && result.status === "cancelling") {
|
|
1359
|
+
const wait = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
|
|
1360
|
+
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
|
1361
|
+
if (Date.now() >= deadline) break;
|
|
1362
|
+
try {
|
|
1363
|
+
result = toResult(await postCancel(), firstTx);
|
|
1364
|
+
if (result.status === "cancelled") return result;
|
|
1365
|
+
} catch (e) {
|
|
1366
|
+
if (!isSoftRoundPollError(e, "cancel")) throw e;
|
|
1367
|
+
}
|
|
1368
|
+
try {
|
|
1369
|
+
const round = await this.rounds.get({ roundId: input.roundId });
|
|
1370
|
+
if (round.status === "cancelled") {
|
|
1371
|
+
return {
|
|
1372
|
+
roundId: input.roundId,
|
|
1373
|
+
txHash: round.cancelTxHash ?? result.txHash ?? firstTx ?? null,
|
|
1374
|
+
status: "cancelled",
|
|
1375
|
+
round
|
|
1376
|
+
};
|
|
1377
|
+
}
|
|
1378
|
+
} catch (e) {
|
|
1379
|
+
if (!isSoftRoundPollError(e, "cancel")) throw e;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return { ...result, txHash: result.txHash ?? firstTx ?? null };
|
|
1121
1383
|
},
|
|
1122
1384
|
get: async (input) => {
|
|
1123
1385
|
requireField(input?.roundId, "roundId");
|
|
@@ -1268,7 +1530,7 @@ var Playmos = class {
|
|
|
1268
1530
|
}
|
|
1269
1531
|
prizePool = round.prizePoolAddress;
|
|
1270
1532
|
}
|
|
1271
|
-
const provider =
|
|
1533
|
+
const provider = this.walletProvider();
|
|
1272
1534
|
if (this.config.gas?.mode === "player") {
|
|
1273
1535
|
const from0 = await getAccount(provider);
|
|
1274
1536
|
await assertEnoughGas(provider, from0);
|
|
@@ -1285,18 +1547,31 @@ var Playmos = class {
|
|
|
1285
1547
|
const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
|
|
1286
1548
|
let txHash;
|
|
1287
1549
|
let status = "pending";
|
|
1550
|
+
let callsId;
|
|
1288
1551
|
try {
|
|
1289
|
-
const
|
|
1552
|
+
const sent = await sendCalls(
|
|
1290
1553
|
provider,
|
|
1291
1554
|
from,
|
|
1292
1555
|
this.env.chainId,
|
|
1293
1556
|
[call],
|
|
1294
1557
|
paymasterUrl
|
|
1295
1558
|
);
|
|
1559
|
+
callsId = sent.id;
|
|
1296
1560
|
const waited = await waitForCalls(provider, callsId);
|
|
1297
1561
|
txHash = waited.txHash;
|
|
1298
1562
|
status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
|
|
1299
1563
|
} catch (e) {
|
|
1564
|
+
if (e instanceof WalletTimeoutError && callsId) {
|
|
1565
|
+
return {
|
|
1566
|
+
prizePoolAddress: prizePool,
|
|
1567
|
+
amount: formatMicroToUsd(amountMicro),
|
|
1568
|
+
amountMicro: amountMicro.toString(),
|
|
1569
|
+
txHash,
|
|
1570
|
+
status: "pending",
|
|
1571
|
+
callsId
|
|
1572
|
+
};
|
|
1573
|
+
}
|
|
1574
|
+
if (e instanceof WalletTimeoutError) throw e;
|
|
1300
1575
|
const msg = e?.message ?? String(e);
|
|
1301
1576
|
if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
|
|
1302
1577
|
throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
|
|
@@ -1316,7 +1591,8 @@ var Playmos = class {
|
|
|
1316
1591
|
amount: formatMicroToUsd(amountMicro),
|
|
1317
1592
|
amountMicro: amountMicro.toString(),
|
|
1318
1593
|
txHash,
|
|
1319
|
-
status
|
|
1594
|
+
status,
|
|
1595
|
+
...status === "pending" && callsId ? { callsId } : {}
|
|
1320
1596
|
};
|
|
1321
1597
|
}
|
|
1322
1598
|
};
|
|
@@ -1563,13 +1839,36 @@ var Playmos = class {
|
|
|
1563
1839
|
}
|
|
1564
1840
|
}
|
|
1565
1841
|
this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
|
|
1842
|
+
if (!config.mock) {
|
|
1843
|
+
try {
|
|
1844
|
+
resolveProvider(config.wallet, this.walletTimeoutPolicy());
|
|
1845
|
+
} catch {
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Wallet timeout policy (#462 / PR #463 residual).
|
|
1851
|
+
* - connectTimeoutMs → eth_requestAccounts only
|
|
1852
|
+
* - machine RPCs → 20s default
|
|
1853
|
+
* - wallet_sendCalls → unbounded (0) — never short-time a payment sheet
|
|
1854
|
+
*/
|
|
1855
|
+
walletTimeoutPolicy() {
|
|
1856
|
+
const connect = typeof this.config.connectTimeoutMs === "number" && this.config.connectTimeoutMs > 0 ? this.config.connectTimeoutMs : 2e4;
|
|
1857
|
+
return {
|
|
1858
|
+
connectTimeoutMs: connect,
|
|
1859
|
+
requestTimeoutMs: 2e4,
|
|
1860
|
+
sendCallsTimeoutMs: 0
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
walletProvider() {
|
|
1864
|
+
return resolveProvider(this.config.wallet, this.walletTimeoutPolicy());
|
|
1566
1865
|
}
|
|
1567
1866
|
/** Connect the player's wallet and return their address. */
|
|
1568
1867
|
async connect() {
|
|
1569
1868
|
if (this.config.mock) {
|
|
1570
1869
|
return "0x0000000000000000000000000000000000000001";
|
|
1571
1870
|
}
|
|
1572
|
-
return getAccount(
|
|
1871
|
+
return getAccount(this.walletProvider());
|
|
1573
1872
|
}
|
|
1574
1873
|
/**
|
|
1575
1874
|
* A DROP-IN payment provider for game kits that expect `{ connect, payEntry }`
|
|
@@ -1629,7 +1928,7 @@ var Playmos = class {
|
|
|
1629
1928
|
})
|
|
1630
1929
|
);
|
|
1631
1930
|
}
|
|
1632
|
-
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
1931
|
+
if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
|
|
1633
1932
|
const res = await this.http.post(
|
|
1634
1933
|
"/payments",
|
|
1635
1934
|
{
|
|
@@ -1667,7 +1966,7 @@ var Playmos = class {
|
|
|
1667
1966
|
},
|
|
1668
1967
|
{ idempotencyKey }
|
|
1669
1968
|
);
|
|
1670
|
-
const provider =
|
|
1969
|
+
const provider = this.walletProvider();
|
|
1671
1970
|
if (this.config.gas?.mode === "player") {
|
|
1672
1971
|
const from0 = await getAccount(provider);
|
|
1673
1972
|
await assertEnoughGas(provider, from0);
|
|
@@ -1712,7 +2011,17 @@ var Playmos = class {
|
|
|
1712
2011
|
})
|
|
1713
2012
|
);
|
|
1714
2013
|
}
|
|
1715
|
-
|
|
2014
|
+
const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
|
|
2015
|
+
if (!serverSettleNoWallet) {
|
|
2016
|
+
const pinned = typeof input.identity === "string" ? input.identity.trim() : "";
|
|
2017
|
+
if (!pinned) {
|
|
2018
|
+
throw new ConfigError(
|
|
2019
|
+
'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).',
|
|
2020
|
+
{ field: "identity" }
|
|
2021
|
+
);
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
if (serverSettleNoWallet) {
|
|
1716
2025
|
const pinnedRoundKey = input.roundKey ?? input.roundId;
|
|
1717
2026
|
const res = await this.http.post(
|
|
1718
2027
|
"/payments",
|
|
@@ -1757,7 +2066,7 @@ var Playmos = class {
|
|
|
1757
2066
|
},
|
|
1758
2067
|
{ idempotencyKey }
|
|
1759
2068
|
);
|
|
1760
|
-
const provider =
|
|
2069
|
+
const provider = this.walletProvider();
|
|
1761
2070
|
if (this.config.gas?.mode === "player") {
|
|
1762
2071
|
const from0 = await getAccount(provider);
|
|
1763
2072
|
await assertEnoughGas(provider, from0);
|
|
@@ -1768,12 +2077,28 @@ var Playmos = class {
|
|
|
1768
2077
|
if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
|
|
1769
2078
|
const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
|
|
1770
2079
|
if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
|
|
1771
|
-
const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity
|
|
2080
|
+
const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity;
|
|
2081
|
+
if (!identity || !String(identity).trim()) {
|
|
2082
|
+
throw new ConfigError(
|
|
2083
|
+
"enterRound: missing on-chain identity after create-intent (pass input.identity).",
|
|
2084
|
+
{ field: "identity" }
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
1772
2087
|
const amountUnits = this.resolveUnits(intent, amountMicro);
|
|
1773
2088
|
const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
|
|
1774
|
-
|
|
2089
|
+
let callsId;
|
|
2090
|
+
try {
|
|
2091
|
+
({ id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent)));
|
|
2092
|
+
} catch (e) {
|
|
2093
|
+
throw mapAlreadyEntered(e);
|
|
2094
|
+
}
|
|
1775
2095
|
const { txHash } = await waitForCalls(provider, callsId);
|
|
1776
|
-
|
|
2096
|
+
let payment;
|
|
2097
|
+
try {
|
|
2098
|
+
payment = await this.settle(intent.payment.id, txHash);
|
|
2099
|
+
} catch (e) {
|
|
2100
|
+
throw mapAlreadyEntered(e);
|
|
2101
|
+
}
|
|
1777
2102
|
payment.identity = identity;
|
|
1778
2103
|
payment.prizePoolAddress = prizePool.toLowerCase();
|
|
1779
2104
|
payment.roundKey = roundKey;
|
|
@@ -1970,8 +2295,8 @@ var Playmos = class {
|
|
|
1970
2295
|
});
|
|
1971
2296
|
let raw;
|
|
1972
2297
|
try {
|
|
1973
|
-
if (await walletAvailable(this.config.wallet)) {
|
|
1974
|
-
const provider =
|
|
2298
|
+
if (await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
|
|
2299
|
+
const provider = this.walletProvider();
|
|
1975
2300
|
raw = await provider.request({
|
|
1976
2301
|
method: "eth_call",
|
|
1977
2302
|
params: [{ to: prizePool, data }, "latest"]
|
|
@@ -1998,7 +2323,7 @@ var Playmos = class {
|
|
|
1998
2323
|
raw = json.result;
|
|
1999
2324
|
}
|
|
2000
2325
|
} catch (e) {
|
|
2001
|
-
if (e instanceof ApiError) throw e;
|
|
2326
|
+
if (e instanceof ApiError || e instanceof WalletTimeoutError) throw e;
|
|
2002
2327
|
throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
|
|
2003
2328
|
prizePool,
|
|
2004
2329
|
wallet
|
package/dist/server.d.cts
CHANGED
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playmos/sdk",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "Playmos SDK
|
|
3
|
+
"version": "0.3.8",
|
|
4
|
+
"description": "Playmos SDK — stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
7
|
+
"homepage": "https://playmos-docs-public.vercel.app/docs",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://playmos-docs-public.vercel.app/docs"
|
|
10
|
+
},
|
|
7
11
|
"repository": {
|
|
8
12
|
"type": "git",
|
|
9
13
|
"url": "git+https://github.com/playmos-labs/playmos-sdk.git",
|