@playmos/sdk 0.3.3 → 0.3.4

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/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError, AuthError } from './chunk-TMBBEGIF.js';
2
- export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-TMBBEGIF.js';
1
+ import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError } from './chunk-VYR6BBHF.js';
2
+ export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-VYR6BBHF.js';
3
3
  import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
4
4
 
5
5
  // src/config.ts
@@ -15,9 +15,26 @@ var DEFAULT_API_BASE_URL = {
15
15
  base: "https://api.playmos.io/v1",
16
16
  "base-sepolia": "https://api.sandbox.playmos.io/v1"
17
17
  };
18
+ function clientRuntimeSignals(g = globalThis) {
19
+ const any = g;
20
+ const signals = [];
21
+ if (typeof any.window !== "undefined") signals.push("window");
22
+ if (typeof any.document !== "undefined") signals.push("document");
23
+ const likelyCfWorker = typeof any.Cloudflare !== "undefined" || typeof any.WebSocketPair !== "undefined" && typeof any.window === "undefined";
24
+ if (typeof any.importScripts === "function" && !likelyCfWorker) {
25
+ signals.push("importScripts");
26
+ }
27
+ if (any.navigator?.product === "ReactNative") signals.push("ReactNative");
28
+ return signals;
29
+ }
30
+ function isClientRuntime(g = globalThis) {
31
+ return clientRuntimeSignals(g).length > 0;
32
+ }
18
33
  function resolveEnv(apiKey, networkOverride, apiBaseUrlOverride) {
19
34
  if (typeof apiKey !== "string" || apiKey.trim() === "") {
20
- throw new AuthError("Missing apiKey. Pass a pk_test_\u2026 or pk_live_\u2026 key.");
35
+ throw new AuthError(
36
+ 'Missing apiKey. Expected a "pk_test_", "pk_live_", "sk_test_", or "sk_live_" prefix.'
37
+ );
21
38
  }
22
39
  const isTest = apiKey.startsWith("pk_test_") || apiKey.startsWith("sk_test_");
23
40
  const isLive = apiKey.startsWith("pk_live_") || apiKey.startsWith("sk_live_");
@@ -27,12 +44,14 @@ function resolveEnv(apiKey, networkOverride, apiBaseUrlOverride) {
27
44
  { keyPreview: apiKey.slice(0, 8) }
28
45
  );
29
46
  }
47
+ const isSecret = apiKey.startsWith("sk_");
30
48
  const network = networkOverride ?? (isTest ? "base-sepolia" : "base");
31
49
  return {
32
50
  network,
33
51
  chainId: CHAIN_ID[network],
34
52
  apiBaseUrl: apiBaseUrlOverride ?? DEFAULT_API_BASE_URL[network],
35
- isTest
53
+ isTest,
54
+ isSecret
36
55
  };
37
56
  }
38
57
 
@@ -340,13 +359,6 @@ var erc20Abi = [
340
359
  { name: "amount", type: "uint256" }
341
360
  ],
342
361
  outputs: [{ type: "bool" }]
343
- },
344
- {
345
- type: "function",
346
- name: "balanceOf",
347
- stateMutability: "view",
348
- inputs: [{ name: "account", type: "address" }],
349
- outputs: [{ type: "uint256" }]
350
362
  }
351
363
  ];
352
364
  var playmosPayAbi = [
@@ -363,17 +375,6 @@ var playmosPayAbi = [
363
375
  }
364
376
  ];
