@playmos/sdk 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,20 +5,6 @@
5
5
  Stablecoin payments for games on Base. One SDK for in-app purchases (1%), skill-game prize-pool entries (10%, 60/30/10), and **in-game economies** (player · NPC · agent commerce via `transfer()`). USD in, USDC on-chain — no crypto UX for your players.
6
6
 
7
7
 
8
- ## Local `file:` / monorepo install
9
-
10
- `@playmos/sdk` ships **built `dist/`**. `prepare` / `prepack` / `prepublishOnly` run
11
- `scripts/ensure-build.cjs`, which rebuilds `dist/` via local `tsup` or (on a cold
12
- `file:` install with no `sdk/node_modules`) `npx --yes tsup@8.3.0`:
13
-
14
- - `npm install` inside `sdk/`
15
- - `npm install` of a **`file:`** / git dependency (dogfood monorepos)
16
- - `npm publish` / pack
17
-
18
- So wiping `dist/` then `npm i file:…/sdk` still yields a current build (#211).
19
- CI also fails if committed `dist/` drifts from source.
20
-
21
-
22
8
  ## Install
23
9
 
24
10
  ```bash
@@ -205,6 +191,26 @@ The full 5-NPC walkthrough — create → fund → P2P / shop / bounty, each con
205
191
  PLAYMOS_SK_TEST=sk_test_… PLAYMOS_FEE_SINK=0x… node docs/examples/playmos-town-sdk.mjs
206
192
  ```
207
193
 
194
+ ## Local `file:` / monorepo install
195
+
196
+ `@playmos/sdk` ships **built `dist/`**. Lifecycle hooks `prepare` / `prepack` / `prepublishOnly`
197
+ run `scripts/ensure-build.cjs` (rebuild via local `tsup` or, on a cold `file:` install with no
198
+ `sdk/node_modules`, `npx --yes tsup@8.3.0`) — **when lifecycle scripts are enabled**.
199
+
200
+ **Repo hardening (sdk#431 / #434):** root + `sdk/` + `service/` + `contracts/` set
201
+ `ignore-scripts=true` in `.npmrc`. That blocks dependency install scripts (supply-chain defense)
202
+ and also suppresses those prepare/prepack/prepublishOnly hooks.
203
+
204
+ | Action | What to do |
205
+ |--------|------------|
206
+ | Day-to-day install in this monorepo | `npm ci` / `npm i` as usual — scripts ignored by design |
207
+ | Rebuild dist after source edits | **`npm run build`** in `sdk/` (user scripts still run) |
208
+ | **Manual** `npm publish` from a laptop | **`cd sdk && npm run build && npm publish`** — do not rely on prepublishOnly |
209
+ | CI publish | Already runs `npm run build` explicitly before pack/publish |
210
+
211
+ So wiping `dist/` then `npm i file:…/sdk` may **not** auto-rebuild under `ignore-scripts`;
212
+ run `npm run build` in `sdk/` first, or use CI. CI also fails if committed `dist/` drifts from source.
213
+
208
214
  ## Mock mode — offline, deterministic
209
215
 
210
216
  For CI and wiring checks, `mock: true` returns instant, deterministic results with **no network and no chain**. Results carry `mock: true` and use the real status union, so your handling code sees the exact production shape.
@@ -215,9 +221,23 @@ const payment = await playmos.pay({
215
221
  gameId: "game_sandbox_iap", sku: "gems_100", amount: "0.99", playerId: "player_abc",
216
222
  });
217
223
  // instant — payment.status === "confirmed", payment.mock === true
224
+
225
+ // Operator lifecycle offline too — no sk_ required when mock:true (#435)
226
+ await playmos.rounds.open({
227
+ gameId: "game_sandbox_skill",
228
+ roundId: "r1",
229
+ entryAmount: "0.25",
230
+ payout: { kind: "winner-take-all" },
231
+ });
232
+ await playmos.rounds.lock({ roundId: "r1" });
233
+ await playmos.rounds.settle({
234
+ roundId: "r1",
235
+ results: { ranking: ["0x0000000000000000000000000000000000000001"] },
236
+ });
218
237
  ```
219
238
 
220
239
  Mock mode is not test mode: mock is fabricated and offline; the sandbox is real and settles on Base Sepolia.
240
+ **Live** `rounds.open` / `lock` / `settle` still need a provisioned secret key on the server.
221
241
 
222
242
  ## Production — real player wallets
223
243
 
@@ -257,4 +277,8 @@ The escrow/marketplace resolve endpoints (release/refund/confirm) return a deter
257
277
 
258
278
  ## Docs
259
279
 
260
- Full documentation skill games, webhooks, gas, payouts, in-game economies, and the REST API at [playmos.io](https://playmos.io).
280
+ Full developer docs (install, IAP `pay()`, skill `enterRound()`, agents, webhooks, REST)**public, no login**:
281
+
282
+ **https://playmos-docs-public.vercel.app/docs**
283
+
284
+ That is the third-party / stranger surface while we stress-test. Production **`playmos.io/docs`** comes after Founder promote (gate still holds for production only).
package/dist/index.cjs CHANGED
@@ -315,12 +315,66 @@ function createHttpClient(baseUrl, apiKey, retry) {
315
315
  }
316
316
  return false;
317
317
  }
318
+ async function probeSignerBelowFloor() {
319
+ try {
320
+ const res = await send(`${base}/health`, { method: "GET" });
321
+ if (!res.ok) return { below: false };
322
+ const body = await res.json();
323
+ const bal = body?.capabilities?.payments?.signerBalance;
324
+ if (!bal || bal.ok !== false) return { below: false };
325
+ const reason = bal.reason ?? "";
326
+ const fundedFloorMiss = reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth";
327
+ if (!fundedFloorMiss) return { below: false, reason: reason || void 0 };
328
+ return { below: true, reason };
329
+ } catch {
330
+ return { below: false };
331
+ }
332
+ }
318
333
  async function sendWithRetry(url, init) {
319
334
  let res = await send(url, init);
335
+ let signerBelowFloor = false;
336
+ let signerBelowReason;
337
+ let probed = false;
338
+ const maybeProbe = async () => {
339
+ if (probed || signerBelowFloor) return;
340
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
341
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
342
+ if (ct.includes("application/json")) return;
343
+ probed = true;
344
+ const probe = await probeSignerBelowFloor();
345
+ signerBelowFloor = probe.below;
346
+ signerBelowReason = probe.reason;
347
+ };
348
+ const maybePeekJsonSignerLow = async () => {
349
+ if (signerBelowFloor) return;
350
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
351
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
352
+ if (!ct.includes("application/json")) return;
353
+ try {
354
+ const peek = JSON.parse(await res.clone().text());
355
+ const code = peek?.error?.code;
356
+ const reason = peek?.error?.reason;
357
+ if (code === "signer_low_balance" || reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth") {
358
+ signerBelowFloor = true;
359
+ signerBelowReason = reason ?? code;
360
+ }
361
+ } catch {
362
+ }
363
+ };
364
+ const classifySignerBelow = async () => {
365
+ await maybeProbe();
366
+ await maybePeekJsonSignerLow();
367
+ };
368
+ await classifySignerBelow();
320
369
  for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
370
+ if (signerBelowFloor) break;
321
371
  await sleep(backoffMs(res, attempt, cfg));
322
372
  res = await send(url, init);
373
+ await classifySignerBelow();
323
374
  }
375
+ const flagged = res;
376
+ flagged.__playmosSignerBelowFloor = signerBelowFloor;
377
+ if (signerBelowReason) flagged.__playmosSignerBelowReason = signerBelowReason;
324
378
  return res;
325
379
  }
326
380
  async function handle(res, acceptStatuses) {
@@ -339,8 +393,22 @@ function createHttpClient(baseUrl, apiKey, retry) {
339
393
  json = text ? JSON.parse(text) : {};
340
394
  } catch {
341
395
  const gateway = res.status === 502 || res.status === 503 || res.status === 504;
342
- const msg = gateway ? `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while settling on-chain. Retry with the same idempotency key (safe); prefer a dedicated Base Sepolia RPC on the service (BB-GATEB-002).` : `Non-JSON response (${res.status}) from ${res.url}`;
343
- throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
396
+ const below = res.__playmosSignerBelowFloor === true;
397
+ let msg;
398
+ if (gateway && below) {
399
+ const why = res.__playmosSignerBelowReason ?? "low_balance";
400
+ msg = `Sandbox signer below funding floor (${why}) (${res.status} from ${res.url}) \u2014 top up Sepolia USDC (see GET /health capabilities.payments.signerBalance). Do not retry; retry cannot succeed until funded. Cross-ref: issue #205 recurrence \xB7 hub funding #15.`;
401
+ } else if (gateway) {
402
+ msg = `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while settling on-chain. Retry with the same idempotency key (safe); prefer a dedicated Base Sepolia RPC on the service (BB-GATEB-002).`;
403
+ } else {
404
+ msg = `Non-JSON response (${res.status}) from ${res.url}`;
405
+ }
406
+ throw new ApiError(msg, {
407
+ status: res.status,
408
+ body: text.slice(0, 500),
409
+ gateway,
410
+ signerBelowFloor: below || void 0
411
+ });
344
412
  }
