@playmos/sdk 0.2.0 → 0.3.1

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
@@ -2,6 +2,8 @@
2
2
 
3
3
  var viem = require('viem');
4
4
 
5
+ // src/client.ts
6
+
5
7
  // src/errors.ts
6
8
  var PlaymosError = class extends Error {
7
9
  constructor(code, message, detail) {
@@ -60,6 +62,15 @@ var ConfigError = class extends PlaymosError {
60
62
  super("config", message, detail);
61
63
  }
62
64
  };
65
+ var NothingToWithdrawError = class extends PlaymosError {
66
+ constructor(detail) {
67
+ super(
68
+ "nothing_to_withdraw",
69
+ "Nothing to withdraw \u2014 this wallet has no credited prize balance on this PrizePool (round not settled for them, or already claimed).",
70
+ detail
71
+ );
72
+ }
73
+ };
63
74
 
64
75
  // src/config.ts
65
76
  var CHAIN_ID = {
@@ -222,8 +233,37 @@ function validateMetadata(metadata) {
222
233
  }
223
234
 
224
235
  // src/http.ts
225
- function createHttpClient(baseUrl, apiKey) {
236
+ function resolveRetry(retry) {
237
+ const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
238
+ if (retry === false) return off;
239
+ const r = retry === true || retry === void 0 ? {} : retry;
240
+ return {
241
+ maxRetries: r.maxRetries ?? 2,
242
+ baseDelayMs: r.baseDelayMs ?? 500,
243
+ maxDelayMs: r.maxDelayMs ?? 2e4
244
+ };
245
+ }
246
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
247
+ function backoffMs(res, attempt, cfg) {
248
+ const header = res.headers.get("retry-after");
249
+ if (header) {
250
+ const secs = Number(header);
251
+ let ms;
252
+ if (Number.isFinite(secs)) {
253
+ ms = secs * 1e3;
254
+ } else {
255
+ const at = Date.parse(header);
256
+ ms = Number.isNaN(at) ? NaN : at - Date.now();
257
+ }
258
+ if (Number.isFinite(ms) && ms >= 0) return Math.min(ms, cfg.maxDelayMs);
259
+ }
260
+ const expo = cfg.baseDelayMs * 2 ** attempt;
261
+ const jitter = Math.random() * cfg.baseDelayMs;
262
+ return Math.min(expo + jitter, cfg.maxDelayMs);
263
+ }
264
+ function createHttpClient(baseUrl, apiKey, retry) {
226
265
  const base = baseUrl.replace(/\/+$/, "");
266
+ const cfg = resolveRetry(retry);
227
267
  async function send(url, init) {
228
268
  try {
229
269
  return await fetch(url, init);
@@ -235,6 +275,14 @@ function createHttpClient(baseUrl, apiKey) {
235
275
  );
236
276
  }
237
277
  }
278
+ async function sendWithRetry(url, init) {
279
+ let res = await send(url, init);
280
+ for (let attempt = 0; res.status === 429 && attempt < cfg.maxRetries; attempt++) {
281
+ await sleep(backoffMs(res, attempt, cfg));
282
+ res = await send(url, init);
283
+ }
284
+ return res;
285
+ }
238
286
  async function handle(res) {
239
287
  let text;
240
288
  try {
@@ -267,11 +315,11 @@ function createHttpClient(baseUrl, apiKey) {
267
315
  authorization: `Bearer ${apiKey}`
268
316
  };
269
317
  if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
270
- const res = await send(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
318
+ const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
271
319
  return handle(res);
272
320
  },
273
321
  async get(path) {
274
- const res = await send(`${base}${path}`, {
322
+ const res = await sendWithRetry(`${base}${path}`, {
275
323
  method: "GET",
276
324
  headers: { authorization: `Bearer ${apiKey}` }
277
325
  });
@@ -425,6 +473,13 @@ var prizePoolAbi = [
425
473
  ],
426
474
  outputs: [{ type: "bool" }]
427
475
  },
476
+ {
477
+ type: "function",
478
+ name: "withdrawable",
479
+ stateMutability: "view",
480
+ inputs: [{ name: "account", type: "address" }],
481
+ outputs: [{ type: "uint256" }]
482
+ },
428
483
  {
429
484
  type: "function",
430
485
  name: "withdraw",
@@ -512,6 +567,14 @@ function buildEntryCalls(args) {
512
567
  { to: args.prizePool, data: enterData }
513
568
  ];
514
569
  }
570
+ function buildWithdrawCall(prizePool) {
571
+ const data = viem.encodeFunctionData({
572
+ abi: prizePoolAbi,
573
+ functionName: "withdraw",
574
+ args: []
575
+ });
576
+ return { to: prizePool, data };
577
+ }
515
578
 
516
579
  // src/mock.ts
517
580
  var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
@@ -556,6 +619,9 @@ function mockEntryPayment(args) {
556
619
  },
557
620
  gameId: args.gameId,
558
621
  roundId: args.roundId,
622
+ roundKey: args.roundId,
623
+ // Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
624
+ prizePoolAddress: "0x0000000000000000000000000000000000000001",
559
625
  playerId: args.playerId,
560
626
  txHash: MOCK_TX,
561
627
  chain: args.chain,
@@ -668,6 +734,112 @@ var Playmos = class {
668
734
  `/rounds/${encodeURIComponent(input.roundId)}`
669
735
  );
670
736
  return round;
737
+ },
738
+ /**
739
+ * Read a wallet's **claimable** prize balance for a round (issue #41).
740
+ * Prefers the service chain read (`GET /v1/rounds/:id/prize?wallet=`); falls
741
+ * back to a direct eth_call when `prizePoolAddress` is supplied and no wallet
742
+ * connector is needed for the read path… actually uses service first, then
743
+ * on-chain via the player's provider if the service is unavailable.
744
+ */
745
+ prize: async (input) => {
746
+ requireField(input?.roundId, "roundId");
747
+ const wallet = requireAddressField(input?.wallet, "wallet");
748
+ try {
749
+ const res = await this.http.get(
750
+ `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
751
+ );
752
+ return res.prize;
753
+ } catch (e) {
754
+ if (!input.prizePoolAddress && !(e instanceof ApiError)) throw e;
755
+ if (!input.prizePoolAddress) throw e;
756
+ }
757
+ const prizePoolAddress = requireAddressField(input.prizePoolAddress, "prizePoolAddress");
758
+ const claimableMicro = await this.readWithdrawable(prizePoolAddress, wallet);
759
+ return {
760
+ roundId: input.roundId,
761
+ prizePoolAddress,
762
+ wallet,
763
+ claimable: formatMicroToUsd(claimableMicro),
764
+ claimableMicro: claimableMicro.toString()
765
+ };
766
+ },
767
+ /**
768
+ * Winner **claim** — call PrizePool.withdraw() from the player's wallet (issue #41).
769
+ * Pull-payment: credits from settle live in `withdrawable[msg.sender]`. No approve needed.
770
+ * Pass either `prizePoolAddress` (from enterRound / rounds.get) or `roundId` (service resolves).
771
+ */
772
+ withdraw: async (input) => {
773
+ if (this.config.mock) {
774
+ return {
775
+ prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
776
+ amount: "0.00",
777
+ amountMicro: "0",
778
+ txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
779
+ status: "confirmed"
780
+ };
781
+ }
782
+ let prizePool = input.prizePoolAddress ? requireAddressField(input.prizePoolAddress, "prizePoolAddress") : void 0;
783
+ if (!prizePool) {
784
+ requireField(input?.roundId, "roundId");
785
+ const round = await this.rounds.get({ roundId: input.roundId });
786
+ if (!round.prizePoolAddress) {
787
+ throw new ConfigError(
788
+ "rounds.get did not return prizePoolAddress \u2014 pass prizePoolAddress from enterRound or configure contracts.prizePool"
789
+ );
790
+ }
791
+ prizePool = round.prizePoolAddress;
792
+ }
793
+ const provider = resolveProvider(this.config.wallet);
794
+ if (this.config.gas?.mode === "player") {
795
+ const from0 = await getAccount(provider);
796
+ await assertEnoughGas(provider, from0);
797
+ }
798
+ const from = await getAccount(provider);
799
+ let amountMicro = 0n;
800
+ if (input.checkBalance !== false) {
801
+ amountMicro = await this.readWithdrawable(prizePool, from);
802
+ if (amountMicro === 0n) {
803
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from });
804
+ }
805
+ }
806
+ const call = buildWithdrawCall(prizePool);
807
+ const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
808
+ let txHash;
809
+ let status = "pending";
810
+ try {
811
+ const { id: callsId } = await sendCalls(
812
+ provider,
813
+ from,
814
+ this.env.chainId,
815
+ [call],
816
+ paymasterUrl
817
+ );
818
+ const waited = await waitForCalls(provider, callsId);
819
+ txHash = waited.txHash;
820
+ status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
821
+ } catch (e) {
822
+ const msg = e?.message ?? String(e);
823
+ if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
824
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
825
+ }
826
+ if (e instanceof NothingToWithdrawError || e instanceof PaymentFailedError) throw e;
827
+ throw new PaymentFailedError("PrizePool.withdraw() failed.", { cause: msg });
828
+ }
829
+ if (status === "failed") {
830
+ throw new PaymentFailedError("PrizePool.withdraw() did not confirm on-chain.", {
831
+ prizePoolAddress: prizePool,
832
+ txHash
833
+ });
834
+ }
835
+ if (amountMicro === 0n && input.checkBalance === false) ;
836
+ return {
837
+ prizePoolAddress: prizePool,
838
+ amount: formatMicroToUsd(amountMicro),
839
+ amountMicro: amountMicro.toString(),
840
+ txHash,
841
+ status
842
+ };
671
843
  }
672
844
  };
673
845
  /**
@@ -697,7 +869,12 @@ var Playmos = class {
697
869
  fund: (input) => {
698
870
  requireField(input?.agentId, "agentId");
699
871
  validateAmount(input.amount);
700
- return this.http.post(`/agents/wallets/${encodeURIComponent(input.agentId)}/fund`, { amount: input.amount });
872
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
873
+ return this.http.post(
874
+ `/agents/wallets/${encodeURIComponent(input.agentId)}/fund`,
875
+ { amount: input.amount, idempotencyKey },
876
+ { idempotencyKey }
877
+ );
701
878
  },
702
879
  /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
703
880
  pay: (input) => {
@@ -726,7 +903,7 @@ var Playmos = class {
726
903
  { feeBps: input.feeBps }
727
904
  );
728
905
  }
729
- const feeSink = feeBps > 0 ? requireAddressField(input.feeSink, "feeSink") : input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
906
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
730
907
  const resolver = input.resolver === void 0 ? void 0 : requireAddressField(input.resolver, "resolver");
731
908
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
732
909
  const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
@@ -838,19 +1015,51 @@ var Playmos = class {
838
1015
  }
839
1016
  };
840
1017
  /**
841
- * `transfers` — read-back / confirmation for a prior `transfer()` (issue #27).
842
- * Poll when POST returned `status: "settling"`; reconciles against chain (incl. gasless agent path).
1018
+ * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
1019
+ * `get` reconciles once against chain truth; `wait` polls it to a terminal state
1020
+ * for you (no hand-rolled loop). Both cover the gasless agent path.
843
1021
  */
844
1022
  this.transfers = {
1023
+ /** One-shot reconcile of a transfer against chain truth. */
845
1024
  get: async (transferId) => {
846
1025
  requireField(transferId, "transferId");
847
- const res = await this.http.get(`/transfers/${encodeURIComponent(transferId)}`);
1026
+ const res = await this.http.get(
1027
+ `/transfers/${encodeURIComponent(transferId)}`
1028
+ );
848
1029
  return res.transfer;
1030
+ },
1031
+ /**
1032
+ * Block until a transfer reaches a terminal state — `settled` or `failed` —
1033
+ * instead of hand-rolling a poll loop (#47). Polls `transfers.get(id)` every
1034
+ * `intervalMs` (default 1000) until terminal, then RESOLVES with the final
1035
+ * reconcile. Throws a typed `ApiError` (`detail.timeout`) if neither
1036
+ * `timeoutMs` (default 30000) nor `maxAttempts` (default 40) is reached first.
1037
+ *
1038
+ * A `failed` transfer is a legitimate outcome, so it RESOLVES (status
1039
+ * "failed") — inspect `result.status`; it does not throw.
1040
+ */
1041
+ wait: async (transferId, opts) => {
1042
+ requireField(transferId, "transferId");
1043
+ const intervalMs = opts?.intervalMs ?? 1e3;
1044
+ const timeoutMs = opts?.timeoutMs ?? 3e4;
1045
+ const maxAttempts = opts?.maxAttempts ?? 40;
1046
+ const deadline = Date.now() + timeoutMs;
1047
+ let last;
1048
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
1049
+ last = await this.transfers.get(transferId);
1050
+ if (last.status === "settled" || last.status === "failed") return last;
1051
+ if (Date.now() + intervalMs > deadline) break;
1052
+ await new Promise((r) => setTimeout(r, intervalMs));
1053
+ }
1054
+ throw new ApiError(
1055
+ `Timed out waiting for transfer ${transferId} to settle (last status: ${last?.status ?? "unknown"}). It may still settle \u2014 re-check with playmos.transfers.get("${transferId}").`,
1056
+ { transferId, timeout: true, lastStatus: last?.status ?? "unknown" }
1057
+ );
849
1058
  }
850
1059
  };
851
1060
  this.config = config;
852
1061
  this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
853
- this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey);
1062
+ this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
854
1063
  }
855
1064
  /** Connect the player's wallet and return their address. */
856
1065
  async connect() {
@@ -960,6 +1169,9 @@ var Playmos = class {
960
1169
  );
961
1170
  const payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
962
1171
  if (input.identity) payment2.identity = input.identity;
1172
+ const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1173
+ if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1174
+ payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
963
1175
  return payment2;
964
1176
  }
965
1177
  const intent = await this.http.post(
@@ -987,6 +1199,8 @@ var Playmos = class {
987
1199
  const { txHash } = await waitForCalls(provider, callsId);
988
1200
  const payment = await this.settle(intent.payment.id, txHash);
989
1201
  payment.identity = identity;
1202
+ payment.prizePoolAddress = prizePool.toLowerCase();
1203
+ payment.roundKey = roundKey;
990
1204
  return payment;
991
1205
  }
992
1206
  /**
@@ -1000,12 +1214,13 @@ var Playmos = class {
1000
1214
  * Retries are safe: pass the same `idempotencyKey` and a re-call NEVER broadcasts a second tx —
1001
1215
  * it returns the cached result (`idempotentReplay: true`).
1002
1216
  *
1003
- * Confirmation: treat `status === "settled" && txHash` as final. If `settling`, call
1004
- * `playmos.transfers.get(id)` until settled/failed. BaseScan: `https://sepolia.basescan.org/tx/<txHash>`.
1217
+ * Confirmation: treat `status === "settled" && txHash` as final. If `settling`, either call
1218
+ * `playmos.transfers.wait(id)`, or pass `{ confirm: true }` here to block until terminal in one
1219
+ * call (#47). BaseScan: `https://sepolia.basescan.org/tx/<txHash>`.
1005
1220
  *
1006
1221
  * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1007
1222
  */
1008
- async transfer(input) {
1223
+ async transfer(input, opts) {
1009
1224
  const amountMicro = validateAmount(input.amount);
1010
1225
  const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1011
1226
  const to = partyRef(input.to, "to");
@@ -1016,12 +1231,7 @@ var Playmos = class {
1016
1231
  { feeBps: input.feeBps }
1017
1232
  );
1018
1233
  }
1019
- let feeSink;
1020
- if (feeBps > 0) {
1021
- feeSink = requireAddressField(input.feeSink, "feeSink");
1022
- } else if (input.feeSink !== void 0) {
1023
- feeSink = requireAddressField(input.feeSink, "feeSink");
1024
- }
1234
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
1025
1235
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1026
1236
  const res = await this.http.post(
1027
1237
  "/transfers",
@@ -1030,7 +1240,7 @@ var Playmos = class {
1030
1240
  );
1031
1241
  const t = res.transfer;
1032
1242
  const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
1033
- return {
1243
+ const out = {
1034
1244
  id: t.id,
1035
1245
  status: t.status,
1036
1246
  txHash: t.txHash,
@@ -1046,6 +1256,12 @@ var Playmos = class {
1046
1256
  memo: t.memo ?? input.memo ?? null,
1047
1257
  idempotentReplay: Boolean(t.idempotentReplay)
1048
1258
  };
1259
+ if (opts?.confirm && out.status === "settling") {
1260
+ const final = await this.transfers.wait(out.id, opts);
1261
+ out.status = final.status;
1262
+ out.txHash = final.txHash ?? out.txHash;
1263
+ }
1264
+ return out;
1049
1265
  }
1050
1266
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1051
1267
  async verify(paymentId) {
@@ -1094,6 +1310,58 @@ var Playmos = class {
1094
1310
  const fromServer = intent.clientParams?.amountUnits ?? intent.clientParams?.amountMicro;
1095
1311
  return fromServer ? BigInt(fromServer) : local;
1096
1312
  }
1313
+ /**
1314
+ * Read PrizePool.withdrawable[wallet] via eth_call (issue #41).
1315
+ * Uses the wallet provider when present; otherwise a public Base Sepolia RPC in test.
1316
+ */
1317
+ async readWithdrawable(prizePool, wallet) {
1318
+ const data = viem.encodeFunctionData({
1319
+ abi: prizePoolAbi,
1320
+ functionName: "withdrawable",
1321
+ args: [wallet]
1322
+ });
1323
+ let raw;
1324
+ try {
1325
+ if (await walletAvailable(this.config.wallet)) {
1326
+ const provider = resolveProvider(this.config.wallet);
1327
+ raw = await provider.request({
1328
+ method: "eth_call",
1329
+ params: [{ to: prizePool, data }, "latest"]
1330
+ });
1331
+ } else {
1332
+ const rpc = this.env.network === "base" ? "https://mainnet.base.org" : "https://sepolia.base.org";
1333
+ const res = await fetch(rpc, {
1334
+ method: "POST",
1335
+ headers: { "content-type": "application/json" },
1336
+ body: JSON.stringify({
1337
+ jsonrpc: "2.0",
1338
+ id: 1,
1339
+ method: "eth_call",
1340
+ params: [{ to: prizePool, data }, "latest"]
1341
+ })
1342
+ });
1343
+ const json = await res.json();
1344
+ if (!json.result) {
1345
+ throw new ApiError(
1346
+ `eth_call withdrawable failed: ${json.error?.message ?? "no result"}`,
1347
+ { prizePool, wallet }
1348
+ );
1349
+ }
1350
+ raw = json.result;
1351
+ }
1352
+ } catch (e) {
1353
+ if (e instanceof ApiError) throw e;
1354
+ throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
1355
+ prizePool,
1356
+ wallet
1357
+ });
1358
+ }
1359
+ return viem.decodeFunctionResult({
1360
+ abi: prizePoolAbi,
1361
+ functionName: "withdrawable",
1362
+ data: raw
1363
+ });
1364
+ }
1097
1365
  sponsorUrl(intent) {
1098
1366
  if (this.config.gas?.mode === "player") return void 0;
1099
1367
  return this.config.gas?.paymasterUrl ?? intent.clientParams?.paymasterUrl;
@@ -1381,6 +1649,7 @@ exports.InsufficientGasError = InsufficientGasError;
1381
1649
  exports.InvalidAmountError = InvalidAmountError;
1382
1650
  exports.MICRO_PER_USDC = MICRO_PER_USDC;
1383
1651
  exports.MissingFieldError = MissingFieldError;
1652
+ exports.NothingToWithdrawError = NothingToWithdrawError;
1384
1653
  exports.PaymentFailedError = PaymentFailedError;
1385
1654
  exports.PayoutError = PayoutError;
1386
1655
  exports.Playmos = Playmos;