365
377
  var prizePoolAbi = [
366
- {
367
- type: "function",
368
- name: "openRound",
369
- stateMutability: "nonpayable",
370
- inputs: [
371
- { name: "roundId", type: "bytes32" },
372
- { name: "series", type: "bytes32" },
373
- { name: "entry", type: "uint256" }
374
- ],
375
- outputs: []
376
- },
377
378
  {
378
379
  type: "function",
379
380
  name: "enter",
@@ -384,50 +385,6 @@ var prizePoolAbi = [
384
385
  ],
385
386
  outputs: []
386
387
  },
387
- {
388
- type: "function",
389
- name: "lockRound",
390
- stateMutability: "nonpayable",
391
- inputs: [{ name: "roundId", type: "bytes32" }],
392
- outputs: []
393
- },
394
- {
395
- type: "function",
396
- name: "settle",
397
- stateMutability: "nonpayable",
398
- inputs: [
399
- { name: "roundId", type: "bytes32" },
400
- { name: "winners", type: "address[]" },
401
- { name: "amounts", type: "uint256[]" }
402
- ],
403
- outputs: []
404
- },
405
- {
406
- type: "function",
407
- name: "getRound",
408
- stateMutability: "view",
409
- inputs: [{ name: "roundId", type: "bytes32" }],
410
- outputs: [
411
- { name: "state", type: "uint8" },
412
- { name: "series", type: "bytes32" },
413
- { name: "entry", type: "uint256" },
414
- { name: "pot", type: "uint256" },
415
- { name: "entrantCount", type: "uint256" },
416
- { name: "payable_", type: "uint256" },
417
- { name: "inheritedSeed", type: "uint256" },
418
- { name: "lockedAt", type: "uint256" }
419
- ]
420
- },
421
- {
422
- type: "function",
423
- name: "hasEntered",
424
- stateMutability: "view",
425
- inputs: [
426
- { name: "roundId", type: "bytes32" },
427
- { name: "identity", type: "bytes32" }
428
- ],
429
- outputs: [{ type: "bool" }]
430
- },
431
388
  {
432
389
  type: "function",
433
390
  name: "withdrawable",
@@ -551,7 +508,7 @@ function buildWithdrawCall(prizePool) {
551
508
  }
552
509
 
553
510
  // src/mock.ts
554
- var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
511
+ var MOCK_TX = `0x${"0".repeat(56)}deadbeef`;
555
512
  function mockIapPayment(args) {
556
513
  const { feeMicro, netMicro } = computeIapSplit(args.amountMicro, args.feeBps);
557
514
  return {
@@ -799,10 +756,16 @@ var Playmos = class {
799
756
  }
800
757
  };
801
758
  this.payouts = {
802
- /** Choose how the studio is paid: "usdc" (default) or "fiat" (Bridge). */
803
- setMode: (mode) => this.http.post("/payouts/mode", { mode }),
804
- /** Create a Bridge KYC onboarding link (fiat payout). */
805
- createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
759
+ /** Choose how the studio is paid: "usdc" (default) or "fiat" (Bridge). Secret key only (#264). */
760
+ setMode: async (mode) => {
761
+ this.assertSecretKey("playmos.payouts.setMode");
762
+ return this.http.post("/payouts/mode", { mode });
763
+ },
764
+ /** Create a Bridge KYC onboarding link (fiat payout). Secret key only (#264). */
765
+ createOnboardingLink: async () => {
766
+ this.assertSecretKey("playmos.payouts.createOnboardingLink");
767
+ return this.http.post("/payouts/onboarding_link", {});
768
+ }
806
769
  };
807
770
  /**
808
771
  * `x402` — Phase 4 HTTP 402 / x402-shaped adapter (#153).
@@ -852,6 +815,10 @@ var Playmos = class {
852
815
  * Settle a previously minted challenge with an `x402-payload` authorization.
853
816
  * Defaults mode to `server-signer` when omitted (explicit on the wire — fail-closed
854
817
  * materialize still requires mode; the route also defaults for this path).
818
+ *
819
+ * #259/#269: settle uses `idempotency-key = requirement.id` and auto-retries gateway 504.
820
+ * A 504 replay may return **without throwing** with `success: false`, `status: "settling"`,
821
+ * `txHash: null` (in-flight). Treat as non-final — re-call or verify later; do not new-key.
855
822
  */
856
823
  fulfill: async (requirement, authorization) => {
857
824
  this.assertX402Allowed();
@@ -885,10 +852,19 @@ var Playmos = class {
885
852
  }
886
853
  if (payload.scheme === void 0) payload.scheme = "exact";
887
854
  if (payload.requirementId === void 0) payload.requirementId = requirement.id;
888
- const res = await this.http.post("/x402/settle", {
889
- requirement,
890
- authorization: { kind: "x402-payload", payload }
891
- });
855
+ const res = await this.http.post(
856
+ "/x402/settle",
857
+ {
858
+ requirement,
859
+ authorization: { kind: "x402-payload", payload }
860
+ },
861
+ // Settlement idempotency anchor = requirement.id (settlement.ts: "Settlement
862
+ // idempotency key + x402 challenge id"). Without this header the settle POST is
863
+ // excluded from the safe gateway-504 auto-retry (isRetryable), and the SDK's own
864
+ // 504 message ("retry with the same idempotency key (safe)") would be a lie —
865
+ // a 504 is exactly when the on-chain settle may have already landed (#259).
866
+ { idempotencyKey: requirement.id }
867
+ );
892
868
  const s = res.settlement;
893
869
  return {
894
870
  requirementId: s.requirementId,
@@ -931,8 +907,11 @@ var Playmos = class {
931
907
  */
932
908
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
933
909
  this.mockRounds = /* @__PURE__ */ new Map();
910
+ /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
911
+ this.mockWithdrawable = /* @__PURE__ */ new Map();
934
912
  this.rounds = {
935
913
  open: async (input) => {
914
+ this.assertSecretKey("playmos.rounds.open");
936
915
  requireField(input?.gameId, "gameId");
937
916
  requireField(input?.roundId, "roundId");
938
917
  requireField(input?.entryAmount, "entryAmount");
@@ -952,7 +931,7 @@ var Playmos = class {
952
931
  code: "conflict"
953
932
  });
954
933
  }
955
- const round2 = {
934
+ const round = {
956
935
  roundId: input.roundId,
957
936
  gameId: input.gameId,
958
937
  roundKey,
@@ -962,24 +941,29 @@ var Playmos = class {
962
941
  entrants: 0,
963
942
  payout: input.payout,
964
943
  closeAt: input.closeAt,
965
- openTxHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
944
+ openTxHash: MOCK_TX,
966
945
  prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
967
946
  };
968
- this.mockRounds.set(input.roundId, round2);
969
- return round2;
947
+ this.mockRounds.set(input.roundId, round);
948
+ return round;
970
949
  }
971
- const { round } = await this.http.post("/rounds", {
972
- gameId: input.gameId,
973
- roundId: input.roundId,
974
- entryAmount: input.entryAmount,
975
- payout: input.payout,
976
- closeAt: input.closeAt,
977
- seriesKey: input.seriesKey,
978
- roundKey: input.roundKey
979
- });
980
- return round;
950
+ const body = await this.http.post(
951
+ "/rounds",
952
+ {
953
+ gameId: input.gameId,
954
+ roundId: input.roundId,
955
+ entryAmount: input.entryAmount,
956
+ payout: input.payout,
957
+ closeAt: input.closeAt,
958
+ seriesKey: input.seriesKey,
959
+ roundKey: input.roundKey
960
+ },
961
+ { acceptStatuses: [202] }
962
+ );
963
+ return body.round;
981
964
  },
982
965
  lock: async (input) => {
966
+ this.assertSecretKey("playmos.rounds.lock");
983
967
  requireField(input?.roundId, "roundId");
984
968
  if (this.config.mock) {
985
969
  const existing = this.mockRounds.get(input.roundId);
@@ -999,7 +983,7 @@ var Playmos = class {
999
983
  // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1000
984
  pool: existing.pool ?? existing.entryAmount,
1001
985
  entrants: existing.entrants ?? 0,
1002
- lockTxHash: "0x000000000000000000000000000000000000000000000000000000000000loc1"
986
+ lockTxHash: MOCK_TX
1003
987
  };
1004
988
  this.mockRounds.set(input.roundId, locked);
1005
989
  return locked;
@@ -1011,6 +995,7 @@ var Playmos = class {
1011
995
  return round;
1012
996
  },
1013
997
  settle: async (input) => {
998
+ this.assertSecretKey("playmos.rounds.settle");
1014
999
  requireField(input?.roundId, "roundId");
1015
1000
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1016
1001
  if (this.config.mock) {
@@ -1041,7 +1026,7 @@ var Playmos = class {
1041
1026
  // Winner-take-all mock shape when ranking only.
1042
1027
  amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1043
1028
  }));
1044
- const settleTxHash = "0x000000000000000000000000000000000000000000000000000000000000set1";
1029
+ const settleTxHash = MOCK_TX;
1045
1030
  const poolPaid = existing.pool ?? existing.entryAmount;
1046
1031
  const settled = {
1047
1032
  ...existing,
@@ -1050,6 +1035,19 @@ var Playmos = class {
1050
1035
  pool: poolPaid
1051
1036
  };
1052
1037
  this.mockRounds.set(input.roundId, settled);
1038
+ for (const w of winners) {
1039
+ const wAddr = String(w.wallet).toLowerCase();
1040
+ let micro = 0n;
1041
+ try {
1042
+ micro = validateAmount(String(w.amount ?? "0"));
1043
+ } catch {
1044
+ micro = 0n;
1045
+ }
1046
+ if (micro > 0n) {
1047
+ const k = `${input.roundId}:${wAddr}`;
1048
+ this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
1049
+ }
1050
+ }
1053
1051
  return {
1054
1052
  roundId: input.roundId,
1055
1053
  txHash: settleTxHash,
@@ -1060,10 +1058,65 @@ var Playmos = class {
1060
1058
  }
1061
1059
  const { settle } = await this.http.post(
1062
1060
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1063
- { gameId: input.gameId, results: input.results }
1061
+ { gameId: input.gameId, results: input.results },
1062
+ { acceptStatuses: [202] }
1064
1063
  );
1065
1064
  return settle;
1066
1065
  },
1066
+ /**
1067
+ * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
1068
+ * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
1069
+ * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1070
+ */
1071
+ cancel: async (input) => {
1072
+ this.assertSecretKey("playmos.rounds.cancel");
1073
+ requireField(input?.roundId, "roundId");
1074
+ if (this.config.mock) {
1075
+ const existing = this.mockRounds.get(input.roundId);
1076
+ if (!existing) {
1077
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1078
+ }
1079
+ if (existing.status === "cancelled") {
1080
+ return {
1081
+ roundId: input.roundId,
1082
+ txHash: existing.cancelTxHash ?? MOCK_TX,
1083
+ status: "cancelled",
1084
+ round: existing
1085
+ };
1086
+ }
1087
+ if (existing.status === "settled") {
1088
+ throw new ApiError(`round ${input.roundId} is already settled \u2014 cannot cancel`, {
1089
+ status: 409,
1090
+ code: "conflict"
1091
+ });
1092
+ }
1093
+ const cancelled = {
1094
+ ...existing,
1095
+ status: "cancelled",
1096
+ cancelTxHash: MOCK_TX,
1097
+ pool: null
1098
+ };
1099
+ this.mockRounds.set(input.roundId, cancelled);
1100
+ return {
1101
+ roundId: input.roundId,
1102
+ txHash: MOCK_TX,
1103
+ status: "cancelled",
1104
+ round: cancelled
1105
+ };
1106
+ }
1107
+ const body = await this.http.post(
1108
+ `/rounds/${encodeURIComponent(input.roundId)}/cancel`,
1109
+ { gameId: input.gameId },
1110
+ { acceptStatuses: [202] }
1111
+ );
1112
+ const status = body.status === "cancelling" || body.round?.status === "cancelling" ? "cancelling" : "cancelled";
1113
+ return {
1114
+ roundId: input.roundId,
1115
+ txHash: body.txHash ?? body.round?.cancelTxHash ?? null,
1116
+ status,
1117
+ round: body.round
1118
+ };
1119
+ },
1067
1120
  get: async (input) => {
1068
1121
  requireField(input?.roundId, "roundId");
1069
1122
  if (this.config.mock) {
@@ -1088,6 +1141,21 @@ var Playmos = class {
1088
1141
  prize: async (input) => {
1089
1142
  requireField(input?.roundId, "roundId");
1090
1143
  const wallet = requireAddressField(input?.wallet, "wallet");
1144
+ if (this.config.mock) {
1145
+ const existing = this.mockRounds.get(input.roundId);
1146
+ if (!existing) {
1147
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1148
+ }
1149
+ const claimableMicro2 = this.mockWithdrawable.get(`${input.roundId}:${wallet.toLowerCase()}`) ?? 0n;
1150
+ const prizePoolAddress2 = input.prizePoolAddress ?? existing.prizePoolAddress ?? "0x0000000000000000000000000000000000000002";
1151
+ return {
1152
+ roundId: input.roundId,
1153
+ prizePoolAddress: prizePoolAddress2,
1154
+ wallet,
1155
+ claimable: formatMicroToUsd(claimableMicro2),
1156
+ claimableMicro: claimableMicro2.toString()
1157
+ };
1158
+ }
1091
1159
  try {
1092
1160
  const res = await this.http.get(
1093
1161
  `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
@@ -1114,11 +1182,24 @@ var Playmos = class {
1114
1182
  */
1115
1183
  withdraw: async (input) => {
1116
1184
  if (this.config.mock) {
1185
+ let amountMicro2 = 0n;
1186
+ let prizePool2 = input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001";
1187
+ if (input.roundId) {
1188
+ const existing = this.mockRounds.get(input.roundId);
1189
+ if (existing?.prizePoolAddress) prizePool2 = existing.prizePoolAddress;
1190
+ for (const [k, v] of this.mockWithdrawable) {
1191
+ if (k.startsWith(`${input.roundId}:`) && v > 0n) {
1192
+ amountMicro2 = v;
1193
+ this.mockWithdrawable.set(k, 0n);
1194
+ break;
1195
+ }
1196
+ }
1197
+ }
1117
1198
  return {
1118
- prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
1119
- amount: "0.00",
1120
- amountMicro: "0",
1121
- txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1199
+ prizePoolAddress: prizePool2,
1200
+ amount: formatMicroToUsd(amountMicro2),
1201
+ amountMicro: amountMicro2.toString(),
1202
+ txHash: MOCK_TX,
1122
1203
  status: "confirmed"
1123
1204
  };
1124
1205
  }
@@ -1193,23 +1274,27 @@ var Playmos = class {
1193
1274
  this.agents = {
1194
1275
  /** Assign (or return) the wallet for a game NPC id. Idempotent — safe to call wherever your NPCs spawn. */
1195
1276
  createWallet: async (input) => {
1277
+ this.assertSecretKey("playmos.agents");
1196
1278
  requireField(input?.agentId, "agentId");
1197
1279
  const { agent } = await this.http.post("/agents/wallets", { agentId: input.agentId });
1198
1280
  return agent;
1199
1281
  },
1200
1282
  /** Resolve one NPC's wallet by your id. */
1201
1283
  wallet: async (agentId) => {
1284
+ this.assertSecretKey("playmos.agents");
1202
1285
  requireField(agentId, "agentId");
1203
1286
  const { agent } = await this.http.get(`/agents/wallets/${encodeURIComponent(agentId)}`);
1204
1287
  return agent;
1205
1288
  },
1206
1289
  /** List the NPC wallets you've assigned in this studio. */
1207
1290
  list: async () => {
1291
+ this.assertSecretKey("playmos.agents");
1208
1292
  const { agents } = await this.http.get("/agents/wallets");
1209
1293
  return agents;
1210
1294
  },
1211
1295
  /** Sandbox faucet: fund an NPC with USDC from your treasury (per-NPC lifetime cap). */
1212
- fund: (input) => {
1296
+ fund: async (input) => {
1297
+ this.assertSecretKey("playmos.agents.fund");
1213
1298
  requireField(input?.agentId, "agentId");
1214
1299
  validateAmount(input.amount);
1215
1300
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
@@ -1220,10 +1305,17 @@ var Playmos = class {
1220
1305
  );
1221
1306
  },
1222
1307
  /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
1223
- pay: (input) => {
1308
+ pay: async (input) => {
1309
+ this.assertSecretKey("playmos.agents.pay");
1224
1310
  requireField(input?.from, "from");
1225
1311
  requireField(input?.to, "to");
1226
- return this.transfer({ from: input.from, to: input.to, amount: input.amount, feeBps: input.feeBps, feeSink: input.feeSink });
1312
+ return this.transfer({
1313
+ from: input.from,
1314
+ to: input.to,
1315
+ amount: input.amount,
1316
+ feeBps: input.feeBps,
1317
+ feeSink: input.feeSink
1318
+ });
1227
1319
  }
1228
1320
  };
1229
1321
  /**
@@ -1407,6 +1499,15 @@ var Playmos = class {
1407
1499
  };
1408
1500
  this.config = config;
1409
1501
  this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
1502
+ if (this.env.isSecret) {
1503
+ const signals = clientRuntimeSignals();
1504
+ if (signals.length > 0) {
1505
+ throw new AuthError(
1506
+ "Secret (sk_) key used in a client runtime \u2014 sk_ keys are server-only; use a pk_ key client-side.",
1507
+ { isSecret: true, clientRuntime: true, signals }
1508
+ );
1509
+ }
1510
+ }
1410
1511
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
1411
1512
  }
1412
1513
  /** Connect the player's wallet and return their address. */
@@ -1434,9 +1535,10 @@ var Playmos = class {
1434
1535
  roundId: req.roundKey,
1435
1536
  roundKey: req.roundKey,
1436
1537
  identity: req.identity,
1437
- amount: formatMicroToUsd(req.entryUnits),
1438
- playerId: req.wallet
1538
+ playerId: req.wallet,
1539
+ amount: formatMicroToUsd(req.entryUnits)
1439
1540
  });
1541
+ this.rememberMock(entry);
1440
1542
  const raw = String(entry.status ?? "pending").toLowerCase();
1441
1543
  const status = raw === "confirmed" ? "confirmed" : raw === "failed" ? "failed" : "pending";
1442
1544
  const onchain = !entry.mock;
@@ -1476,14 +1578,39 @@ var Playmos = class {
1476
1578
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1477
1579
  const res = await this.http.post(
1478
1580
  "/payments",
1479
- { kind: "iap", amount: input.amount, sku: input.sku, playerId: input.playerId, gameId: input.gameId, studio: input.studio, metadata, settle: "server" },
1480
- { idempotencyKey }
1581
+ {
1582
+ kind: "iap",
1583
+ amount: input.amount,
1584
+ sku: input.sku,
1585
+ playerId: input.playerId,
1586
+ gameId: input.gameId,
1587
+ studio: input.studio,
1588
+ metadata,
1589
+ settle: "server",
1590
+ idempotencyKey
1591
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1592
+ },
1593
+ { idempotencyKey, acceptStatuses: [201, 202] }
1481
1594
  );
1482
- return this.mapServerPayment(res.payment, "iap", amountMicro);
1595
+ let payment = this.mapServerPayment(res.payment, "iap", amountMicro);
1596
+ if (payment.status === "pending" || payment.status === "created") {
1597
+ payment = await this.waitForServerSettle(payment.id, "iap", amountMicro);
1598
+ }
1599
+ return payment;
1483
1600
  }
1484
1601
  const intent = await this.http.post(
1485
1602
  "/payments",
1486
- { kind: "iap", amount: input.amount, sku: input.sku, playerId: input.playerId, gameId: input.gameId, studio: input.studio, metadata },
1603
+ {
1604
+ kind: "iap",
1605
+ amount: input.amount,
1606
+ sku: input.sku,
1607
+ playerId: input.playerId,
1608
+ gameId: input.gameId,
1609
+ studio: input.studio,
1610
+ metadata,
1611
+ idempotencyKey
1612
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1613
+ },
1487
1614
  { idempotencyKey }
1488
1615
  );
1489
1616
  const provider = resolveProvider(this.config.wallet);
@@ -1532,23 +1659,48 @@ var Playmos = class {
1532
1659
  );
1533
1660
  }
1534
1661
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1662
+ const pinnedRoundKey = input.roundKey ?? input.roundId;
1535
1663
  const res = await this.http.post(
1536
1664
  "/payments",
1537
- { kind: "entry", amount: input.amount, gameId: input.gameId, roundId: input.roundKey ?? input.roundId, identity: input.identity, playerId: input.playerId, metadata, settle: "server" },
1538
- { idempotencyKey }
1665
+ {
1666
+ kind: "entry",
1667
+ amount: input.amount,
1668
+ gameId: input.gameId,
1669
+ roundId: pinnedRoundKey,
1670
+ identity: input.identity,
1671
+ playerId: input.playerId,
1672
+ metadata,
1673
+ settle: "server",
1674
+ idempotencyKey
1675
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1676
+ },
1677
+ { idempotencyKey, acceptStatuses: [201, 202] }
1539
1678
  );
1540
- const payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
1679
+ let payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
1680
+ if (payment2.status === "pending" || payment2.status === "created") {
1681
+ payment2 = await this.waitForServerSettle(payment2.id, "entry", amountMicro);
1682
+ }
1541
1683
  if (input.identity) payment2.identity = input.identity;
1542
1684
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1543
1685
  if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1544
- payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1686
+ payment2.roundKey = input.roundKey ?? res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1545
1687
  return payment2;
1546
1688
  }
1547
1689
  const intent = await this.http.post(
1548
1690
  "/payments",
1549
1691
  // When the game supplies its own roundKey, that IS the round the service
1550
1692
  // stores + verifies against (so on-chain enter and server hasEntered align).
1551
- { kind: "entry", amount: input.amount, gameId: input.gameId, roundId: input.roundKey ?? input.roundId, identity: input.identity, playerId: input.playerId, metadata },
1693
+ {
1694
+ kind: "entry",
1695
+ amount: input.amount,
1696
+ gameId: input.gameId,
1697
+ roundId: input.roundKey ?? input.roundId,
1698
+ identity: input.identity,
1699
+ playerId: input.playerId,
1700
+ metadata,
1701
+ idempotencyKey
1702
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1703
+ },
1552
1704
  { idempotencyKey }
1553
1705
  );
1554
1706
  const provider = resolveProvider(this.config.wallet);
@@ -1591,6 +1743,7 @@ var Playmos = class {
1591
1743
  * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1592
1744
  */
1593
1745
  async transfer(input, opts) {
1746
+ this.assertSecretKey("playmos.transfer");
1594
1747
  const amountMicro = validateAmount(input.amount);
1595
1748
  const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1596
1749
  const to = partyRef(input.to, "to");
@@ -1660,16 +1813,55 @@ var Playmos = class {
1660
1813
  }
1661
1814
  // ---- internals ---------------------------------------------------------
1662
1815
  /**
1663
- * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1664
- * network call so mainnet / live keys never silently hit the adapter.
1816
+ * Secret (sk_) surfaces agents / transfer / x402. Capability tracks key type
1817
+ * in code, not README prose (#260). pk_ keys are client-safe and refuse these.
1818
+ */
1819
+ assertSecretKey(surface) {
1820
+ if (!this.env.isSecret) {
1821
+ throw new AuthError(
1822
+ `${surface} requires a secret (sk_) key \u2014 use sk_test_\u2026 / sk_live_\u2026 on the server. Publishable (pk_) keys are client-safe and cannot call this surface.`,
1823
+ { surface, isSecret: false }
1824
+ );
1825
+ }
1826
+ }
1827
+ /**
1828
+ * x402 V1 is Base Sepolia + secret test keys only (ADR D5 + #260). Fail closed
1829
+ * before any network call so mainnet / live / publishable keys never hit the adapter.
1830
+ * Does not change fulfill/settle body shape (A owns #261 / #259 money path).
1665
1831
  */
1666
1832
  assertX402Allowed() {
1833
+ this.assertSecretKey("playmos.x402");
1667
1834
  if (!this.env.isTest || this.env.network !== "base-sepolia") {
1668
1835
  throw new ConfigError(
1669
- 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1670
- { network: this.env.network, isTest: this.env.isTest }
1836
+ 'playmos.x402 is Base Sepolia / sk_test_ only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1837
+ { network: this.env.network, isTest: this.env.isTest, isSecret: this.env.isSecret }
1838
+ );
1839
+ }
1840
+ }
1841
+ /**
1842
+ * Poll GET /payments/:id after server-settle until terminal status.
1843
+ * - Entry (#221): service may return 202 pending (openRound+enter > DO edge).
1844
+ * - IAP: service often returns 201 + txHash while status is still "created"
1845
+ * until chain verify catches PaymentSettled (RPC lag) — poll so pay() matches
1846
+ * the documented "confirmed" sandbox promise (TTFSC-T0 / cold-run).
1847
+ */
1848
+ async waitForServerSettle(paymentId, kind, amountMicro, timeoutMs = 6e4) {
1849
+ const start = Date.now();
1850
+ let delayMs = 400;
1851
+ while (Date.now() - start < timeoutMs) {
1852
+ const raw = await this.http.get(
1853
+ `/payments/${encodeURIComponent(paymentId)}`
1671
1854
  );
1855
+ if (raw.status === "confirmed" || raw.status === "failed") {
1856
+ return this.mapServerPayment(raw, kind, amountMicro);
1857
+ }
1858
+ await new Promise((r) => setTimeout(r, delayMs));
1859
+ delayMs = Math.min(Math.floor(delayMs * 1.4), 2e3);
1672
1860
  }
1861
+ throw new ApiError(
1862
+ `Timed out after ${timeoutMs}ms waiting for server-settle of ${paymentId}. Re-check with playmos.verify("${paymentId}").`,
1863
+ { paymentId, timeout: true, asyncSettle: true }
1864
+ );
1673
1865
  }
1674
1866
  /**
1675
1867
  * Map a server-settled payment (from the `settle: "server"` response) into the
@@ -2042,6 +2234,4 @@ function isX402PayloadAuthorization(auth) {
2042
2234
  return auth.kind === "x402-payload";
2043
2235
  }
2044
2236
 
2045
- export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
2046
- //# sourceMappingURL=index.js.map
2047
- //# sourceMappingURL=index.js.map
2237
+ export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, clientRuntimeSignals, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isClientRuntime, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, resolveEnv, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
package/dist/server.cjs CHANGED
@@ -59,5 +59,3 @@ function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
59
59
 
60
60
  exports.WebhookSignatureError = WebhookSignatureError;
61
61
  exports.verifyWebhook = verifyWebhook;
62
- //# sourceMappingURL=server.cjs.map
63
- //# sourceMappingURL=server.cjs.map