345
413
  if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
346
414
  return json;
@@ -981,7 +1049,7 @@ var Playmos = class {
981
1049
  this.mockWithdrawable = /* @__PURE__ */ new Map();
982
1050
  this.rounds = {
983
1051
  open: async (input) => {
984
- this.assertSecretKey("playmos.rounds.open");
1052
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
985
1053
  requireField(input?.gameId, "gameId");
986
1054
  requireField(input?.roundId, "roundId");
987
1055
  requireField(input?.entryAmount, "entryAmount");
@@ -1035,7 +1103,7 @@ var Playmos = class {
1035
1103
  return body.round;
1036
1104
  },
1037
1105
  lock: async (input) => {
1038
- this.assertSecretKey("playmos.rounds.lock");
1106
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.lock");
1039
1107
  requireField(input?.roundId, "roundId");
1040
1108
  if (this.config.mock) {
1041
1109
  const existing = this.mockRounds.get(input.roundId);
@@ -1067,7 +1135,7 @@ var Playmos = class {
1067
1135
  return round;
1068
1136
  },
1069
1137
  settle: async (input) => {
1070
- this.assertSecretKey("playmos.rounds.settle");
1138
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.settle");
1071
1139
  requireField(input?.roundId, "roundId");
1072
1140
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1073
1141
  if (this.config.mock) {
@@ -1141,7 +1209,7 @@ var Playmos = class {
1141
1209
  * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1142
1210
  */
1143
1211
  cancel: async (input) => {
1144
- this.assertSecretKey("playmos.rounds.cancel");
1212
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
1145
1213
  requireField(input?.roundId, "roundId");
1146
1214
  if (this.config.mock) {
1147
1215
  const existing = this.mockRounds.get(input.roundId);
package/dist/index.js CHANGED
@@ -245,12 +245,66 @@ function createHttpClient(baseUrl, apiKey, retry) {
245
245
  }
246
246
  return false;
247
247
  }
248
+ async function probeSignerBelowFloor() {
249
+ try {
250
+ const res = await send(`${base}/health`, { method: "GET" });
251
+ if (!res.ok) return { below: false };
252
+ const body = await res.json();
253
+ const bal = body?.capabilities?.payments?.signerBalance;
254
+ if (!bal || bal.ok !== false) return { below: false };
255
+ const reason = bal.reason ?? "";
256
+ const fundedFloorMiss = reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth";
257
+ if (!fundedFloorMiss) return { below: false, reason: reason || void 0 };
258
+ return { below: true, reason };
259
+ } catch {
260
+ return { below: false };
261
+ }
262
+ }
248
263
  async function sendWithRetry(url, init) {
249
264
  let res = await send(url, init);
265
+ let signerBelowFloor = false;
266
+ let signerBelowReason;
267
+ let probed = false;
268
+ const maybeProbe = async () => {
269
+ if (probed || signerBelowFloor) return;
270
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
271
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
272
+ if (ct.includes("application/json")) return;
273
+ probed = true;
274
+ const probe = await probeSignerBelowFloor();
275
+ signerBelowFloor = probe.below;
276
+ signerBelowReason = probe.reason;
277
+ };
278
+ const maybePeekJsonSignerLow = async () => {
279
+ if (signerBelowFloor) return;
280
+ if (res.status !== 502 && res.status !== 503 && res.status !== 504) return;
281
+ const ct = (res.headers.get("content-type") || "").toLowerCase();
282
+ if (!ct.includes("application/json")) return;
283
+ try {
284
+ const peek = JSON.parse(await res.clone().text());
285
+ const code = peek?.error?.code;
286
+ const reason = peek?.error?.reason;
287
+ if (code === "signer_low_balance" || reason === "low_usdc" || reason === "low_eth" || reason === "low_usdc_and_eth") {
288
+ signerBelowFloor = true;
289
+ signerBelowReason = reason ?? code;
290
+ }
291
+ } catch {
292
+ }
293
+ };
294
+ const classifySignerBelow = async () => {
295
+ await maybeProbe();
296
+ await maybePeekJsonSignerLow();
297
+ };
298
+ await classifySignerBelow();
250
299
  for (let attempt = 0; attempt < cfg.maxRetries && isRetryable(res, init); attempt++) {
300
+ if (signerBelowFloor) break;
251
301
  await sleep(backoffMs(res, attempt, cfg));
252
302
  res = await send(url, init);
303
+ await classifySignerBelow();
253
304
  }
305
+ const flagged = res;
306
+ flagged.__playmosSignerBelowFloor = signerBelowFloor;
307
+ if (signerBelowReason) flagged.__playmosSignerBelowReason = signerBelowReason;
254
308
  return res;
255
309
  }
256
310
  async function handle(res, acceptStatuses) {
@@ -269,8 +323,22 @@ function createHttpClient(baseUrl, apiKey, retry) {
269
323
  json = text ? JSON.parse(text) : {};
270
324
  } catch {
271
325
  const gateway = res.status === 502 || res.status === 503 || res.status === 504;
272
- const msg = gateway ? `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while settling on-chain. Retry with the same idempotency key (safe); prefer a dedicated Base Sepolia RPC on the service (BB-GATEB-002).` : `Non-JSON response (${res.status}) from ${res.url}`;
273
- throw new ApiError(msg, { status: res.status, body: text.slice(0, 500), gateway });
326
+ const below = res.__playmosSignerBelowFloor === true;
327
+ let msg;
328
+ if (gateway && below) {
329
+ const why = res.__playmosSignerBelowReason ?? "low_balance";
330
+ msg = `Sandbox signer below funding floor (${why}) (${res.status} from ${res.url}) \u2014 top up Sepolia USDC (see GET /health capabilities.payments.signerBalance). Do not retry; retry cannot succeed until funded. Cross-ref: issue #205 recurrence \xB7 hub funding #15.`;
331
+ } else if (gateway) {
332
+ msg = `Sandbox gateway timeout (${res.status}) from ${res.url} \u2014 the request likely exceeded the platform limit while settling on-chain. Retry with the same idempotency key (safe); prefer a dedicated Base Sepolia RPC on the service (BB-GATEB-002).`;
333
+ } else {
334
+ msg = `Non-JSON response (${res.status}) from ${res.url}`;
335
+ }
336
+ throw new ApiError(msg, {
337
+ status: res.status,
338
+ body: text.slice(0, 500),
339
+ gateway,
340
+ signerBelowFloor: below || void 0
341
+ });
274
342
  }
275
343
  if (res.ok || acceptStatuses !== void 0 && acceptStatuses.includes(res.status)) {
276
344
  return json;
@@ -911,7 +979,7 @@ var Playmos = class {
911
979
  this.mockWithdrawable = /* @__PURE__ */ new Map();
912
980
  this.rounds = {
913
981
  open: async (input) => {
914
- this.assertSecretKey("playmos.rounds.open");
982
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
915
983
  requireField(input?.gameId, "gameId");
916
984
  requireField(input?.roundId, "roundId");
917
985
  requireField(input?.entryAmount, "entryAmount");
@@ -965,7 +1033,7 @@ var Playmos = class {
965
1033
  return body.round;
966
1034
  },
967
1035
  lock: async (input) => {
968
- this.assertSecretKey("playmos.rounds.lock");
1036
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.lock");
969
1037
  requireField(input?.roundId, "roundId");
970
1038
  if (this.config.mock) {
971
1039
  const existing = this.mockRounds.get(input.roundId);
@@ -997,7 +1065,7 @@ var Playmos = class {
997
1065
  return round;
998
1066
  },
999
1067
  settle: async (input) => {
1000
- this.assertSecretKey("playmos.rounds.settle");
1068
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.settle");
1001
1069
  requireField(input?.roundId, "roundId");
1002
1070
  if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
1003
1071
  if (this.config.mock) {
@@ -1071,7 +1139,7 @@ var Playmos = class {
1071
1139
  * chain Cancelled(4) reconciles — poll `rounds.get` or re-call cancel (#346).
1072
1140
  */
1073
1141
  cancel: async (input) => {
1074
- this.assertSecretKey("playmos.rounds.cancel");
1142
+ if (!this.config.mock) this.assertSecretKey("playmos.rounds.cancel");
1075
1143
  requireField(input?.roundId, "roundId");
1076
1144
  if (this.config.mock) {
1077
1145
  const existing = this.mockRounds.get(input.roundId);
package/package.json CHANGED
@@ -1,9 +1,17 @@
1
1
  {
2
2
  "name": "@playmos/sdk",
3
- "version": "0.3.5",
4
- "description": "Playmos SDK \u2014 stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
3
+ "version": "0.3.7",
4
+ "description": "Playmos SDK stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/playmos-labs/playmos-sdk.git",
10
+ "directory": "sdk"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
7
15
  "type": "module",
8
16
  "sideEffects": false,
9
17
  "main": "./dist/index.cjs",