@playmos/sdk 0.3.1 → 0.3.3
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 +33 -20
- package/dist/index.cjs +521 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +383 -130
- package/dist/index.d.ts +383 -130
- package/dist/index.js +517 -45
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
package/dist/index.cjs
CHANGED
|
@@ -275,15 +275,36 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
275
275
|
);
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
|
+
function hasIdempotencyKey(init) {
|
|
279
|
+
const h = init.headers;
|
|
280
|
+
if (!h) return false;
|
|
281
|
+
if (h instanceof Headers) {
|
|
282
|
+
return Boolean(h.get("idempotency-key") || h.get("Idempotency-Key"));
|
|
283
|
+
}
|
|
284
|
+
if (Array.isArray(h)) {
|
|
285
|
+
return h.some(([k]) => String(k).toLowerCase() === "idempotency-key");
|
|
286
|
+
}
|
|
287
|
+
const rec = h;
|
|
288
|
+
return Object.keys(rec).some((k) => k.toLowerCase() === "idempotency-key" && !!rec[k]);
|
|
289
|
+
}
|
|
290
|
+
function isRetryable(res, init) {
|
|
291
|
+
if (res.status === 429) return true;
|
|
292
|
+
if (res.status === 502 || res.status === 503 || res.status === 504) {
|
|
293
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
294
|
+
if (method === "GET" || method === "HEAD") return true;
|
|
295
|
+
return hasIdempotencyKey(init);
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
278
299
|
async function sendWithRetry(url, init) {
|
|
279
300
|
let res = await send(url, init);
|
|
280
|
-
for (let attempt = 0;
|
|
301
|
+
for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
|
|
281
302
|
await sleep(backoffMs(res, attempt, cfg));
|
|
282
303
|
res = await send(url, init);
|
|
283
304
|
}
|
|
284
305
|
return res;
|
|
285
306
|
}
|
|
286
|
-
async function handle(res) {
|
|
307
|
+
async function handle(res, acceptStatuses) {
|
|
287
308
|
let text;
|
|
288
309
|
try {
|
|
289
310
|
text = await res.text();
|
|
@@ -298,9 +319,13 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
298
319
|
try {
|
|
299
320
|
json = text ? JSON.parse(text) : {};
|
|
300
321
|
} catch {
|
|
301
|
-
|
|
322
|
+
const gateway = res.status === 502 || res.status === 503 || res.status === 504;
|
|
323
|
+
const msg = gateway ? `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while settling on-chain. Retry with the same idempotency key (safe); prefer a dedicated Base Sepolia RPC on the service (BB-GATEB-002).` : `Non-JSON response (${res.status}) from ${res.url}`;
|
|
324
|
+
throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
|
|
325
|
+
}
|
|
326
|
+
if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
|
|
327
|
+
return json;
|
|
302
328
|
}
|
|
303
|
-
if (res.ok) return json;
|
|
304
329
|
const errBody = json;
|
|
305
330
|
const message = errBody?.error?.message ?? `Request failed with ${res.status}`;
|
|
306
331
|
if (res.status === 401 || res.status === 403 || errBody?.error?.code === "auth") {
|
|
@@ -316,7 +341,7 @@ function createHttpClient(baseUrl, apiKey, retry) {
|
|
|
316
341
|
};
|
|
317
342
|
if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
|
|
318
343
|
const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
319
|
-
return handle(res);
|
|
344
|
+
return handle(res, opts?.acceptStatuses);
|
|
320
345
|
},
|
|
321
346
|
async get(path) {
|
|
322
347
|
const res = await sendWithRetry(`${base}${path}`, {
|
|
@@ -532,7 +557,23 @@ async function waitForCalls(provider, id, timeoutMs = 6e4) {
|
|
|
532
557
|
function encodeApprove(spender, amountUnits) {
|
|
533
558
|
return viem.encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
|
|
534
559
|
}
|
|
535
|
-
|
|
560
|
+
var GAS_FLOOR_WEI_DEFAULT = 200000000000000n;
|
|
561
|
+
var GAS_FLOOR_WEI_BASE_SEPOLIA = 50000000000000n;
|
|
562
|
+
var CHAIN_ID_BASE_SEPOLIA = 84532;
|
|
563
|
+
function gasFloorWei(chainId) {
|
|
564
|
+
return chainId === CHAIN_ID_BASE_SEPOLIA ? GAS_FLOOR_WEI_BASE_SEPOLIA : GAS_FLOOR_WEI_DEFAULT;
|
|
565
|
+
}
|
|
566
|
+
async function assertEnoughGas(provider, from, minWei) {
|
|
567
|
+
let floor = minWei;
|
|
568
|
+
if (floor === void 0) {
|
|
569
|
+
let chainId = 0;
|
|
570
|
+
try {
|
|
571
|
+
const hex = await provider.request({ method: "eth_chainId", params: [] });
|
|
572
|
+
chainId = Number(BigInt(hex));
|
|
573
|
+
} catch {
|
|
574
|
+
}
|
|
575
|
+
floor = gasFloorWei(chainId);
|
|
576
|
+
}
|
|
536
577
|
let balance;
|
|
537
578
|
try {
|
|
538
579
|
const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
|
|
@@ -540,8 +581,11 @@ async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
|
|
|
540
581
|
} catch {
|
|
541
582
|
return;
|
|
542
583
|
}
|
|
543
|
-
if (balance <
|
|
544
|
-
throw new InsufficientGasError({
|
|
584
|
+
if (balance < floor) {
|
|
585
|
+
throw new InsufficientGasError({
|
|
586
|
+
balanceWei: balance.toString(),
|
|
587
|
+
minWei: floor.toString()
|
|
588
|
+
});
|
|
545
589
|
}
|
|
546
590
|
}
|
|
547
591
|
var toBytes32 = (s) => viem.keccak256(viem.toBytes(s));
|
|
@@ -603,6 +647,7 @@ function mockEntryPayment(args) {
|
|
|
603
647
|
args.seedBps,
|
|
604
648
|
args.rakeBps
|
|
605
649
|
);
|
|
650
|
+
const roundKey = args.roundKey && args.roundKey.trim() !== "" ? args.roundKey : args.roundId;
|
|
606
651
|
return {
|
|
607
652
|
id: prefixedId("entry"),
|
|
608
653
|
status: "confirmed",
|
|
@@ -619,7 +664,8 @@ function mockEntryPayment(args) {
|
|
|
619
664
|
},
|
|
620
665
|
gameId: args.gameId,
|
|
621
666
|
roundId: args.roundId,
|
|
622
|
-
roundKey
|
|
667
|
+
roundKey,
|
|
668
|
+
identity: args.identity,
|
|
623
669
|
// Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
|
|
624
670
|
prizePoolAddress: "0x0000000000000000000000000000000000000001",
|
|
625
671
|
playerId: args.playerId,
|
|
@@ -630,14 +676,160 @@ function mockEntryPayment(args) {
|
|
|
630
676
|
mock: true
|
|
631
677
|
};
|
|
632
678
|
}
|
|
679
|
+
function mockVerifyResult(payment) {
|
|
680
|
+
return { ...payment, mock: true };
|
|
681
|
+
}
|
|
633
682
|
|
|
634
|
-
// src/
|
|
683
|
+
// src/x402.ts
|
|
635
684
|
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
636
|
-
function
|
|
685
|
+
function requireAddress(value, field) {
|
|
637
686
|
if (typeof value !== "string" || value.trim() === "") {
|
|
638
687
|
throw new MissingFieldError(field);
|
|
639
688
|
}
|
|
640
689
|
if (!ADDRESS_RE.test(value)) {
|
|
690
|
+
throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
|
|
691
|
+
field,
|
|
692
|
+
value
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
return value.toLowerCase();
|
|
696
|
+
}
|
|
697
|
+
function networkToCaip2(network) {
|
|
698
|
+
if (network === "base") return "eip155:8453";
|
|
699
|
+
if (network === "base-sepolia") return "eip155:84532";
|
|
700
|
+
throw new ConfigError(`Unknown network for x402: ${JSON.stringify(network)}`, { network });
|
|
701
|
+
}
|
|
702
|
+
function toX402PaymentRequired(req, extras) {
|
|
703
|
+
const amountMicro = parseUsdToMicro(req.amount);
|
|
704
|
+
const out = {
|
|
705
|
+
scheme: "exact",
|
|
706
|
+
network: networkToCaip2(req.network),
|
|
707
|
+
maxAmountRequired: amountMicro.toString(),
|
|
708
|
+
// Service V1 emits the asset symbol; address is implied by network + config.
|
|
709
|
+
asset: "USDC",
|
|
710
|
+
payTo: req.payTo,
|
|
711
|
+
playmosRequirementId: req.id,
|
|
712
|
+
amount: req.amount,
|
|
713
|
+
playmosNetwork: req.network,
|
|
714
|
+
expiresAt: req.expiresAt,
|
|
715
|
+
terms: req.terms ?? null,
|
|
716
|
+
feeBps: extras?.feeBps ?? null,
|
|
717
|
+
feeSink: extras?.feeSink ?? null
|
|
718
|
+
};
|
|
719
|
+
return out;
|
|
720
|
+
}
|
|
721
|
+
function encodePaymentHeader(obj) {
|
|
722
|
+
const json = JSON.stringify(obj);
|
|
723
|
+
if (typeof Buffer !== "undefined") {
|
|
724
|
+
return Buffer.from(json, "utf8").toString("base64");
|
|
725
|
+
}
|
|
726
|
+
const bytes = new TextEncoder().encode(json);
|
|
727
|
+
let bin = "";
|
|
728
|
+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
|
|
729
|
+
return btoa(bin);
|
|
730
|
+
}
|
|
731
|
+
function decodePaymentHeader(header) {
|
|
732
|
+
if (typeof header !== "string" || header.trim() === "") {
|
|
733
|
+
throw new ConfigError("payment header must be a non-empty base64 JSON string");
|
|
734
|
+
}
|
|
735
|
+
try {
|
|
736
|
+
let json;
|
|
737
|
+
if (typeof Buffer !== "undefined") {
|
|
738
|
+
json = Buffer.from(header, "base64").toString("utf8");
|
|
739
|
+
} else {
|
|
740
|
+
const bin = atob(header);
|
|
741
|
+
const bytes = new Uint8Array(bin.length);
|
|
742
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
743
|
+
json = new TextDecoder().decode(bytes);
|
|
744
|
+
}
|
|
745
|
+
return JSON.parse(json);
|
|
746
|
+
} catch (err) {
|
|
747
|
+
throw new ConfigError("invalid base64 JSON payment header", {
|
|
748
|
+
cause: err instanceof Error ? err.message : String(err)
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
function createX402Challenge(requirement, extras) {
|
|
753
|
+
if (!requirement || typeof requirement !== "object") {
|
|
754
|
+
throw new ConfigError("createX402Challenge requires a PaymentRequirement");
|
|
755
|
+
}
|
|
756
|
+
if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
|
|
757
|
+
throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
|
|
758
|
+
}
|
|
759
|
+
requireAddress(requirement.payTo, "payTo");
|
|
760
|
+
parseUsdToMicro(requirement.amount);
|
|
761
|
+
const feeBps = extras?.feeBps ?? 0;
|
|
762
|
+
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
|
|
763
|
+
throw new ConfigError(
|
|
764
|
+
`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(extras?.feeBps)}`,
|
|
765
|
+
{ feeBps: extras?.feeBps }
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const feeSink = extras?.feeSink ?? null;
|
|
769
|
+
if (feeBps > 0 && feeSink) requireAddress(feeSink, "feeSink");
|
|
770
|
+
const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
|
|
771
|
+
return {
|
|
772
|
+
status: 402,
|
|
773
|
+
headers: {
|
|
774
|
+
"PAYMENT-REQUIRED": encodePaymentHeader(paymentRequired),
|
|
775
|
+
"Content-Type": "application/json"
|
|
776
|
+
},
|
|
777
|
+
body: {
|
|
778
|
+
error: { code: "payment_required", message: "Payment Required" },
|
|
779
|
+
requirement,
|
|
780
|
+
paymentRequired,
|
|
781
|
+
feeBps,
|
|
782
|
+
feeSink
|
|
783
|
+
},
|
|
784
|
+
paymentRequired
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
function validateX402ChallengeInput(input) {
|
|
788
|
+
if (!input || typeof input !== "object") {
|
|
789
|
+
throw new ConfigError("x402 challenge input is required");
|
|
790
|
+
}
|
|
791
|
+
if ("url" in input && input.url !== void 0) {
|
|
792
|
+
throw new ConfigError(
|
|
793
|
+
"playmos.x402 does not accept { url } in V1 \u2014 use pay({ payTo, amount }) wrapping challenges+settle (ADR: resource URLs deferred V1.1+). See docs/design/PHASE-4-X402-ADR.md.",
|
|
794
|
+
{ field: "url" }
|
|
795
|
+
);
|
|
796
|
+
}
|
|
797
|
+
const payTo = requireAddress(input.payTo, "payTo");
|
|
798
|
+
parseUsdToMicro(input.amount);
|
|
799
|
+
const intent = input.intent ?? "transfer";
|
|
800
|
+
if (intent !== "transfer" && intent !== "marketplace.buy") {
|
|
801
|
+
throw new ConfigError(
|
|
802
|
+
`x402 intent must be "transfer" or "marketplace.buy", got: ${JSON.stringify(input.intent)}`,
|
|
803
|
+
{ intent: input.intent }
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
const feeBps = input.feeBps ?? 0;
|
|
807
|
+
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
|
|
808
|
+
throw new ConfigError(
|
|
809
|
+
`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(input.feeBps)}`,
|
|
810
|
+
{ feeBps: input.feeBps }
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const feeSink = input.feeSink === void 0 ? void 0 : requireAddress(input.feeSink, "feeSink");
|
|
814
|
+
return {
|
|
815
|
+
payTo,
|
|
816
|
+
amount: input.amount,
|
|
817
|
+
intent,
|
|
818
|
+
feeBps,
|
|
819
|
+
...feeSink !== void 0 ? { feeSink } : {},
|
|
820
|
+
...input.terms !== void 0 ? { terms: input.terms } : {},
|
|
821
|
+
...input.expiresInMs !== void 0 ? { expiresInMs: input.expiresInMs } : {},
|
|
822
|
+
...input.id !== void 0 ? { id: input.id } : {}
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// src/client.ts
|
|
827
|
+
var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
828
|
+
function requireAddressField(value, field) {
|
|
829
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
830
|
+
throw new MissingFieldError(field);
|
|
831
|
+
}
|
|
832
|
+
if (!ADDRESS_RE2.test(value)) {
|
|
641
833
|
throw new ConfigError(
|
|
642
834
|
`${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
|
|
643
835
|
{ field, value }
|
|
@@ -682,6 +874,122 @@ var Playmos = class {
|
|
|
682
874
|
/** Create a Bridge KYC onboarding link (fiat payout). */
|
|
683
875
|
createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
|
|
684
876
|
};
|
|
877
|
+
/**
|
|
878
|
+
* `x402` — Phase 4 HTTP 402 / x402-shaped adapter (#153).
|
|
879
|
+
*
|
|
880
|
+
* V1 surface (ADR):
|
|
881
|
+
* - `challenge` → `POST /v1/x402/challenges` (returns 402; treated as success)
|
|
882
|
+
* - `fulfill` → `POST /v1/x402/settle`
|
|
883
|
+
* - `pay` → challenge then fulfill (senior DX ≤15 lines)
|
|
884
|
+
*
|
|
885
|
+
* **Not** resource URLs (`pay({ url })` is V1.1+). **Not** stock third-party
|
|
886
|
+
* x402 client interop (X3b deferred until #161). Requires `sk_test_` +
|
|
887
|
+
* Base Sepolia; flag `PLAYMOS_X402_ENABLED` on the service.
|
|
888
|
+
*
|
|
889
|
+
* ```ts
|
|
890
|
+
* const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SK!, network: "base-sepolia" });
|
|
891
|
+
* const result = await playmos.x402.pay({
|
|
892
|
+
* payTo: "0x…",
|
|
893
|
+
* amount: "0.50",
|
|
894
|
+
* feeBps: 0,
|
|
895
|
+
* });
|
|
896
|
+
* // result.txHash → BaseScan
|
|
897
|
+
* ```
|
|
898
|
+
*/
|
|
899
|
+
this.x402 = {
|
|
900
|
+
/**
|
|
901
|
+
* Mint a server-authoritative PaymentRequirement (HTTP 402).
|
|
902
|
+
* Fee terms are stored server-side — settle rejects payload disagreement.
|
|
903
|
+
*/
|
|
904
|
+
challenge: async (input) => {
|
|
905
|
+
this.assertX402Allowed();
|
|
906
|
+
const body = validateX402ChallengeInput(input);
|
|
907
|
+
if (body.intent === "marketplace.buy") {
|
|
908
|
+
throw new ConfigError(
|
|
909
|
+
'x402 challenge intent "marketplace.buy" is not supported yet (V1 service: "transfer" only; marketplace.buy follows).',
|
|
910
|
+
{ intent: body.intent }
|
|
911
|
+
);
|
|
912
|
+
}
|
|
913
|
+
const res = await this.http.post("/x402/challenges", body, { acceptStatuses: [402] });
|
|
914
|
+
return {
|
|
915
|
+
requirement: res.requirement,
|
|
916
|
+
paymentRequired: res.paymentRequired,
|
|
917
|
+
feeBps: res.feeBps ?? body.feeBps,
|
|
918
|
+
feeSink: res.feeSink ?? body.feeSink ?? null
|
|
919
|
+
};
|
|
920
|
+
},
|
|
921
|
+
/**
|
|
922
|
+
* Settle a previously minted challenge with an `x402-payload` authorization.
|
|
923
|
+
* Defaults mode to `server-signer` when omitted (explicit on the wire — fail-closed
|
|
924
|
+
* materialize still requires mode; the route also defaults for this path).
|
|
925
|
+
*/
|
|
926
|
+
fulfill: async (requirement, authorization) => {
|
|
927
|
+
this.assertX402Allowed();
|
|
928
|
+
if (!requirement || typeof requirement !== "object") {
|
|
929
|
+
throw new ConfigError("fulfill requires a PaymentRequirement from challenge()");
|
|
930
|
+
}
|
|
931
|
+
if (typeof requirement.id !== "string" || requirement.id.trim() === "") {
|
|
932
|
+
throw new MissingFieldError("requirement.id");
|
|
933
|
+
}
|
|
934
|
+
let payload;
|
|
935
|
+
if (authorization && typeof authorization === "object" && "kind" in authorization) {
|
|
936
|
+
const a = authorization;
|
|
937
|
+
if (a.kind !== "x402-payload") {
|
|
938
|
+
throw new ConfigError('authorization.kind must be "x402-payload"', { kind: a.kind });
|
|
939
|
+
}
|
|
940
|
+
payload = { ...a.payload ?? {} };
|
|
941
|
+
} else {
|
|
942
|
+
payload = { ...authorization ?? {} };
|
|
943
|
+
}
|
|
944
|
+
delete payload.feeBps;
|
|
945
|
+
delete payload.feeSink;
|
|
946
|
+
if (payload.mode === void 0 || typeof payload.mode === "string" && payload.mode.trim() === "") {
|
|
947
|
+
const hasNestedAuth = payload.authorization !== void 0 || payload.signature !== void 0 || payload.paymentPayload !== void 0;
|
|
948
|
+
if (hasNestedAuth) {
|
|
949
|
+
throw new ConfigError(
|
|
950
|
+
"fulfill: payload.mode is required when authorization/signature is present \u2014 stock-shaped payloads are not auto-mapped to server-signer (V1: no interop; set mode explicitly or see #161)",
|
|
951
|
+
{ field: "mode" }
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
payload.mode = "server-signer";
|
|
955
|
+
}
|
|
956
|
+
if (payload.scheme === void 0) payload.scheme = "exact";
|
|
957
|
+
if (payload.requirementId === void 0) payload.requirementId = requirement.id;
|
|
958
|
+
const res = await this.http.post("/x402/settle", {
|
|
959
|
+
requirement,
|
|
960
|
+
authorization: { kind: "x402-payload", payload }
|
|
961
|
+
});
|
|
962
|
+
const s = res.settlement;
|
|
963
|
+
return {
|
|
964
|
+
requirementId: s.requirementId,
|
|
965
|
+
status: s.status,
|
|
966
|
+
txHash: s.txHash ?? null,
|
|
967
|
+
idempotentReplay: Boolean(s.idempotentReplay),
|
|
968
|
+
verifiedVia: s.verifiedVia,
|
|
969
|
+
feeBps: s.feeBps,
|
|
970
|
+
fee: s.fee,
|
|
971
|
+
net: s.net,
|
|
972
|
+
requirement: res.requirement,
|
|
973
|
+
success: s.success
|
|
974
|
+
};
|
|
975
|
+
},
|
|
976
|
+
/**
|
|
977
|
+
* Senior DX: mint challenge + settle in one call.
|
|
978
|
+
* Wraps challenges + settle only — **not** `pay({ url })` (ADR).
|
|
979
|
+
*/
|
|
980
|
+
pay: async (input) => {
|
|
981
|
+
this.assertX402Allowed();
|
|
982
|
+
validateX402ChallengeInput(input);
|
|
983
|
+
const challenge = await this.x402.challenge(input);
|
|
984
|
+
const auth = {
|
|
985
|
+
mode: input.mode ?? "server-signer"
|
|
986
|
+
};
|
|
987
|
+
if (input.from !== void 0) auth.from = input.from;
|
|
988
|
+
if (input.authorization !== void 0) auth.authorization = input.authorization;
|
|
989
|
+
if (input.txHash !== void 0) auth.txHash = input.txHash;
|
|
990
|
+
return this.x402.fulfill(challenge.requirement, auth);
|
|
991
|
+
}
|
|
992
|
+
};
|
|
685
993
|
/**
|
|
686
994
|
* Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
|
|
687
995
|
*
|
|
@@ -691,6 +999,8 @@ var Playmos = class {
|
|
|
691
999
|
*
|
|
692
1000
|
* Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
|
|
693
1001
|
*/
|
|
1002
|
+
/** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
|
|
1003
|
+
this.mockRounds = /* @__PURE__ */ new Map();
|
|
694
1004
|
this.rounds = {
|
|
695
1005
|
open: async (input) => {
|
|
696
1006
|
requireField(input?.gameId, "gameId");
|
|
@@ -700,6 +1010,34 @@ var Playmos = class {
|
|
|
700
1010
|
throw new ConfigError("payout rule is required (winner-take-all | top-n | custom)");
|
|
701
1011
|
}
|
|
702
1012
|
validateAmount(input.entryAmount);
|
|
1013
|
+
if (this.config.mock) {
|
|
1014
|
+
const roundKey = input.roundKey ?? input.roundId;
|
|
1015
|
+
const existing = this.mockRounds.get(input.roundId);
|
|
1016
|
+
if (existing?.status === "open" || existing?.status === "locked") {
|
|
1017
|
+
return existing;
|
|
1018
|
+
}
|
|
1019
|
+
if (existing?.status === "settled") {
|
|
1020
|
+
throw new ApiError(`round ${input.roundId} already settled`, {
|
|
1021
|
+
status: 409,
|
|
1022
|
+
code: "conflict"
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
const round2 = {
|
|
1026
|
+
roundId: input.roundId,
|
|
1027
|
+
gameId: input.gameId,
|
|
1028
|
+
roundKey,
|
|
1029
|
+
status: "open",
|
|
1030
|
+
entryAmount: input.entryAmount,
|
|
1031
|
+
pool: null,
|
|
1032
|
+
entrants: 0,
|
|
1033
|
+
payout: input.payout,
|
|
1034
|
+
closeAt: input.closeAt,
|
|
1035
|
+
openTxHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
|
|
1036
|
+
prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
|
|
1037
|
+
};
|
|
1038
|
+
this.mockRounds.set(input.roundId, round2);
|
|
1039
|
+
return round2;
|
|
1040
|
+
}
|
|
703
1041
|
const { round } = await this.http.post("/rounds", {
|
|
704
1042
|
gameId: input.gameId,
|
|
705
1043
|
roundId: input.roundId,
|
|
@@ -713,6 +1051,29 @@ var Playmos = class {
|
|
|
713
1051
|
},
|
|
714
1052
|
lock: async (input) => {
|
|
715
1053
|
requireField(input?.roundId, "roundId");
|
|
1054
|
+
if (this.config.mock) {
|
|
1055
|
+
const existing = this.mockRounds.get(input.roundId);
|
|
1056
|
+
if (!existing) {
|
|
1057
|
+
throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
|
|
1058
|
+
}
|
|
1059
|
+
if (existing.status === "locked" || existing.status === "settled") return existing;
|
|
1060
|
+
if (existing.status !== "open") {
|
|
1061
|
+
throw new ApiError(`round ${input.roundId} is not open (status=${existing.status})`, {
|
|
1062
|
+
status: 409,
|
|
1063
|
+
code: "conflict"
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
const locked = {
|
|
1067
|
+
...existing,
|
|
1068
|
+
status: "locked",
|
|
1069
|
+
// Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
|
|
1070
|
+
pool: existing.pool ?? existing.entryAmount,
|
|
1071
|
+
entrants: existing.entrants ?? 0,
|
|
1072
|
+
lockTxHash: "0x000000000000000000000000000000000000000000000000000000000000loc1"
|
|
1073
|
+
};
|
|
1074
|
+
this.mockRounds.set(input.roundId, locked);
|
|
1075
|
+
return locked;
|
|
1076
|
+
}
|
|
716
1077
|
const { round } = await this.http.post(
|
|
717
1078
|
`/rounds/${encodeURIComponent(input.roundId)}/lock`,
|
|
718
1079
|
{ gameId: input.gameId }
|
|
@@ -722,6 +1083,51 @@ var Playmos = class {
|
|
|
722
1083
|
settle: async (input) => {
|
|
723
1084
|
requireField(input?.roundId, "roundId");
|
|
724
1085
|
if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
|
|
1086
|
+
if (this.config.mock) {
|
|
1087
|
+
const existing = this.mockRounds.get(input.roundId);
|
|
1088
|
+
if (!existing) {
|
|
1089
|
+
throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
|
|
1090
|
+
}
|
|
1091
|
+
if (existing.status === "settled" && existing.settleTxHash) {
|
|
1092
|
+
return {
|
|
1093
|
+
roundId: input.roundId,
|
|
1094
|
+
txHash: existing.settleTxHash,
|
|
1095
|
+
poolPaid: existing.pool ?? "0",
|
|
1096
|
+
winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
|
|
1097
|
+
wallet,
|
|
1098
|
+
amount: existing.pool ?? existing.entryAmount
|
|
1099
|
+
})),
|
|
1100
|
+
status: "settled"
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
if (existing.status !== "locked" && existing.status !== "open") {
|
|
1104
|
+
throw new ApiError(`round ${input.roundId} cannot settle (status=${existing.status})`, {
|
|
1105
|
+
status: 409,
|
|
1106
|
+
code: "conflict"
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
|
|
1110
|
+
wallet,
|
|
1111
|
+
// Winner-take-all mock shape when ranking only.
|
|
1112
|
+
amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
|
|
1113
|
+
}));
|
|
1114
|
+
const settleTxHash = "0x000000000000000000000000000000000000000000000000000000000000set1";
|
|
1115
|
+
const poolPaid = existing.pool ?? existing.entryAmount;
|
|
1116
|
+
const settled = {
|
|
1117
|
+
...existing,
|
|
1118
|
+
status: "settled",
|
|
1119
|
+
settleTxHash,
|
|
1120
|
+
pool: poolPaid
|
|
1121
|
+
};
|
|
1122
|
+
this.mockRounds.set(input.roundId, settled);
|
|
1123
|
+
return {
|
|
1124
|
+
roundId: input.roundId,
|
|
1125
|
+
txHash: settleTxHash,
|
|
1126
|
+
poolPaid,
|
|
1127
|
+
winners,
|
|
1128
|
+
status: "settled"
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
725
1131
|
const { settle } = await this.http.post(
|
|
726
1132
|
`/rounds/${encodeURIComponent(input.roundId)}/settle`,
|
|
727
1133
|
{ gameId: input.gameId, results: input.results }
|
|
@@ -730,6 +1136,13 @@ var Playmos = class {
|
|
|
730
1136
|
},
|
|
731
1137
|
get: async (input) => {
|
|
732
1138
|
requireField(input?.roundId, "roundId");
|
|
1139
|
+
if (this.config.mock) {
|
|
1140
|
+
const existing = this.mockRounds.get(input.roundId);
|
|
1141
|
+
if (!existing) {
|
|
1142
|
+
throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
|
|
1143
|
+
}
|
|
1144
|
+
return existing;
|
|
1145
|
+
}
|
|
733
1146
|
const { round } = await this.http.get(
|
|
734
1147
|
`/rounds/${encodeURIComponent(input.roundId)}`
|
|
735
1148
|
);
|
|
@@ -1014,6 +1427,11 @@ var Playmos = class {
|
|
|
1014
1427
|
return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
|
|
1015
1428
|
}
|
|
1016
1429
|
};
|
|
1430
|
+
/**
|
|
1431
|
+
* Offline mock payments for this client instance — so `verify(id)` after
|
|
1432
|
+
* `mock: true` pay/enterRound does not hit the live API (#204).
|
|
1433
|
+
*/
|
|
1434
|
+
this.mockPayments = /* @__PURE__ */ new Map();
|
|
1017
1435
|
/**
|
|
1018
1436
|
* `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
|
|
1019
1437
|
* `get` reconciles once against chain truth; `wait` polls it to a terminal state
|
|
@@ -1063,6 +1481,9 @@ var Playmos = class {
|
|
|
1063
1481
|
}
|
|
1064
1482
|
/** Connect the player's wallet and return their address. */
|
|
1065
1483
|
async connect() {
|
|
1484
|
+
if (this.config.mock) {
|
|
1485
|
+
return "0x0000000000000000000000000000000000000001";
|
|
1486
|
+
}
|
|
1066
1487
|
return getAccount(resolveProvider(this.config.wallet));
|
|
1067
1488
|
}
|
|
1068
1489
|
/**
|
|
@@ -1079,18 +1500,30 @@ var Playmos = class {
|
|
|
1079
1500
|
payEntry: async (req) => {
|
|
1080
1501
|
const entry = await this.enterRound({
|
|
1081
1502
|
gameId: cfg.gameId,
|
|
1503
|
+
// Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
|
|
1082
1504
|
roundId: req.roundKey,
|
|
1083
|
-
// the round the server verifies against
|
|
1084
1505
|
roundKey: req.roundKey,
|
|
1085
1506
|
identity: req.identity,
|
|
1086
1507
|
amount: formatMicroToUsd(req.entryUnits),
|
|
1087
1508
|
playerId: req.wallet
|
|
1088
1509
|
});
|
|
1089
|
-
const
|
|
1090
|
-
|
|
1510
|
+
const raw = String(entry.status ?? "pending").toLowerCase();
|
|
1511
|
+
const status = raw === "confirmed" ? "confirmed" : raw === "failed" ? "failed" : "pending";
|
|
1512
|
+
const onchain = !entry.mock;
|
|
1513
|
+
return {
|
|
1514
|
+
paymentId: entry.id,
|
|
1515
|
+
status,
|
|
1516
|
+
...entry.txHash ? { txHash: entry.txHash } : {},
|
|
1517
|
+
onchain,
|
|
1518
|
+
identity: entry.identity ?? req.identity
|
|
1519
|
+
};
|
|
1091
1520
|
}
|
|
1092
1521
|
};
|
|
1093
1522
|
}
|
|
1523
|
+
rememberMock(payment) {
|
|
1524
|
+
if (payment.mock) this.mockPayments.set(payment.id, payment);
|
|
1525
|
+
return payment;
|
|
1526
|
+
}
|
|
1094
1527
|
/** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
|
|
1095
1528
|
async pay(input) {
|
|
1096
1529
|
const amountMicro = validateAmount(input.amount);
|
|
@@ -1099,14 +1532,16 @@ var Playmos = class {
|
|
|
1099
1532
|
const metadata = validateMetadata(input.metadata);
|
|
1100
1533
|
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
1101
1534
|
if (this.config.mock) {
|
|
1102
|
-
return
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1535
|
+
return this.rememberMock(
|
|
1536
|
+
mockIapPayment({
|
|
1537
|
+
amountMicro,
|
|
1538
|
+
feeBps: IAP_FEE_BPS,
|
|
1539
|
+
sku: input.sku,
|
|
1540
|
+
playerId: input.playerId,
|
|
1541
|
+
chain: this.env.network,
|
|
1542
|
+
metadata
|
|
1543
|
+
})
|
|
1544
|
+
);
|
|
1110
1545
|
}
|
|
1111
1546
|
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
1112
1547
|
const res = await this.http.post(
|
|
@@ -1149,17 +1584,22 @@ var Playmos = class {
|
|
|
1149
1584
|
const metadata = validateMetadata(input.metadata);
|
|
1150
1585
|
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
1151
1586
|
if (this.config.mock) {
|
|
1152
|
-
return
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1587
|
+
return this.rememberMock(
|
|
1588
|
+
mockEntryPayment({
|
|
1589
|
+
amountMicro,
|
|
1590
|
+
poolBps: POOL_BPS,
|
|
1591
|
+
seedBps: SEED_BPS,
|
|
1592
|
+
rakeBps: RAKE_BPS,
|
|
1593
|
+
gameId: input.gameId,
|
|
1594
|
+
roundId: input.roundId,
|
|
1595
|
+
playerId: input.playerId,
|
|
1596
|
+
chain: this.env.network,
|
|
1597
|
+
metadata,
|
|
1598
|
+
// B2 pins — must survive mock path for hub hasEntered parity (#204).
|
|
1599
|
+
roundKey: input.roundKey,
|
|
1600
|
+
identity: input.identity
|
|
1601
|
+
})
|
|
1602
|
+
);
|
|
1163
1603
|
}
|
|
1164
1604
|
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
1165
1605
|
const res = await this.http.post(
|
|
@@ -1171,7 +1611,7 @@ var Playmos = class {
|
|
|
1171
1611
|
if (input.identity) payment2.identity = input.identity;
|
|
1172
1612
|
const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
|
|
1173
1613
|
if (pool) payment2.prizePoolAddress = pool.toLowerCase();
|
|
1174
|
-
payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
|
|
1614
|
+
payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
|
|
1175
1615
|
return payment2;
|
|
1176
1616
|
}
|
|
1177
1617
|
const intent = await this.http.post(
|
|
@@ -1266,9 +1706,41 @@ var Playmos = class {
|
|
|
1266
1706
|
/** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
|
|
1267
1707
|
async verify(paymentId) {
|
|
1268
1708
|
requireField(paymentId, "paymentId");
|
|
1709
|
+
if (this.config.mock) {
|
|
1710
|
+
const cached = this.mockPayments.get(paymentId);
|
|
1711
|
+
if (!cached) {
|
|
1712
|
+
throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
|
|
1713
|
+
}
|
|
1714
|
+
const m = mockVerifyResult(cached);
|
|
1715
|
+
return {
|
|
1716
|
+
id: m.id,
|
|
1717
|
+
status: m.status,
|
|
1718
|
+
amount: m.amount,
|
|
1719
|
+
fee: m.fee,
|
|
1720
|
+
net: m.net,
|
|
1721
|
+
txHash: m.txHash,
|
|
1722
|
+
playerId: m.playerId ?? "",
|
|
1723
|
+
sku: m.sku,
|
|
1724
|
+
roundId: m.roundId,
|
|
1725
|
+
chain: m.chain,
|
|
1726
|
+
verifiedVia: "cache"
|
|
1727
|
+
};
|
|
1728
|
+
}
|
|
1269
1729
|
return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
|
|
1270
1730
|
}
|
|
1271
1731
|
// ---- internals ---------------------------------------------------------
|
|
1732
|
+
/**
|
|
1733
|
+
* x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
|
|
1734
|
+
* network call so mainnet / live keys never silently hit the adapter.
|
|
1735
|
+
*/
|
|
1736
|
+
assertX402Allowed() {
|
|
1737
|
+
if (!this.env.isTest || this.env.network !== "base-sepolia") {
|
|
1738
|
+
throw new ConfigError(
|
|
1739
|
+
'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
|
|
1740
|
+
{ network: this.env.network, isTest: this.env.isTest }
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1272
1744
|
/**
|
|
1273
1745
|
* Map a server-settled payment (from the `settle: "server"` response) into the
|
|
1274
1746
|
* SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
|
|
@@ -1449,10 +1921,10 @@ var PayoutError = class extends Error {
|
|
|
1449
1921
|
this.name = "PayoutError";
|
|
1450
1922
|
}
|
|
1451
1923
|
};
|
|
1452
|
-
var
|
|
1924
|
+
var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
|
|
1453
1925
|
var BPS = 10000n;
|
|
1454
|
-
function
|
|
1455
|
-
if (!
|
|
1926
|
+
function requireAddress2(w, i) {
|
|
1927
|
+
if (!ADDRESS_RE3.test(w)) {
|
|
1456
1928
|
throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
|
|
1457
1929
|
}
|
|
1458
1930
|
return w.toLowerCase();
|
|
@@ -1475,7 +1947,7 @@ function computePayout(pool, ranking, rule) {
|
|
|
1475
1947
|
if (!Array.isArray(ranking) || ranking.length === 0) {
|
|
1476
1948
|
throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
|
|
1477
1949
|
}
|
|
1478
|
-
const wallets = ranking.map((w, i) =>
|
|
1950
|
+
const wallets = ranking.map((w, i) => requireAddress2(w, i));
|
|
1479
1951
|
const seen = /* @__PURE__ */ new Set();
|
|
1480
1952
|
for (const w of wallets) {
|
|
1481
1953
|
if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
|
|
@@ -1548,13 +2020,13 @@ function computePayout(pool, ranking, rule) {
|
|
|
1548
2020
|
}
|
|
1549
2021
|
|
|
1550
2022
|
// src/settlement.ts
|
|
1551
|
-
var
|
|
2023
|
+
var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
|
|
1552
2024
|
var DEFAULT_TTL_MS = 15 * 60 * 1e3;
|
|
1553
|
-
function
|
|
2025
|
+
function requireAddress3(value, field) {
|
|
1554
2026
|
if (typeof value !== "string" || value.trim() === "") {
|
|
1555
2027
|
throw new MissingFieldError(field);
|
|
1556
2028
|
}
|
|
1557
|
-
if (!
|
|
2029
|
+
if (!ADDRESS_RE4.test(value)) {
|
|
1558
2030
|
throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
|
|
1559
2031
|
field,
|
|
1560
2032
|
value
|
|
@@ -1571,7 +2043,7 @@ function requireNetwork(value) {
|
|
|
1571
2043
|
}
|
|
1572
2044
|
function createPaymentRequirement(input) {
|
|
1573
2045
|
const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
1574
|
-
const payTo =
|
|
2046
|
+
const payTo = requireAddress3(input.payTo, "payTo");
|
|
1575
2047
|
const network = requireNetwork(input.network);
|
|
1576
2048
|
const asset = input.asset ?? "USDC";
|
|
1577
2049
|
if (asset !== "USDC") {
|
|
@@ -1614,7 +2086,7 @@ function parsePaymentRequirement(input) {
|
|
|
1614
2086
|
asset: o.asset
|
|
1615
2087
|
});
|
|
1616
2088
|
}
|
|
1617
|
-
const payTo =
|
|
2089
|
+
const payTo = requireAddress3(o.payTo, "payTo");
|
|
1618
2090
|
const network = requireNetwork(o.network);
|
|
1619
2091
|
if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
|
|
1620
2092
|
parseUsdToMicro(o.amount);
|
|
@@ -1661,9 +2133,13 @@ exports.computeIapSplit = computeIapSplit;
|
|
|
1661
2133
|
exports.computePayout = computePayout;
|
|
1662
2134
|
exports.computePoolSplit = computePoolSplit;
|
|
1663
2135
|
exports.createPaymentRequirement = createPaymentRequirement;
|
|
2136
|
+
exports.createX402Challenge = createX402Challenge;
|
|
2137
|
+
exports.decodePaymentHeader = decodePaymentHeader;
|
|
2138
|
+
exports.encodePaymentHeader = encodePaymentHeader;
|
|
1664
2139
|
exports.formatMicroToUsd = formatMicroToUsd;
|
|
1665
2140
|
exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
|
|
1666
2141
|
exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
|
|
2142
|
+
exports.networkToCaip2 = networkToCaip2;
|
|
1667
2143
|
exports.parsePaymentRequirement = parsePaymentRequirement;
|
|
1668
2144
|
exports.parseUsdToMicro = parseUsdToMicro;
|
|
1669
2145
|
exports.prefixedId = prefixedId;
|
|
@@ -1673,6 +2149,8 @@ exports.previewMarketplaceSplit = previewMarketplaceSplit;
|
|
|
1673
2149
|
exports.previewPoolSplit = previewPoolSplit;
|
|
1674
2150
|
exports.previewTransferSplit = previewTransferSplit;
|
|
1675
2151
|
exports.serializePaymentRequirement = serializePaymentRequirement;
|
|
2152
|
+
exports.toX402PaymentRequired = toX402PaymentRequired;
|
|
1676
2153
|
exports.ulid = ulid;
|
|
2154
|
+
exports.validateX402ChallengeInput = validateX402ChallengeInput;
|
|
1677
2155
|
//# sourceMappingURL=index.cjs.map
|
|
1678
2156
|
//# sourceMappingURL=index.cjs.map
|