@playmos/sdk 0.3.2 → 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.cjs CHANGED
@@ -85,9 +85,26 @@ var DEFAULT_API_BASE_URL = {
85
85
  base: "https://api.playmos.io/v1",
86
86
  "base-sepolia": "https://api.sandbox.playmos.io/v1"
87
87
  };
88
+ function clientRuntimeSignals(g = globalThis) {
89
+ const any = g;
90
+ const signals = [];
91
+ if (typeof any.window !== "undefined") signals.push("window");
92
+ if (typeof any.document !== "undefined") signals.push("document");
93
+ const likelyCfWorker = typeof any.Cloudflare !== "undefined" || typeof any.WebSocketPair !== "undefined" && typeof any.window === "undefined";
94
+ if (typeof any.importScripts === "function" && !likelyCfWorker) {
95
+ signals.push("importScripts");
96
+ }
97
+ if (any.navigator?.product === "ReactNative") signals.push("ReactNative");
98
+ return signals;
99
+ }
100
+ function isClientRuntime(g = globalThis) {
101
+ return clientRuntimeSignals(g).length > 0;
102
+ }
88
103
  function resolveEnv(apiKey, networkOverride, apiBaseUrlOverride) {
89
104
  if (typeof apiKey !== "string" || apiKey.trim() === "") {
90
- throw new AuthError("Missing apiKey. Pass a pk_test_\u2026 or pk_live_\u2026 key.");
105
+ throw new AuthError(
106
+ 'Missing apiKey. Expected a "pk_test_", "pk_live_", "sk_test_", or "sk_live_" prefix.'
107
+ );
91
108
  }
92
109
  const isTest = apiKey.startsWith("pk_test_") || apiKey.startsWith("sk_test_");
93
110
  const isLive = apiKey.startsWith("pk_live_") || apiKey.startsWith("sk_live_");
@@ -97,12 +114,14 @@ function resolveEnv(apiKey, networkOverride, apiBaseUrlOverride) {
97
114
  { keyPreview: apiKey.slice(0, 8) }
98
115
  );
99
116
  }
117
+ const isSecret = apiKey.startsWith("sk_");
100
118
  const network = networkOverride ?? (isTest ? "base-sepolia" : "base");
101
119
  return {
102
120
  network,
103
121
  chainId: CHAIN_ID[network],
104
122
  apiBaseUrl: apiBaseUrlOverride ?? DEFAULT_API_BASE_URL[network],
105
- isTest
123
+ isTest,
124
+ isSecret
106
125
  };
107
126
  }
108
127
 
@@ -410,13 +429,6 @@ var erc20Abi = [
410
429
  { name: "amount", type: "uint256" }
411
430
  ],
412
431
  outputs: [{ type: "bool" }]
413
- },
414
- {
415
- type: "function",
416
- name: "balanceOf",
417
- stateMutability: "view",
418
- inputs: [{ name: "account", type: "address" }],
419
- outputs: [{ type: "uint256" }]
420
432
  }
421
433
  ];
422
434
  var playmosPayAbi = [
@@ -433,17 +445,6 @@ var playmosPayAbi = [
433
445
  }
434
446
  ];
