@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.js CHANGED
@@ -1,6 +1,6 @@
1
- import { InvalidAmountError, ConfigError, MissingFieldError, AuthError, WalletConnectionError, InsufficientGasError, PaymentFailedError, ApiError } from './chunk-B7SHFZYY.js';
2
- export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-B7SHFZYY.js';
3
- import { encodeFunctionData, numberToHex, keccak256, toBytes } from 'viem';
1
+ import { InvalidAmountError, ConfigError, NothingToWithdrawError, PaymentFailedError, ApiError, MissingFieldError, WalletConnectionError, InsufficientGasError, AuthError } from './chunk-TMBBEGIF.js';
2
+ export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-TMBBEGIF.js';
3
+ import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
4
4
 
5
5
  // src/config.ts
6
6
  var CHAIN_ID = {
@@ -163,8 +163,37 @@ function validateMetadata(metadata) {
163
163
  }
164
164
 
165
165
  // src/http.ts
166
- function createHttpClient(baseUrl, apiKey) {
166
+ function resolveRetry(retry) {
167
+ const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
168
+ if (retry === false) return off;
169
+ const r = retry === true || retry === void 0 ? {} : retry;
170
+ return {
171
+ maxRetries: r.maxRetries ?? 2,
172
+ baseDelayMs: r.baseDelayMs ?? 500,
173
+ maxDelayMs: r.maxDelayMs ?? 2e4
174
+ };
175
+ }
176
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
177
+ function backoffMs(res, attempt, cfg) {
178
+ const header = res.headers.get("retry-after");
179
+ if (header) {
180
+ const secs = Number(header);
181
+ let ms;
182
+ if (Number.isFinite(secs)) {
183
+ ms = secs * 1e3;
184
+ } else {
185
+ const at = Date.parse(header);
186
+ ms = Number.isNaN(at) ? NaN : at - Date.now();
187
+ }
188
+ if (Number.isFinite(ms) && ms >= 0) return Math.min(ms, cfg.maxDelayMs);
189
+ }
190
+ const expo = cfg.baseDelayMs * 2 ** attempt;
191
+ const jitter = Math.random() * cfg.baseDelayMs;
192
+ return Math.min(expo + jitter, cfg.maxDelayMs);
193
+ }
194
+ function createHttpClient(baseUrl, apiKey, retry) {
167
195
  const base = baseUrl.replace(/\/+$/, "");
196
+ const cfg = resolveRetry(retry);
168
197
  async function send(url, init) {
169
198
  try {
170
199
  return await fetch(url, init);
@@ -176,6 +205,14 @@ function createHttpClient(baseUrl, apiKey) {
176
205
  );
177
206
  }
178
207
  }
208
+ async function sendWithRetry(url, init) {
209
+ let res = await send(url, init);
210
+ for (let attempt = 0; res.status === 429 && attempt < cfg.maxRetries; attempt++) {
211
+ await sleep(backoffMs(res, attempt, cfg));
212
+ res = await send(url, init);
213
+ }
214
+ return res;
215
+ }
179
216
  async function handle(res) {
180
217
  let text;
181
218
  try {
@@ -208,11 +245,11 @@ function createHttpClient(baseUrl, apiKey) {
208
245
  authorization: `Bearer ${apiKey}`
209
246
  };
210
247
  if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
211
- const res = await send(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
248
+ const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
212
249
  return handle(res);
213
250
  },
214
251
  async get(path) {
215
- const res = await send(`${base}${path}`, {
252
+ const res = await sendWithRetry(`${base}${path}`, {
216
253
  method: "GET",
217
254
  headers: { authorization: `Bearer ${apiKey}` }
218
255
  });
@@ -366,6 +403,13 @@ var prizePoolAbi = [
366
403
  ],
367
404
  outputs: [{ type: "bool" }]
368
405
  },
406
+ {
407
+ type: "function",
408
+ name: "withdrawable",
409
+ stateMutability: "view",
410
+ inputs: [{ name: "account", type: "address" }],
411
+ outputs: [{ type: "uint256" }]
412
+ },
369
413
  {
370
414
  type: "function",
371
415
  name: "withdraw",
@@ -453,6 +497,14 @@ function buildEntryCalls(args) {
453
497
  { to: args.prizePool, data: enterData }
454
498
  ];
455
499
  }
500
+ function buildWithdrawCall(prizePool) {
501
+ const data = encodeFunctionData({
502
+ abi: prizePoolAbi,
503
+ functionName: "withdraw",
504
+ args: []
505
+ });
506
+ return { to: prizePool, data };
507
+ }
456
508
 
457
509
  // src/mock.ts
458
510
  var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
@@ -497,6 +549,9 @@ function mockEntryPayment(args) {
497
549
  },
498
550
  gameId: args.gameId,
499
551
  roundId: args.roundId,
552
+ roundKey: args.roundId,
553
+ // Deterministic mock pool address so claim-flow unit tests can assert shape (#41).
554
+ prizePoolAddress: "0x0000000000000000000000000000000000000001",
500
555
  playerId: args.playerId,
501
556
  txHash: MOCK_TX,
502
557
  chain: args.chain,
@@ -609,6 +664,112 @@ var Playmos = class {
609
664
  `/rounds/${encodeURIComponent(input.roundId)}`
610
665
  );
611
666
  return round;
667
+ },
668
+ /**
669
+ * Read a wallet's **claimable** prize balance for a round (issue #41).
670
+ * Prefers the service chain read (`GET /v1/rounds/:id/prize?wallet=`); falls
671
+ * back to a direct eth_call when `prizePoolAddress` is supplied and no wallet
672
+ * connector is needed for the read path… actually uses service first, then
673
+ * on-chain via the player's provider if the service is unavailable.
674
+ */
675
+ prize: async (input) => {
676
+ requireField(input?.roundId, "roundId");
677
+ const wallet = requireAddressField(input?.wallet, "wallet");
678
+ try {
679
+ const res = await this.http.get(
680
+ `/rounds/${encodeURIComponent(input.roundId)}/prize?wallet=${encodeURIComponent(wallet)}`
681
+ );
682
+ return res.prize;
683
+ } catch (e) {
684
+ if (!input.prizePoolAddress && !(e instanceof ApiError)) throw e;
685
+ if (!input.prizePoolAddress) throw e;
686
+ }
687
+ const prizePoolAddress = requireAddressField(input.prizePoolAddress, "prizePoolAddress");
688
+ const claimableMicro = await this.readWithdrawable(prizePoolAddress, wallet);
689
+ return {
690
+ roundId: input.roundId,
691
+ prizePoolAddress,
692
+ wallet,
693
+ claimable: formatMicroToUsd(claimableMicro),
694
+ claimableMicro: claimableMicro.toString()
695
+ };
696
+ },
697
+ /**
698
+ * Winner **claim** — call PrizePool.withdraw() from the player's wallet (issue #41).
699
+ * Pull-payment: credits from settle live in `withdrawable[msg.sender]`. No approve needed.
700
+ * Pass either `prizePoolAddress` (from enterRound / rounds.get) or `roundId` (service resolves).
701
+ */
702
+ withdraw: async (input) => {
703
+ if (this.config.mock) {
704
+ return {
705
+ prizePoolAddress: input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001",
706
+ amount: "0.00",
707
+ amountMicro: "0",
708
+ txHash: "0x000000000000000000000000000000000000000000000000000000000000mock",
709
+ status: "confirmed"
710
+ };
711
+ }
712
+ let prizePool = input.prizePoolAddress ? requireAddressField(input.prizePoolAddress, "prizePoolAddress") : void 0;
713
+ if (!prizePool) {
714
+ requireField(input?.roundId, "roundId");
715
+ const round = await this.rounds.get({ roundId: input.roundId });
716
+ if (!round.prizePoolAddress) {
717
+ throw new ConfigError(
718
+ "rounds.get did not return prizePoolAddress \u2014 pass prizePoolAddress from enterRound or configure contracts.prizePool"
719
+ );
720
+ }
721
+ prizePool = round.prizePoolAddress;
722
+ }
723
+ const provider = resolveProvider(this.config.wallet);
724
+ if (this.config.gas?.mode === "player") {
725
+ const from0 = await getAccount(provider);
726
+ await assertEnoughGas(provider, from0);
727
+ }
728
+ const from = await getAccount(provider);
729
+ let amountMicro = 0n;
730
+ if (input.checkBalance !== false) {
731
+ amountMicro = await this.readWithdrawable(prizePool, from);
732
+ if (amountMicro === 0n) {
733
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from });
734
+ }
735
+ }
736
+ const call = buildWithdrawCall(prizePool);
737
+ const paymasterUrl = this.config.gas?.mode === "sponsored" ? this.config.gas.paymasterUrl : void 0;
738
+ let txHash;
739
+ let status = "pending";
740
+ try {
741
+ const { id: callsId } = await sendCalls(
742
+ provider,
743
+ from,
744
+ this.env.chainId,
745
+ [call],
746
+ paymasterUrl
747
+ );
748
+ const waited = await waitForCalls(provider, callsId);
749
+ txHash = waited.txHash;
750
+ status = waited.status === "CONFIRMED" ? "confirmed" : waited.status === "FAILED" ? "failed" : "pending";
751
+ } catch (e) {
752
+ const msg = e?.message ?? String(e);
753
+ if (/NothingToWithdraw|nothing to withdraw/i.test(msg)) {
754
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool, wallet: from, cause: msg });
755
+ }
756
+ if (e instanceof NothingToWithdrawError || e instanceof PaymentFailedError) throw e;
757
+ throw new PaymentFailedError("PrizePool.withdraw() failed.", { cause: msg });
758
+ }
759
+ if (status === "failed") {
760
+ throw new PaymentFailedError("PrizePool.withdraw() did not confirm on-chain.", {
761
+ prizePoolAddress: prizePool,
762
+ txHash
763
+ });
764
+ }
765
+ if (amountMicro === 0n && input.checkBalance === false) ;
766
+ return {
767
+ prizePoolAddress: prizePool,
768
+ amount: formatMicroToUsd(amountMicro),
769
+ amountMicro: amountMicro.toString(),
770
+ txHash,
771
+ status
772
+ };
612
773
  }
613
774
  };
614
775
  /**
@@ -638,7 +799,12 @@ var Playmos = class {
638
799
  fund: (input) => {
639
800
  requireField(input?.agentId, "agentId");
640
801
  validateAmount(input.amount);
641
- return this.http.post(`/agents/wallets/${encodeURIComponent(input.agentId)}/fund`, { amount: input.amount });
802
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
803
+ return this.http.post(
804
+ `/agents/wallets/${encodeURIComponent(input.agentId)}/fund`,
805
+ { amount: input.amount, idempotencyKey },
806
+ { idempotencyKey }
807
+ );
642
808
  },
643
809
  /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
644
810
  pay: (input) => {
@@ -667,7 +833,7 @@ var Playmos = class {
667
833
  { feeBps: input.feeBps }
668
834
  );
669
835
  }
670
- const feeSink = feeBps > 0 ? requireAddressField(input.feeSink, "feeSink") : input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
836
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
671
837
  const resolver = input.resolver === void 0 ? void 0 : requireAddressField(input.resolver, "resolver");
672
838
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
673
839
  const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
@@ -779,19 +945,51 @@ var Playmos = class {
779
945
  }
780
946
  };
781
947
  /**
782
- * `transfers` — read-back / confirmation for a prior `transfer()` (issue #27).
783
- * Poll when POST returned `status: "settling"`; reconciles against chain (incl. gasless agent path).
948
+ * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
949
+ * `get` reconciles once against chain truth; `wait` polls it to a terminal state
950
+ * for you (no hand-rolled loop). Both cover the gasless agent path.
784
951
  */
785
952
  this.transfers = {
953
+ /** One-shot reconcile of a transfer against chain truth. */
786
954
  get: async (transferId) => {
787
955
  requireField(transferId, "transferId");
788
- const res = await this.http.get(`/transfers/${encodeURIComponent(transferId)}`);
956
+ const res = await this.http.get(
957
+ `/transfers/${encodeURIComponent(transferId)}`
958
+ );
789
959
  return res.transfer;
960
+ },
961
+ /**
962
+ * Block until a transfer reaches a terminal state — `settled` or `failed` —
963
+ * instead of hand-rolling a poll loop (#47). Polls `transfers.get(id)` every
964
+ * `intervalMs` (default 1000) until terminal, then RESOLVES with the final
965
+ * reconcile. Throws a typed `ApiError` (`detail.timeout`) if neither
966
+ * `timeoutMs` (default 30000) nor `maxAttempts` (default 40) is reached first.
967
+ *
968
+ * A `failed` transfer is a legitimate outcome, so it RESOLVES (status
969
+ * "failed") — inspect `result.status`; it does not throw.
970
+ */
971
+ wait: async (transferId, opts) => {
972
+ requireField(transferId, "transferId");
973
+ const intervalMs = opts?.intervalMs ?? 1e3;
974
+ const timeoutMs = opts?.timeoutMs ?? 3e4;
975
+ const maxAttempts = opts?.maxAttempts ?? 40;
976
+ const deadline = Date.now() + timeoutMs;
977
+ let last;
978
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
979
+ last = await this.transfers.get(transferId);
980
+ if (last.status === "settled" || last.status === "failed") return last;
981
+ if (Date.now() + intervalMs > deadline) break;
982
+ await new Promise((r) => setTimeout(r, intervalMs));
983
+ }
984
+ throw new ApiError(
985
+ `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}").`,
986
+ { transferId, timeout: true, lastStatus: last?.status ?? "unknown" }
987
+ );
790
988
  }
791
989
  };
