@playmos/sdk 0.3.1 → 0.3.2
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 +414 -42
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +379 -129
- package/dist/index.d.ts +379 -129
- package/dist/index.js +410 -44
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
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
|
*
|
|
@@ -1014,6 +1322,11 @@ var Playmos = class {
|
|
|
1014
1322
|
return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
|
|
1015
1323
|
}
|
|
1016
1324
|
};
|
|
1325
|
+
/**
|
|
1326
|
+
* Offline mock payments for this client instance — so `verify(id)` after
|
|
1327
|
+
* `mock: true` pay/enterRound does not hit the live API (#204).
|
|
1328
|
+
*/
|
|
1329
|
+
this.mockPayments = /* @__PURE__ */ new Map();
|
|
1017
1330
|
/**
|
|
1018
1331
|
* `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
|
|
1019
1332
|
* `get` reconciles once against chain truth; `wait` polls it to a terminal state
|
|
@@ -1063,6 +1376,9 @@ var Playmos = class {
|
|
|
1063
1376
|
}
|
|
1064
1377
|
/** Connect the player's wallet and return their address. */
|
|
1065
1378
|
async connect() {
|
|
1379
|
+
if (this.config.mock) {
|
|
1380
|
+
return "0x0000000000000000000000000000000000000001";
|
|
1381
|
+
}
|
|
1066
1382
|
return getAccount(resolveProvider(this.config.wallet));
|
|
1067
1383
|
}
|
|
1068
1384
|
/**
|
|
@@ -1079,18 +1395,29 @@ var Playmos = class {
|
|
|
1079
1395
|
payEntry: async (req) => {
|
|
1080
1396
|
const entry = await this.enterRound({
|
|
1081
1397
|
gameId: cfg.gameId,
|
|
1398
|
+
// Studio roundId for bookkeeping; pin roundKey separately for hasEntered.
|
|
1082
1399
|
roundId: req.roundKey,
|
|
1083
|
-
// the round the server verifies against
|
|
1084
1400
|
roundKey: req.roundKey,
|
|
1085
1401
|
identity: req.identity,
|
|
1086
1402
|
amount: formatMicroToUsd(req.entryUnits),
|
|
1087
1403
|
playerId: req.wallet
|
|
1088
1404
|
});
|
|
1089
1405
|
const status = entry.status === "confirmed" ? "CONFIRMED" : entry.status === "failed" ? "FAILED" : "PENDING";
|
|
1090
|
-
|
|
1406
|
+
const onchain = !entry.mock;
|
|
1407
|
+
return {
|
|
1408
|
+
paymentId: entry.id,
|
|
1409
|
+
status,
|
|
1410
|
+
txHash: entry.txHash,
|
|
1411
|
+
onchain,
|
|
1412
|
+
identity: entry.identity ?? req.identity
|
|
1413
|
+
};
|
|
1091
1414
|
}
|
|
1092
1415
|
};
|
|
1093
1416
|
}
|
|
1417
|
+
rememberMock(payment) {
|
|
1418
|
+
if (payment.mock) this.mockPayments.set(payment.id, payment);
|
|
1419
|
+
return payment;
|
|
1420
|
+
}
|
|
1094
1421
|
/** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
|
|
1095
1422
|
async pay(input) {
|
|
1096
1423
|
const amountMicro = validateAmount(input.amount);
|
|
@@ -1099,14 +1426,16 @@ var Playmos = class {
|
|
|
1099
1426
|
const metadata = validateMetadata(input.metadata);
|
|
1100
1427
|
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
1101
1428
|
if (this.config.mock) {
|
|
1102
|
-
return
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1429
|
+
return this.rememberMock(
|
|
1430
|
+
mockIapPayment({
|
|
1431
|
+
amountMicro,
|
|
1432
|
+
feeBps: IAP_FEE_BPS,
|
|
1433
|
+
sku: input.sku,
|
|
1434
|
+
playerId: input.playerId,
|
|
1435
|
+
chain: this.env.network,
|
|
1436
|
+
metadata
|
|
1437
|
+
})
|
|
1438
|
+
);
|
|
1110
1439
|
}
|
|
1111
1440
|
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
1112
1441
|
const res = await this.http.post(
|
|
@@ -1149,17 +1478,22 @@ var Playmos = class {
|
|
|
1149
1478
|
const metadata = validateMetadata(input.metadata);
|
|
1150
1479
|
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
1151
1480
|
if (this.config.mock) {
|
|
1152
|
-
return
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1481
|
+
return this.rememberMock(
|
|
1482
|
+
mockEntryPayment({
|
|
1483
|
+
amountMicro,
|
|
1484
|
+
poolBps: POOL_BPS,
|
|
1485
|
+
seedBps: SEED_BPS,
|
|
1486
|
+
rakeBps: RAKE_BPS,
|
|
1487
|
+
gameId: input.gameId,
|
|
1488
|
+
roundId: input.roundId,
|
|
1489
|
+
playerId: input.playerId,
|
|
1490
|
+
chain: this.env.network,
|
|
1491
|
+
metadata,
|
|
1492
|
+
// B2 pins — must survive mock path for hub hasEntered parity (#204).
|
|
1493
|
+
roundKey: input.roundKey,
|
|
1494
|
+
identity: input.identity
|
|
1495
|
+
})
|
|
1496
|
+
);
|
|
1163
1497
|
}
|
|
1164
1498
|
if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
|
|
1165
1499
|
const res = await this.http.post(
|
|
@@ -1171,7 +1505,7 @@ var Playmos = class {
|
|
|
1171
1505
|
if (input.identity) payment2.identity = input.identity;
|
|
1172
1506
|
const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
|
|
1173
1507
|
if (pool) payment2.prizePoolAddress = pool.toLowerCase();
|
|
1174
|
-
payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
|
|
1508
|
+
payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
|
|
1175
1509
|
return payment2;
|
|
1176
1510
|
}
|
|
1177
1511
|
const intent = await this.http.post(
|
|
@@ -1266,9 +1600,41 @@ var Playmos = class {
|
|
|
1266
1600
|
/** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
|
|
1267
1601
|
async verify(paymentId) {
|
|
1268
1602
|
requireField(paymentId, "paymentId");
|
|
1603
|
+
if (this.config.mock) {
|
|
1604
|
+
const cached = this.mockPayments.get(paymentId);
|
|
1605
|
+
if (!cached) {
|
|
1606
|
+
throw new ApiError(`payment not found: ${paymentId}`, { status: 404, code: "not_found" });
|
|
1607
|
+
}
|
|
1608
|
+
const m = mockVerifyResult(cached);
|
|
1609
|
+
return {
|
|
1610
|
+
id: m.id,
|
|
1611
|
+
status: m.status,
|
|
1612
|
+
amount: m.amount,
|
|
1613
|
+
fee: m.fee,
|
|
1614
|
+
net: m.net,
|
|
1615
|
+
txHash: m.txHash,
|
|
1616
|
+
playerId: m.playerId ?? "",
|
|
1617
|
+
sku: m.sku,
|
|
1618
|
+
roundId: m.roundId,
|
|
1619
|
+
chain: m.chain,
|
|
1620
|
+
verifiedVia: "cache"
|
|
1621
|
+
};
|
|
1622
|
+
}
|
|
1269
1623
|
return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
|
|
1270
1624
|
}
|
|
1271
1625
|
// ---- internals ---------------------------------------------------------
|
|
1626
|
+
/**
|
|
1627
|
+
* x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
|
|
1628
|
+
* network call so mainnet / live keys never silently hit the adapter.
|
|
1629
|
+
*/
|
|
1630
|
+
assertX402Allowed() {
|
|
1631
|
+
if (!this.env.isTest || this.env.network !== "base-sepolia") {
|
|
1632
|
+
throw new ConfigError(
|
|
1633
|
+
'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
|
|
1634
|
+
{ network: this.env.network, isTest: this.env.isTest }
|
|
1635
|
+
);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1272
1638
|
/**
|
|
1273
1639
|
* Map a server-settled payment (from the `settle: "server"` response) into the
|
|
1274
1640
|
* SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
|
|
@@ -1449,10 +1815,10 @@ var PayoutError = class extends Error {
|
|
|
1449
1815
|
this.name = "PayoutError";
|
|
1450
1816
|
}
|
|
1451
1817
|
};
|
|
1452
|
-
var
|
|
1818
|
+
var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
|
|
1453
1819
|
var BPS = 10000n;
|
|
1454
|
-
function
|
|
1455
|
-
if (!
|
|
1820
|
+
function requireAddress2(w, i) {
|
|
1821
|
+
if (!ADDRESS_RE3.test(w)) {
|
|
1456
1822
|
throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
|
|
1457
1823
|
}
|
|
1458
1824
|
return w.toLowerCase();
|
|
@@ -1475,7 +1841,7 @@ function computePayout(pool, ranking, rule) {
|
|
|
1475
1841
|
if (!Array.isArray(ranking) || ranking.length === 0) {
|
|
1476
1842
|
throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
|
|
1477
1843
|
}
|
|
1478
|
-
const wallets = ranking.map((w, i) =>
|
|
1844
|
+
const wallets = ranking.map((w, i) => requireAddress2(w, i));
|
|
1479
1845
|
const seen = /* @__PURE__ */ new Set();
|
|
1480
1846
|
for (const w of wallets) {
|
|
1481
1847
|
if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
|
|
@@ -1548,13 +1914,13 @@ function computePayout(pool, ranking, rule) {
|
|
|
1548
1914
|
}
|
|
1549
1915
|
|
|
1550
1916
|
// src/settlement.ts
|
|
1551
|
-
var
|
|
1917
|
+
var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
|
|
1552
1918
|
var DEFAULT_TTL_MS = 15 * 60 * 1e3;
|
|
1553
|
-
function
|
|
1919
|
+
function requireAddress3(value, field) {
|
|
1554
1920
|
if (typeof value !== "string" || value.trim() === "") {
|
|
1555
1921
|
throw new MissingFieldError(field);
|
|
1556
1922
|
}
|
|
1557
|
-
if (!
|
|
1923
|
+
if (!ADDRESS_RE4.test(value)) {
|
|
1558
1924
|
throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
|
|
1559
1925
|
field,
|
|
1560
1926
|
value
|
|
@@ -1571,7 +1937,7 @@ function requireNetwork(value) {
|
|
|
1571
1937
|
}
|
|
1572
1938
|
function createPaymentRequirement(input) {
|
|
1573
1939
|
const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
1574
|
-
const payTo =
|
|
1940
|
+
const payTo = requireAddress3(input.payTo, "payTo");
|
|
1575
1941
|
const network = requireNetwork(input.network);
|
|
1576
1942
|
const asset = input.asset ?? "USDC";
|
|
1577
1943
|
if (asset !== "USDC") {
|
|
@@ -1614,7 +1980,7 @@ function parsePaymentRequirement(input) {
|
|
|
1614
1980
|
asset: o.asset
|
|
1615
1981
|
});
|
|
1616
1982
|
}
|
|
1617
|
-
const payTo =
|
|
1983
|
+
const payTo = requireAddress3(o.payTo, "payTo");
|
|
1618
1984
|
const network = requireNetwork(o.network);
|
|
1619
1985
|
if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
|
|
1620
1986
|
parseUsdToMicro(o.amount);
|
|
@@ -1661,9 +2027,13 @@ exports.computeIapSplit = computeIapSplit;
|
|
|
1661
2027
|
exports.computePayout = computePayout;
|
|
1662
2028
|
exports.computePoolSplit = computePoolSplit;
|
|
1663
2029
|
exports.createPaymentRequirement = createPaymentRequirement;
|
|
2030
|
+
exports.createX402Challenge = createX402Challenge;
|
|
2031
|
+
exports.decodePaymentHeader = decodePaymentHeader;
|
|
2032
|
+
exports.encodePaymentHeader = encodePaymentHeader;
|
|
1664
2033
|
exports.formatMicroToUsd = formatMicroToUsd;
|
|
1665
2034
|
exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
|
|
1666
2035
|
exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
|
|
2036
|
+
exports.networkToCaip2 = networkToCaip2;
|
|
1667
2037
|
exports.parsePaymentRequirement = parsePaymentRequirement;
|
|
1668
2038
|
exports.parseUsdToMicro = parseUsdToMicro;
|
|
1669
2039
|
exports.prefixedId = prefixedId;
|
|
@@ -1673,6 +2043,8 @@ exports.previewMarketplaceSplit = previewMarketplaceSplit;
|
|
|
1673
2043
|
exports.previewPoolSplit = previewPoolSplit;
|
|
1674
2044
|
exports.previewTransferSplit = previewTransferSplit;
|
|
1675
2045
|
exports.serializePaymentRequirement = serializePaymentRequirement;
|
|
2046
|
+
exports.toX402PaymentRequired = toX402PaymentRequired;
|
|
1676
2047
|
exports.ulid = ulid;
|
|
2048
|
+
exports.validateX402ChallengeInput = validateX402ChallengeInput;
|
|
1677
2049
|
//# sourceMappingURL=index.cjs.map
|
|
1678
2050
|
//# sourceMappingURL=index.cjs.map
|