@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.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,
@@ -1001,8 +977,11 @@ var Playmos = class {
1001
977
  */
1002
978
  /** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
1003
979
  this.mockRounds = /* @__PURE__ */ new Map();
980
+ /** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
981
+ this.mockWithdrawable = /* @__PURE__ */ new Map();
1004
982
  this.rounds = {
1005
983
  open: async (input) => {
984
+ this.assertSecretKey("playmos.rounds.open");
1006
985
  requireField(input?.gameId, "gameId");
1007
986
  requireField(input?.roundId, "roundId");
1008
987
  requireField(input?.entryAmount, "entryAmount");
@@ -1022,7 +1001,7 @@ var Playmos = class {
1022
1001
  code: "conflict"
1023
1002
  });
1024
1003
  }
1025
- const round2 = {
1004
+ const round = {
1026
1005
  roundId: input.roundId,
1027
1006
  gameId: input.gameId,
1028
1007
  roundKey,
@@ -1032,24 +1011,29 @@ var Playmos = class {
1032
1011
  entrants: 0,
1033
1012
  payout: input.payout,
1034
1013
  closeAt: input.closeAt,
1035
- openTxHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1014
+ openTxHash: MOCK_TX,
1036
1015
  prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
1037
1016
  };
1038
- this.mockRounds.set(input.roundId, round2);
1039
- return round2;
1017
+ this.mockRounds.set(input.roundId, round);
1018
+ return round;
1040
1019
  }
1041
- const { round } = await this.http.post("/rounds", {
1042
- gameId: input.gameId,
1043
- roundId: input.roundId,
1044
- entryAmount: input.entryAmount,
1045
- payout: input.payout,
1046
- closeAt: input.closeAt,
1047
- seriesKey: input.seriesKey,
1048
- roundKey: input.roundKey
1049
- });
1050
- return round;
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;
1051
1034
  },
1052
1035
  lock: async (input) => {
1036
+ this.assertSecretKey("playmos.rounds.lock");
1053
1037
  requireField(input?.roundId, "roundId");
1054
1038
  if (this.config.mock) {
1055
1039
  const existing = this.mockRounds.get(input.roundId);
@@ -1069,7 +1053,7 @@ var Playmos = class {
1069
1053
  // Mock payable pool ≈ 60% of entry × max(1, entrants) for shape only — not money truth.
1070
1054
  pool: existing.pool ?? existing.entryAmount,
1071
1055
  entrants: existing.entrants ?? 0,
1072
- lockTxHash: "0x000000000000000000000000000000000000000000000000000000000000loc1"
1056
+ lockTxHash: MOCK_TX
1073
1057
  };
1074
1058
  this.mockRounds.set(input.roundId, locked);
1075
1059
  return locked;
@@ -1081,6 +1065,7 @@ var Playmos = class {
1081
1065
  return round;
1082
1066
  },
1083
1067
  settle: async (input) => {
1068
+ this.assertSecretKey("playmos.rounds.settle");
1084
1069
  requireField(input?.roundId, "roundId");
1085
1070
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1086
1071
  if (this.config.mock) {
@@ -1111,7 +1096,7 @@ var Playmos = class {
1111
1096
  // Winner-take-all mock shape when ranking only.
1112
1097
  amount: i === 0 ? existing.pool ?? existing.entryAmount : "0"
1113
1098
  }));
1114
- const settleTxHash = "0x000000000000000000000000000000000000000000000000000000000000set1";
1099
+ const settleTxHash = MOCK_TX;
1115
1100
  const poolPaid = existing.pool ?? existing.entryAmount;
1116
1101
  const settled = {
1117
1102
  ...existing,
@@ -1120,6 +1105,19 @@ var Playmos = class {
1120
1105
  pool: poolPaid
1121
1106
  };
1122
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
+ }
1123
1121
  return {
1124
1122
  roundId: input.roundId,
1125
1123
  txHash: settleTxHash,
@@ -1130,10 +1128,65 @@ var Playmos = class {
1130
1128
  }
1131
1129
  const { settle } = await this.http.post(
1132
1130
  `/rounds/${encodeURIComponent(input.roundId)}/settle`,
1133
- { gameId: input.gameId, results: input.results }
1131
+ { gameId: input.gameId, results: input.results },
1132
+ { acceptStatuses: [202] }
1134
1133
  );
1135
1134
  return settle;
1136
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
+ },
1137
1190
  get: async (input) => {
1138
1191
  requireField(input?.roundId, "roundId");
1139
1192
  if (this.config.mock) {
@@ -1158,6 +1211,21 @@ var Playmos = class {
1158
1211
  prize: async (input) => {
1159
1212
  requireField(input?.roundId, "roundId");
1160
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
+ }
1161
1229
  try {
1162
1230
  const res = await this.http.get(
1163
1231
  `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
@@ -1184,11 +1252,24 @@ var Playmos = class {
1184
1252
  */
1185
1253
  withdraw: async (input) => {
1186
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
+ }
1187
1268
  return {
1188
- prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
1189
- amount: "0.00",
1190
- amountMicro: "0",
1191
- txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
1269
+ prizePoolAddress: prizePool2,
1270
+ amount: formatMicroToUsd(amountMicro2),
1271
+ amountMicro: amountMicro2.toString(),
1272
+ txHash: MOCK_TX,
1192
1273
  status: "confirmed"
1193
1274
  };
1194
1275
  }
@@ -1263,23 +1344,27 @@ var Playmos = class {
1263
1344
  this.agents = {
1264
1345
  /** Assign (or return) the wallet for a game NPC id. Idempotent — safe to call wherever your NPCs spawn. */
1265
1346
  createWallet: async (input) => {
1347
+ this.assertSecretKey("playmos.agents");
1266
1348
  requireField(input?.agentId, "agentId");
1267
1349
  const { agent } = await this.http.post("/agents/wallets", { agentId: input.agentId });
1268
1350
  return agent;
1269
1351
  },
1270
1352
  /** Resolve one NPC's wallet by your id. */
1271
1353
  wallet: async (agentId) => {
1354
+ this.assertSecretKey("playmos.agents");
1272
1355
  requireField(agentId, "agentId");
1273
1356
  const { agent } = await this.http.get(`/agents/wallets/${encodeURIComponent(agentId)}`);
1274
1357
  return agent;
1275
1358
  },
1276
1359
  /** List the NPC wallets you've assigned in this studio. */
1277
1360
  list: async () => {
1361
+ this.assertSecretKey("playmos.agents");
1278
1362
  const { agents } = await this.http.get("/agents/wallets");
1279
1363
  return agents;
1280
1364
  },
1281
1365
  /** Sandbox faucet: fund an NPC with USDC from your treasury (per-NPC lifetime cap). */
1282
- fund: (input) => {
1366
+ fund: async (input) => {
1367
+ this.assertSecretKey("playmos.agents.fund");
1283
1368
  requireField(input?.agentId, "agentId");
1284
1369
  validateAmount(input.amount);
1285
1370
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
@@ -1290,10 +1375,17 @@ var Playmos = class {
1290
1375
  );
1291
1376
  },
1292
1377
  /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
1293
- pay: (input) => {
1378
+ pay: async (input) => {
1379
+ this.assertSecretKey("playmos.agents.pay");
1294
1380
  requireField(input?.from, "from");
1295
1381
  requireField(input?.to, "to");
1296
- 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
+ });
1297
1389
  }
1298
1390
  };
1299
1391
  /**
@@ -1477,6 +1569,15 @@ var Playmos = class {
1477
1569
  };
1478
1570
  this.config = config;
1479
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
+ }
1480
1581
  this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
1481
1582
  }
1482
1583
  /** Connect the player's wallet and return their address. */
@@ -1504,9 +1605,10 @@ var Playmos = class {
1504
1605
  roundId: req.roundKey,
1505
1606
  roundKey: req.roundKey,
1506
1607
  identity: req.identity,
1507
- amount: formatMicroToUsd(req.entryUnits),
1508
- playerId: req.wallet
1608
+ playerId: req.wallet,
1609
+ amount: formatMicroToUsd(req.entryUnits)
1509
1610
  });
1611
+ this.rememberMock(entry);
1510
1612
  const raw = String(entry.status ?? "pending").toLowerCase();
1511
1613
  const status = raw === "confirmed" ? "confirmed" : raw === "failed" ? "failed" : "pending";
1512
1614
  const onchain = !entry.mock;
@@ -1546,14 +1648,39 @@ var Playmos = class {
1546
1648
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1547
1649
  const res = await this.http.post(
1548
1650
  "/payments",
1549
- { kind: "iap", amount: input.amount, sku: input.sku, playerId: input.playerId, gameId: input.gameId, studio: input.studio, metadata, settle: "server" },
1550
- { 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] }
1551
1664
  );
1552
- 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;
1553
1670
  }
1554
1671
  const intent = await this.http.post(
1555
1672
  "/payments",
1556
- { 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
+ },
1557
1684
  { idempotencyKey }
1558
1685
  );
1559
1686
  const provider = resolveProvider(this.config.wallet);
@@ -1602,23 +1729,48 @@ var Playmos = class {
1602
1729
  );
1603
1730
  }
1604
1731
  if (this.env.isTest && !await walletAvailable(this.config.wallet)) {
1732
+ const pinnedRoundKey = input.roundKey ?? input.roundId;
1605
1733
  const res = await this.http.post(
1606
1734
  "/payments",
1607
- { kind: "entry", amount: input.amount, gameId: input.gameId, roundId: input.roundKey ?? input.roundId, identity: input.identity, playerId: input.playerId, metadata, settle: "server" },
1608
- { 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] }
1609
1748
  );
1610
- 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
+ }
1611
1753
  if (input.identity) payment2.identity = input.identity;
1612
1754
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1613
1755
  if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1614
- 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;
1615
1757
  return payment2;
1616
1758
  }
1617
1759
  const intent = await this.http.post(
1618
1760
  "/payments",
1619
1761
  // When the game supplies its own roundKey, that IS the round the service
1620
1762
  // stores + verifies against (so on-chain enter and server hasEntered align).
1621
- { 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
+ },
1622
1774
  { idempotencyKey }
1623
1775
  );
1624
1776
  const provider = resolveProvider(this.config.wallet);
@@ -1661,6 +1813,7 @@ var Playmos = class {
1661
1813
  * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1662
1814
  */
1663
1815
  async transfer(input, opts) {
1816
+ this.assertSecretKey("playmos.transfer");
1664
1817
  const amountMicro = validateAmount(input.amount);
1665
1818
  const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1666
1819
  const to = partyRef(input.to, "to");
@@ -1730,16 +1883,55 @@ var Playmos = class {
1730
1883
  }
1731
1884
  // ---- internals ---------------------------------------------------------
1732
1885
  /**
1733
- * x402 V1 is Base Sepolia + test keys only (ADR D5). Fail closed before any
1734
- * network call so mainnet / live keys never silently hit the adapter.
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).
1735
1901
  */
1736
1902
  assertX402Allowed() {
1903
+ this.assertSecretKey("playmos.x402");
1737
1904
  if (!this.env.isTest || this.env.network !== "base-sepolia") {
1738
1905
  throw new ConfigError(
1739
- 'playmos.x402 is Base Sepolia / test-key only in V1 (ADR D5). Use apiKey sk_test_\u2026 and network "base-sepolia".',
1740
- { network: this.env.network, isTest: this.env.isTest }
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)}`
1741
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);
1742
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
+ );
1743
1935
  }
1744
1936
  /**
1745
1937
  * Map a server-settled payment (from the `settle: "server"` response) into the
@@ -2129,6 +2321,7 @@ exports.PlaymosError = PlaymosError;
2129
2321
  exports.USDC_ADDRESS = USDC_ADDRESS;
2130
2322
  exports.USDC_DECIMALS = USDC_DECIMALS;
2131
2323
  exports.WalletConnectionError = WalletConnectionError;
2324
+ exports.clientRuntimeSignals = clientRuntimeSignals;
2132
2325
  exports.computeIapSplit = computeIapSplit;
2133
2326
  exports.computePayout = computePayout;
2134
2327
  exports.computePoolSplit = computePoolSplit;
@@ -2137,6 +2330,7 @@ exports.createX402Challenge = createX402Challenge;
2137
2330
  exports.decodePaymentHeader = decodePaymentHeader;
2138
2331
  exports.encodePaymentHeader = encodePaymentHeader;
2139
2332
  exports.formatMicroToUsd = formatMicroToUsd;
2333
+ exports.isClientRuntime = isClientRuntime;
2140
2334
  exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
2141
2335
  exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
2142
2336
  exports.networkToCaip2 = networkToCaip2;
@@ -2148,9 +2342,8 @@ exports.previewIapSplit = previewIapSplit;
2148
2342
  exports.previewMarketplaceSplit = previewMarketplaceSplit;
2149
2343
  exports.previewPoolSplit = previewPoolSplit;
2150
2344
  exports.previewTransferSplit = previewTransferSplit;
2345
+ exports.resolveEnv = resolveEnv;
2151
2346
  exports.serializePaymentRequirement = serializePaymentRequirement;
2152
2347
  exports.toX402PaymentRequired = toX402PaymentRequired;
2153
2348
  exports.ulid = ulid;
2154
2349
  exports.validateX402ChallengeInput = validateX402ChallengeInput;
2155
- //# sourceMappingURL=index.cjs.map
2156
- //# sourceMappingURL=index.cjs.map