792
990
  this.config = config;
793
991
  this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
794
- this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey);
992
+ this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
795
993
  }
796
994
  /** Connect the player's wallet and return their address. */
797
995
  async connect() {
@@ -901,6 +1099,9 @@ var Playmos = class {
901
1099
  );
902
1100
  const payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
903
1101
  if (input.identity) payment2.identity = input.identity;
1102
+ const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
1103
+ if (pool) payment2.prizePoolAddress = pool.toLowerCase();
1104
+ payment2.roundKey = res.clientParams?.roundKey ?? res.clientParams?.roundId ?? payment2.roundId ?? input.roundId;
904
1105
  return payment2;
905
1106
  }
906
1107
  const intent = await this.http.post(
@@ -928,6 +1129,8 @@ var Playmos = class {
928
1129
  const { txHash } = await waitForCalls(provider, callsId);
929
1130
  const payment = await this.settle(intent.payment.id, txHash);
930
1131
  payment.identity = identity;
1132
+ payment.prizePoolAddress = prizePool.toLowerCase();
1133
+ payment.roundKey = roundKey;
931
1134
  return payment;
932
1135
  }
933
1136
  /**
@@ -941,12 +1144,13 @@ var Playmos = class {
941
1144
  * Retries are safe: pass the same `idempotencyKey` and a re-call NEVER broadcasts a second tx —
942
1145
  * it returns the cached result (`idempotentReplay: true`).
943
1146
  *
944
- * Confirmation: treat `status === "settled" && txHash` as final. If `settling`, call
945
- * `playmos.transfers.get(id)` until settled/failed. BaseScan: `https://sepolia.basescan.org/tx/<txHash>`.
1147
+ * Confirmation: treat `status === "settled" && txHash` as final. If `settling`, either call
1148
+ * `playmos.transfers.wait(id)`, or pass `{ confirm: true }` here to block until terminal in one
1149
+ * call (#47). BaseScan: `https://sepolia.basescan.org/tx/<txHash>`.
946
1150
  *
947
1151
  * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
948
1152
  */
949
- async transfer(input) {
1153
+ async transfer(input, opts) {
950
1154
  const amountMicro = validateAmount(input.amount);
951
1155
  const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
952
1156
  const to = partyRef(input.to, "to");
@@ -957,12 +1161,7 @@ var Playmos = class {
957
1161
  { feeBps: input.feeBps }
958
1162
  );
959
1163
  }
960
- let feeSink;
961
- if (feeBps > 0) {
962
- feeSink = requireAddressField(input.feeSink, "feeSink");
963
- } else if (input.feeSink !== void 0) {
964
- feeSink = requireAddressField(input.feeSink, "feeSink");
965
- }
1164
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
966
1165
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
967
1166
  const res = await this.http.post(
968
1167
  "/transfers",
@@ -971,7 +1170,7 @@ var Playmos = class {
971
1170
  );
972
1171
  const t = res.transfer;
973
1172
  const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
974
- return {
1173
+ const out = {
975
1174
  id: t.id,
976
1175
  status: t.status,
977
1176
  txHash: t.txHash,
@@ -987,6 +1186,12 @@ var Playmos = class {
987
1186
  memo: t.memo ?? input.memo ?? null,
988
1187
  idempotentReplay: Boolean(t.idempotentReplay)
989
1188
  };
1189
+ if (opts?.confirm && out.status === "settling") {
1190
+ const final = await this.transfers.wait(out.id, opts);
1191
+ out.status = final.status;
1192
+ out.txHash = final.txHash ?? out.txHash;
1193
+ }
1194
+ return out;
990
1195
  }
991
1196
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
992
1197
  async verify(paymentId) {
@@ -1035,6 +1240,58 @@ var Playmos = class {
1035
1240
  const fromServer = intent.clientParams?.amountUnits ?? intent.clientParams?.amountMicro;
1036
1241
  return fromServer ? BigInt(fromServer) : local;
1037
1242
  }
1243
+ /**
1244
+ * Read PrizePool.withdrawable[wallet] via eth_call (issue #41).
1245
+ * Uses the wallet provider when present; otherwise a public Base Sepolia RPC in test.
1246
+ */
1247
+ async readWithdrawable(prizePool, wallet) {
1248
+ const data = encodeFunctionData({
1249
+ abi: prizePoolAbi,
1250
+ functionName: "withdrawable",
1251
+ args: [wallet]
1252
+ });
1253
+ let raw;
1254
+ try {
1255
+ if (await walletAvailable(this.config.wallet)) {
1256
+ const provider = resolveProvider(this.config.wallet);
1257
+ raw = await provider.request({
1258
+ method: "eth_call",
1259
+ params: [{ to: prizePool, data }, "latest"]
1260
+ });
1261
+ } else {
1262
+ const rpc = this.env.network === "base" ? "https://mainnet.base.org" : "https://sepolia.base.org";
1263
+ const res = await fetch(rpc, {
1264
+ method: "POST",
1265
+ headers: { "content-type": "application/json" },
1266
+ body: JSON.stringify({
1267
+ jsonrpc: "2.0",
1268
+ id: 1,
1269
+ method: "eth_call",
1270
+ params: [{ to: prizePool, data }, "latest"]
1271
+ })
1272
+ });
1273
+ const json = await res.json();
1274
+ if (!json.result) {
1275
+ throw new ApiError(
1276
+ `eth_call withdrawable failed: ${json.error?.message ?? "no result"}`,
1277
+ { prizePool, wallet }
1278
+ );
1279
+ }
1280
+ raw = json.result;
1281
+ }
1282
+ } catch (e) {
1283
+ if (e instanceof ApiError) throw e;
1284
+ throw new ApiError(`Could not read withdrawable balance: ${e.message}`, {
1285
+ prizePool,
1286
+ wallet
1287
+ });
1288
+ }
1289
+ return decodeFunctionResult({
1290
+ abi: prizePoolAbi,
1291
+ functionName: "withdrawable",
1292
+ data: raw
1293
+ });
1294
+ }
1038
1295
  sponsorUrl(intent) {
1039
1296
  if (this.config.gas?.mode === "player") return void 0;
1040
1297
  return this.config.gas?.paymasterUrl ?? intent.clientParams?.paymasterUrl;