435
447
  var prizePoolAbi = [
436
- {
437
- type: "function",
438
- name: "openRound",
439
- stateMutability: "nonpayable",
440
- inputs: [
441
- { name: "roundId", type: "bytes32" },
442
- { name: "series", type: "bytes32" },
443
- { name: "entry", type: "uint256" }
444
- ],
445
- outputs: []
446
- },
447
448
  {
448
449
  type: "function",
449
450
  name: "enter",
@@ -454,50 +455,6 @@ var prizePoolAbi = [
454
455
  ],
455
456
  outputs: []
456
457
  },
457
- {
458
- type: "function",
459
- name: "lockRound",
460
- stateMutability: "nonpayable",
461
- inputs: [{ name: "roundId", type: "bytes32" }],
462
- outputs: []
463
- },
464
- {
465
- type: "function",
466
- name: "settle",
467
- stateMutability: "nonpayable",
468
- inputs: [
469
- { name: "roundId", type: "bytes32" },
470
- { name: "winners", type: "address[]" },
471
- { name: "amounts", type: "uint256[]" }
472
- ],
473
- outputs: []
474
- },
475
- {
476
- type: "function",
477
- name: "getRound",
478
- stateMutability: "view",
479
- inputs: [{ name: "roundId", type: "bytes32" }],
480
- outputs: [
481
- { name: "state", type: "uint8" },
482
- { name: "series", type: "bytes32" },
483
- { name: "entry", type: "uint256" },
484
- { name: "pot", type: "uint256" },
485
- { name: "entrantCount", type: "uint256" },
486
- { name: "payable_", type: "uint256" },
487
- { name: "inheritedSeed", type: "uint256" },
488
- { name: "lockedAt", type: "uint256" }
489
- ]
490
- },
491
- {
492
- type: "function",
493
- name: "hasEntered",
494
- stateMutability: "view",
495
- inputs: [
496
- { name: "roundId", type: "bytes32" },
497
- { name: "identity", type: "bytes32" }
498
- ],
499
- outputs: [{ type: "bool" }]
500
- },
501
458
  {
502
459
  type: "function",
503
460
  name: "withdrawable",
@@ -621,7 +578,7 @@ function buildWithdrawCall(prizePool) {
621
578
  }
622
579
 
623
580
  // src/mock.ts
624
- var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
581
+ var MOCK_TX = `0x${"0".repeat(56)}deadbeef`;
625
582
  function mockIapPayment(args) {
626
583
  const { feeMicro, netMicro } = computeIapSplit(args.amountMicro, args.feeBps);
627
584
  return {
@@ -869,10 +826,16 @@ var Playmos = class {
869
826
  }
870
827
  };
871
828
  this.payouts = {
872
- /** Choose how the studio is paid: "usdc" (default) or "fiat" (Bridge). */
873
- setMode: (mode) => this.http.post("/payouts/mode", { mode }),
874
- /** Create a Bridge KYC onboarding link (fiat payout). */
875
- createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
829
+ /** Choose how the studio is paid: "usdc" (default) or "fiat" (Bridge). Secret key only (#264). */
830
+ setMode: async (mode) => {
831
+ this.assertSecretKey("playmos.payouts.setMode");
832
+ return this.http.post("/payouts/mode", { mode });
833
+ },
834
+ /** Create a Bridge KYC onboarding link (fiat payout). Secret key only (#264). */
835
+ createOnboardingLink: async () => {
836
+ this.assertSecretKey("playmos.payouts.createOnboardingLink");
837
+ return this.http.post("/payouts/onboarding_link", {});
838
+ }
876
839
  };
877
840
  /**
878
841
  * `x402` — Phase 4 HTTP 402 / x402-shaped adapter (#153).
@@ -922,6 +885,10 @@ var Playmos = class {
922
885
  * Settle a previously minted challenge with an `x402-payload` authorization.
923
886
  * Defaults mode to `server-signer` when omitted (explicit on the wire — fail-closed
924
887
  * materialize still requires mode; the route also defaults for this path).
888
+ *
889
+ * #259/#269: settle uses `idempotency-key = requirement.id` and auto-retries gateway 504.
890
+ * A 504 replay may return **without throwing** with `success: false`, `status: "settling"`,
891
+ * `txHash: null` (in-flight). Treat as non-final — re-call or verify later; do not new-key.
925
892
  */
926
893
  fulfill: async (requirement, authorization) => {
927
894
  this.assertX402Allowed();
@@ -955,10 +922,19 @@ var Playmos = class {
955
922
  }
956
923
  if (payload.scheme === void 0) payload.scheme = "exact";
957
924
  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
- });
925
+ const res = await this.http.post(
926
+ "/x402/settle",
927
+ {
928
+ requirement,
929
+ authorization: { kind: "x402-payload", payload }
930
+ },
931
+ // Settlement idempotency anchor = requirement.id (settlement.ts: "Settlement
932
+ // idempotency key + x402 challenge id"). Without this header the settle POST is
933
+ // excluded from the safe gateway-504 auto-retry (isRetryable), and the SDK's own
934
+ // 504 message ("retry with the same idempotency key (safe)") would be a lie —
935
+ // a 504 is exactly when the on-chain settle may have already landed (#259).
936
+ { idempotencyKey: requirement.id }
937
+ );
962
938
  const s = res.settlement;
963
939
  return {
964
940
  requirementId: s.requirementId,
@@ -999,8 +975,13 @@ var Playmos = class {
999
975
  *
1000
976
  * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
1001
977
  */
978
+ /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
979
+ this.mockRounds = /* @__PURE__ */ new Map();
980
+ /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
981
+ this.mockWithdrawable = /* @__PURE__ */ new Map();
1002
982
  this.rounds = {
1003
983
  open: async (input) => {
984
+ this.assertSecretKey("playmos.rounds.open");
1004
985
  requireField(input?.gameId, "gameId");
1005
986
  requireField(input?.roundId, "roundId");
1006
987
  requireField(input?.entryAmount, "entryAmount");
@@ -1008,19 +989,75 @@ var Playmos = class {
1008
989
  throw new ConfigError("payout rule is required (winner-take-all | top-n | custom)");
1009
990
  }
1010
991
  validateAmount(input.entryAmount);
1011
- const { round } = await this.http.post("/rounds", {
1012
- gameId: input.gameId,
1013
- roundId: input.roundId,
1014
- entryAmount: input.entryAmount,
1015
- payout: input.payout,
1016
- closeAt: input.closeAt,
1017
- seriesKey: input.seriesKey,
1018
- roundKey: input.roundKey
1019
- });
1020
- return round;
992
+ if (this.config.mock) {
993
+ const roundKey = input.roundKey ?? input.roundId;
994
+ const existing = this.mockRounds.get(input.roundId);
995
+ if (existing?.status === "open" || existing?.status === "locked") {
996
+ return existing;
997
+ }
998
+ if (existing?.status === "settled") {
999
+ throw new ApiError(`round ${input.roundId} already settled`, {
1000
+ status: 409,
1001
+ code: "conflict"
1002
+ });
1003
+ }
1004
+ const round = {
1005
+ roundId: input.roundId,
1006
+ gameId: input.gameId,
1007
+ roundKey,
1008
+ status: "open",
1009
+ entryAmount: input.entryAmount,
1010
+ pool: null,
1011
+ entrants: 0,
1012
+ payout: input.payout,
1013
+ closeAt: input.closeAt,
1014
+ openTxHash: MOCK_TX,
1015
+ prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1016
+ };
1017
+ this.mockRounds.set(input.roundId, round);
1018
+ return round;
1019
+ }
1020
+ const body = await this.http.post(
1021
+ "/rounds",
1022
+ {
1023
+ gameId: input.gameId,
1024
+ roundId: input.roundId,
1025
+ entryAmount: input.entryAmount,
1026
+ payout: input.payout,
1027
+ closeAt: input.closeAt,
1028
+ seriesKey: input.seriesKey,
1029
+ roundKey: input.roundKey
1030
+ },
1031
+ { acceptStatuses: [202] }
1032
+ );
1033
+ return body.round;
1021
1034
  },
1022
1035
  lock: async (input) => {
1036
+ this.assertSecretKey("playmos.rounds.lock");
1023
1037
  requireField(input?.roundId, "roundId");
1038
+ if (this.config.mock) {
1039
+ const existing = this.mockRounds.get(input.roundId);
1040
+ if (!existing) {
1041
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1042
+ }
1043
+ if (existing.status === "locked" || existing.status === "settled") return existing;
1044
+ if (existing.status !== "open") {
1045
+ throw new ApiError(`round ${input.roundId} is not open (status=${existing.status})`, {
1046
+ status: 409,
1047
+ code: "conflict"
1048
+ });
1049
+ }
1050
+ const locked = {
1051
+ ...existing,
1052
+ status: "locked",
1053
+ // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1054
+ pool: existing.pool ?? existing.entryAmount,
1055
+ entrants: existing.entrants ?? 0,
1056
+ lockTxHash: MOCK_TX
1057
+ };
1058
+ this.mockRounds.set(input.roundId, locked);
1059
+ return locked;
1060
+ }
1024
1061
  const { round } = await this.http.post(
1025
1062
  `/rounds/${encodeURIComponent(input.roundId)}/lock`,
1026
1063
  { gameId: input.gameId }
@@ -1028,16 +1065,137 @@ var Playmos = class {
1028
1065
  return round;
1029
1066
  },
1030
1067
  settle: async (input) => {
1068
+ this.assertSecretKey("playmos.rounds.settle");
1031
1069
  requireField(input?.roundId, "roundId");
1032
1070
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1071
+ if (this.config.mock) {
1072
+ const existing = this.mockRounds.get(input.roundId);
1073
+ if (!existing) {
1074
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1075
+ }
1076
+ if (existing.status === "settled" && existing.settleTxHash) {
1077
+ return {
1078
+ roundId: input.roundId,
1079
+ txHash: existing.settleTxHash,
1080
+ poolPaid: existing.pool ?? "0",
1081
+ winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
1082
+ wallet,
1083
+ amount: existing.pool ?? existing.entryAmount
1084
+ })),
1085
+ status: "settled"
1086
+ };
1087
+ }
1088
+ if (existing.status !== "locked" && existing.status !== "open") {
1089
+ throw new ApiError(`round ${input.roundId} cannot settle (status=${existing.status})`, {
1090
+ status: 409,
1091
+ code: "conflict"
1092
+ });
1093
+ }
1094
+ const winners = "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet, i) => ({
1095
+ wallet,
1096
+ // Winner-take-all mock shape when ranking only.
1097
+ amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1098
+ }));
1099
+ const settleTxHash = MOCK_TX;
1100
+ const poolPaid = existing.pool ?? existing.entryAmount;
1101
+ const settled = {
1102
+ ...existing,
1103
+ status: "settled",
1104
+ settleTxHash,
1105
+ pool: poolPaid
1106
+ };
1107
+ this.mockRounds.set(input.roundId, settled);
1108
+ for (const w of winners) {
1109
+ const wAddr = String(w.wallet).toLowerCase();
1110
+ let micro = 0n;
1111
+ try {
1112
+ micro = validateAmount(String(w.amount ?? "0"));
1113
+ } catch {
1114
+ micro = 0n;
1115
+ }
1116
+ if (micro > 0n) {
1117
+ const k = `${input.roundId}:${wAddr}`;
1118
+ this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
1119
+ }
1120
+ }
1121
+ return {
1122
+ roundId: input.roundId,
1123
+ txHash: settleTxHash,
1124
+ poolPaid,
1125
+ winners,
1126
+ status: "settled"
1127
+ };
1128
+ }
1033
1129
  const { settle } = await this.http.post(
1034
1130
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1035
- { gameId: input.gameId, results: input.results }
1131
+ { gameId: input.gameId, results: input.results },
1132
+ { acceptStatuses: [202] }
1036
1133
  );
1037
1134
  return settle;
1038
1135
  },
1136
+ /**
1137
+ * Cancel an open/locked round (operator sk_ only). Refunds entrants' escrowed ~90%
1138
+ * and frees the series latch. May return `status: "cancelling"` (HTTP 202) until
1139
+ * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1140
+ */
1141
+ cancel: async (input) => {
1142
+ this.assertSecretKey("playmos.rounds.cancel");
1143
+ requireField(input?.roundId, "roundId");
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
+ if (existing.status === "cancelled") {
1150
+ return {
1151
+ roundId: input.roundId,
1152
+ txHash: existing.cancelTxHash ?? MOCK_TX,
1153
+ status: "cancelled",
1154
+ round: existing
1155
+ };
1156
+ }
1157
+ if (existing.status === "settled") {
1158
+ throw new ApiError(`round ${input.roundId} is already settled \u2014 cannot cancel`, {
1159
+ status: 409,
1160
+ code: "conflict"
1161
+ });
1162
+ }
1163
+ const cancelled = {
1164
+ ...existing,
1165
+ status: "cancelled",
1166
+ cancelTxHash: MOCK_TX,
1167
+ pool: null
1168
+ };
1169
+ this.mockRounds.set(input.roundId, cancelled);
1170
+ return {
1171
+ roundId: input.roundId,
1172
+ txHash: MOCK_TX,
1173
+ status: "cancelled",
1174
+ round: cancelled
1175
+ };
1176
+ }
1177
+ const body = await this.http.post(
1178
+ `/rounds/${encodeURIComponent(input.roundId)}/cancel`,
1179
+ { gameId: input.gameId },
1180
+ { acceptStatuses: [202] }
1181
+ );
1182
+ const status = body.status === "cancelling" || body.round?.status === "cancelling" ? "cancelling" : "cancelled";
1183
+ return {
1184
+ roundId: input.roundId,
1185
+ txHash: body.txHash ?? body.round?.cancelTxHash ?? null,
1186
+ status,
1187
+ round: body.round
1188
+ };
1189
+ },
1039
1190
  get: async (input) => {
1040
1191
  requireField(input?.roundId, "roundId");
1192
+ if (this.config.mock) {
1193
+ const existing = this.mockRounds.get(input.roundId);
1194
+ if (!existing) {
1195
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1196
+ }
1197
+ return existing;
1198
+ }
1041
1199
  const { round } = await this.http.get(
1042
1200
  `/rounds/${encodeURIComponent(input.roundId)}`
1043
1201
  );
@@ -1053,6 +1211,21 @@ var Playmos = class {
1053
1211
  prize: async (input) => {
1054
1212
  requireField(input?.roundId, "roundId");
1055
1213
  const wallet = requireAddressField(input?.wallet, "wallet");
1214
+ if (this.config.mock) {
1215
+ const existing = this.mockRounds.get(input.roundId);
1216
+ if (!existing) {
1217
+ throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1218
+ }
1219
+ const claimableMicro2 = this.mockWithdrawable.get(`${input.roundId}:${wallet.toLowerCase()}`) ?? 0n;
1220
+ const prizePoolAddress2 = input.prizePoolAddress ?? existing.prizePoolAddress ?? "0x0000000000000000000000000000000000000002";
1221
+ return {
1222
+ roundId: input.roundId,
1223
+ prizePoolAddress: prizePoolAddress2,
1224
+ wallet,
1225
+ claimable: formatMicroToUsd(claimableMicro2),
1226
+ claimableMicro: claimableMicro2.toString()
1227
+ };
1228
+ }
1056
1229
  try {
1057
1230
  const res = await this.http.get(
1058
1231
  `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
@@ -1079,11 +1252,24 @@ var Playmos = class {
1079
1252
  */
1080
1253
  withdraw: async (input) => {
1081
1254
  if (this.config.mock) {
1255
+ let amountMicro2 = 0n;
1256
+ let prizePool2 = input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001";
1257
+ if (input.roundId) {
1258
+ const existing = this.mockRounds.get(input.roundId);
1259
+ if (existing?.prizePoolAddress) prizePool2 = existing.prizePoolAddress;
1260
+ for (const [k, v] of this.mockWithdrawable) {
1261
+ if (k.startsWith(`${input.roundId}:`) && v > 0n) {
1262
+ amountMicro2 = v;
1263
+ this.mockWithdrawable.set(k, 0n);
1264
+ break;
1265
+ }
1266
+ }
1267
+ }
1082
1268
  return {
1083
- prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
1084
- amount: "0.00",
1085
- amountMicro: "0",
1086
- txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1269
+ prizePoolAddress: prizePool2,
1270
+ amount: formatMicroToUsd(amountMicro2),
1271
+ amountMicro: amountMicro2.toString(),
1272
+ txHash: MOCK_TX,
1087
1273
  status: "confirmed"
1088
1274
  };
1089
1275
  }
@@ -1158,23 +1344,27 @@ var Playmos = class {
1158
1344
  this.agents = {
1159
1345
  /** Assign (or return) the wallet for a game NPC id. Idempotent — safe to call wherever your NPCs spawn. */
1160
1346
  createWallet: async (input) => {
1347
+ this.assertSecretKey("playmos.agents");
1161
1348
  requireField(input?.agentId, "agentId");
1162
1349
  const { agent } = await this.http.post("/agents/wallets", { agentId: input.agentId });
1163
1350
  return agent;
1164
1351
  },
1165
1352
  /** Resolve one NPC's wallet by your id. */
1166
1353
  wallet: async (agentId) => {
1354
+ this.assertSecretKey("playmos.agents");
1167
1355
  requireField(agentId, "agentId");
1168
1356
  const { agent } = await this.http.get(`/agents/wallets/${encodeURIComponent(agentId)}`);
1169
1357
  return agent;
1170
1358
  },
1171
1359
  /** List the NPC wallets you've assigned in this studio. */
1172
1360
  list: async () => {
1361
+ this.assertSecretKey("playmos.agents");
1173
1362
  const { agents } = await this.http.get("/agents/wallets");
1174
1363
  return agents;
1175
1364
  },
1176
1365
  /** Sandbox faucet: fund an NPC with USDC from your treasury (per-NPC lifetime cap). */
1177
- fund: (input) => {
1366
+ fund: async (input) => {
1367
+ this.assertSecretKey("playmos.agents.fund");
1178
1368
  requireField(input?.agentId, "agentId");
1179
1369
  validateAmount(input.amount);
1180
1370
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
@@ -1185,10 +1375,17 @@ var Playmos = class {
1185
1375
  );
1186
1376
  },
1187
1377
  /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
1188
- pay: (input) => {
1378
+ pay: async (input) => {
1379
+ this.assertSecretKey("playmos.agents.pay");
1189
1380
  requireField(input?.from, "from");
1190
1381
  requireField(input?.to, "to");
1191
- return this.transfer({ from: input.from, to: input.to, amount: input.amount, feeBps: input.feeBps, feeSink: input.feeSink });
1382
+ return this.transfer({
1383
+ from: input.from,
1384
+ to: input.to,
1385
+ amount: input.amount,
1386
+ feeBps: input.feeBps,
1387
+ feeSink: input.feeSink
1388
+ });
1192
1389
  }
1193
1390
  };
1194
1391
  /**
@@ -1372,6 +1569,15 @@ var Playmos = class {
1372
1569
  };
1373
1570
  this.config = config;
1374
1571
  this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
1572
+ if (this.env.isSecret) {
1573
+ const signals = clientRuntimeSignals();
1574
+ if (signals.length > 0) {
1575
+ throw new AuthError(
1576
+ "Secret (sk_) key used in a client runtime \u2014 sk_ keys are server-only; use a pk_ key client-side.",
1577
+ { isSecret: true, clientRuntime: true, signals }
1578
+ );
1579
+ }
1580
+ }
1375
1581
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
1376
1582
  }
1377
1583
  /** Connect the player's wallet and return their address. */
@@ -1399,15 +1605,17 @@ var Playmos = class {
1399
1605
  roundId: req.roundKey,
1400
1606
  roundKey: req.roundKey,
1401
1607
  identity: req.identity,
1402
- amount: formatMicroToUsd(req.entryUnits),
1403
- playerId: req.wallet
1608
+ playerId: req.wallet,
1609
+ amount: formatMicroToUsd(req.entryUnits)
1404
1610
  });
1405
- const status = entry.status === "confirmed" ? "CONFIRMED" : entry.status === "failed" ? "FAILED" : "PENDING";
1611
+ this.rememberMock(entry);
1612
+ const raw = String(entry.status ?? "pending").toLowerCase();
1613
+ const status = raw === "confirmed" ? "confirmed" : raw === "failed" ? "failed" : "pending";
1406
1614
  const onchain = !entry.mock;
1407
1615
  return {
1408
1616
  paymentId: entry.id,
1409
1617
  status,
1410
- txHash: entry.txHash,
1618
+ ...entry.txHash ? { txHash: entry.txHash } : {},
1411
1619
  onchain,
1412
1620
  identity: entry.identity ?? req.identity
1413
1621
  };
@@ -1440,14 +1648,39 @@ var Playmos = class {
1440
1648
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1441
1649
  const res = await this.http.post(
1442
1650
  "/payments",
1443
- { kind: "iap", amount: input.amount, sku: input.sku, playerId: input.playerId, gameId: input.gameId, studio: input.studio, metadata, settle: "server" },
1444
- { idempotencyKey }
1651
+ {
1652
+ kind: "iap",
1653
+ amount: input.amount,
1654
+ sku: input.sku,
1655
+ playerId: input.playerId,
1656
+ gameId: input.gameId,
1657
+ studio: input.studio,
1658
+ metadata,
1659
+ settle: "server",
1660
+ idempotencyKey
1661
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1662
+ },
1663
+ { idempotencyKey, acceptStatuses: [201, 202] }
1445
1664
  );
1446
- return this.mapServerPayment(res.payment, "iap", amountMicro);
1665
+ let payment = this.mapServerPayment(res.payment, "iap", amountMicro);
1666
+ if (payment.status === "pending" || payment.status === "created") {
1667
+ payment = await this.waitForServerSettle(payment.id, "iap", amountMicro);
1668
+ }
1669
+ return payment;
1447
1670
  }
1448
1671
  const intent = await this.http.post(
1449
1672
  "/payments",
1450
- { kind: "iap", amount: input.amount, sku: input.sku, playerId: input.playerId, gameId: input.gameId, studio: input.studio, metadata },
1673
+ {
1674
+ kind: "iap",
1675
+ amount: input.amount,
1676
+ sku: input.sku,
1677
+ playerId: input.playerId,
1678
+ gameId: input.gameId,
1679
+ studio: input.studio,
1680
+ metadata,
1681
+ idempotencyKey
1682
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1683
+ },
1451
1684
  { idempotencyKey }
1452
1685
  );
1453
1686
  const provider = resolveProvider(this.config.wallet);
@@ -1496,23 +1729,48 @@ var Playmos = class {
1496
1729
  );
1497
1730
  }
1498
1731
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1732
+ const pinnedRoundKey = input.roundKey ?? input.roundId;
1499
1733
  const res = await this.http.post(
1500
1734
  "/payments",
1501
- { kind: "entry", amount: input.amount, gameId: input.gameId, roundId: input.roundKey ?? input.roundId, identity: input.identity, playerId: input.playerId, metadata, settle: "server" },
1502
- { idempotencyKey }
1735
+ {
1736
+ kind: "entry",
1737
+ amount: input.amount,
1738
+ gameId: input.gameId,
1739
+ roundId: pinnedRoundKey,
1740
+ identity: input.identity,
1741
+ playerId: input.playerId,
1742
+ metadata,
1743
+ settle: "server",
1744
+ idempotencyKey
1745
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1746
+ },
1747
+ { idempotencyKey, acceptStatuses: [201, 202] }
1503
1748
  );
1504
- const payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
1749
+ let payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
1750
+ if (payment2.status === "pending" || payment2.status === "created") {
1751
+ payment2 = await this.waitForServerSettle(payment2.id, "entry", amountMicro);
1752
+ }
1505
1753
  if (input.identity) payment2.identity = input.identity;
1506
1754
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1507
1755
  if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1508
- payment2.roundKey = res.clientParams?.roundKey ?? input.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1756
+ payment2.roundKey = input.roundKey ?? res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
1509
1757
  return payment2;
1510
1758
  }
1511
1759
  const intent = await this.http.post(
1512
1760
  "/payments",
1513
1761
  // When the game supplies its own roundKey, that IS the round the service
1514
1762
  // stores + verifies against (so on-chain enter and server hasEntered align).
1515
- { kind: "entry", amount: input.amount, gameId: input.gameId, roundId: input.roundKey ?? input.roundId, identity: input.identity, playerId: input.playerId, metadata },
1763
+ {
1764
+ kind: "entry",
1765
+ amount: input.amount,
1766
+ gameId: input.gameId,
1767
+ roundId: input.roundKey ?? input.roundId,
1768
+ identity: input.identity,
1769
+ playerId: input.playerId,
1770
+ metadata,
1771
+ idempotencyKey
1772
+ /* MUST be in the BODY — the service reads body.idempotencyKey, not the header (#257 P1b) */
1773
+ },
1516
1774
  { idempotencyKey }
1517
1775
  );
1518
1776
  const provider = resolveProvider(this.config.wallet);
@@ -1555,6 +1813,7 @@ var Playmos = class {
1555
1813
  * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1556
1814
  */
1557
1815
  async transfer(input, opts) {
1816
+ this.assertSecretKey("playmos.transfer");
1558
1817
  const amountMicro = validateAmount(input.amount);
1559
1818
  const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1560
1819
  const to = partyRef(input.to, "to");
@@ -1624,16 +1883,55 @@ var Playmos = class {
1624
1883
  }
1625
1884
  // ---- internals ---------------------------------------------------------
1626
1885
  /**
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.
1886
+ * Secret (sk_) surfaces agents / transfer / x402. Capability tracks key type
1887
+ * in code, not README prose (#260). pk_ keys are client-safe and refuse these.
1888
+ */
1889
+ assertSecretKey(surface) {
1890
+ if (!this.env.isSecret) {
1891
+ throw new AuthError(
1892
+ `${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.`,
1893
+ { surface, isSecret: false }
1894
+ );
1895
+ }
1896
+ }
1897
+ /**
1898
+ * x402 V1 is Base Sepolia + secret test keys only (ADR D5 + #260). Fail closed
1899
+ * before any network call so mainnet / live / publishable keys never hit the adapter.
1900
+ * Does not change fulfill/settle body shape (A owns #261 / #259 money path).
1629
1901
  */
1630
1902
  assertX402Allowed() {
1903
+ this.assertSecretKey("playmos.x402");
1631
1904
  if (!this.env.isTest || this.env.network !== "base-sepolia") {
1632
1905
  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 }
1906
+ 'playmos.x402 is Base Sepolia / sk_test_ only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1907
+ { network: this.env.network, isTest: this.env.isTest, isSecret: this.env.isSecret }
1908
+ );
1909
+ }
1910
+ }
1911
+ /**
1912
+ * Poll GET /payments/:id after server-settle until terminal status.
1913
+ * - Entry (#221): service may return 202 pending (openRound+enter > DO edge).
1914
+ * - IAP: service often returns 201 + txHash while status is still "created"
1915
+ * until chain verify catches PaymentSettled (RPC lag) — poll so pay() matches
1916
+ * the documented "confirmed" sandbox promise (TTFSC-T0 / cold-run).
1917
+ */
1918
+ async waitForServerSettle(paymentId, kind, amountMicro, timeoutMs = 6e4) {
1919
+ const start = Date.now();
1920
+ let delayMs = 400;
1921
+ while (Date.now() - start < timeoutMs) {
1922
+ const raw = await this.http.get(
1923
+ `/payments/${encodeURIComponent(paymentId)}`
1635
1924
  );
1925
+ if (raw.status === "confirmed" || raw.status === "failed") {
1926
+ return this.mapServerPayment(raw, kind, amountMicro);
1927
+ }
1928
+ await new Promise((r) => setTimeout(r, delayMs));
1929
+ delayMs = Math.min(Math.floor(delayMs * 1.4), 2e3);
1636
1930
  }
1931
+ throw new ApiError(
1932
+ `Timed out after ${timeoutMs}ms waiting for server-settle of ${paymentId}. Re-check with playmos.verify("${paymentId}").`,
1933
+ { paymentId, timeout: true, asyncSettle: true }
1934
+ );
1637
1935
  }
1638
1936
  /**
1639
1937
  * Map a server-settled payment (from the `settle: "server"` response) into the
@@ -2023,6 +2321,7 @@ exports.PlaymosError = PlaymosError;
2023
2321
  exports.USDC_ADDRESS = USDC_ADDRESS;
2024
2322
  exports.USDC_DECIMALS = USDC_DECIMALS;
2025
2323
  exports.WalletConnectionError = WalletConnectionError;
2324
+ exports.clientRuntimeSignals = clientRuntimeSignals;
2026
2325
  exports.computeIapSplit = computeIapSplit;
2027
2326
  exports.computePayout = computePayout;
2028
2327
  exports.computePoolSplit = computePoolSplit;
@@ -2031,6 +2330,7 @@ exports.createX402Challenge = createX402Challenge;
2031
2330
  exports.decodePaymentHeader = decodePaymentHeader;
2032
2331
  exports.encodePaymentHeader = encodePaymentHeader;
2033
2332
  exports.formatMicroToUsd = formatMicroToUsd;
2333
+ exports.isClientRuntime = isClientRuntime;
2034
2334
  exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
2035
2335
  exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
2036
2336
  exports.networkToCaip2 = networkToCaip2;
@@ -2042,9 +2342,8 @@ exports.previewIapSplit = previewIapSplit;
2042
2342
  exports.previewMarketplaceSplit = previewMarketplaceSplit;
2043
2343
  exports.previewPoolSplit = previewPoolSplit;
2044
2344
  exports.previewTransferSplit = previewTransferSplit;
2345
+ exports.resolveEnv = resolveEnv;
2045
2346
  exports.serializePaymentRequirement = serializePaymentRequirement;
2046
2347
  exports.toX402PaymentRequired = toX402PaymentRequired;
2047
2348
  exports.ulid = ulid;
2048
2349
  exports.validateX402ChallengeInput = validateX402ChallengeInput;
2049
- //# sourceMappingURL=index.cjs.map
2050
- //# sourceMappingURL=index.cjs.map