@playmos/sdk 0.2.0 → 0.3.0

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
@@ -62,8 +62,8 @@ The base value-movement primitive: move USDC from one wallet to another with a c
62
62
 
63
63
  ```ts
64
64
  // NPC→NPC with a 5% fee (Playmos Town P2P). Requires sk_test_ + agents.createWallet first.
65
- // Full walkthrough: docs/examples/playmos-town-sdk.mjs
66
- // Confirm: status === "settled" && txHash, or poll playmos.transfers.get(id) if settling.
65
+ // Full agent/Town walkthrough: see "Agent economies — the Playmos Town golden path" below.
66
+ // Confirm: status === "settled" && txHash, or await playmos.transfers.wait(id) if settling.
67
67
  const t = await playmos.transfer({
68
68
  // `from` is OPTIONAL in the sandbox — omit it and the service uses its server-held signer wallet
69
69
  // (Phase 1a's only payer), so you don't need to know that address to run this example.
@@ -109,6 +109,65 @@ Fields match the table above (`from` optional → defaults to the signer; `feeBp
109
109
 
110
110
  > Sandbox only (Phase 1a): `transfer` settles server-held NPC/agent wallets on Base Sepolia. The player-signed (EIP-3009) non-custodial path is Phase 1b.
111
111
 
112
+ ### Agent economies — the Playmos Town golden path
113
+
114
+ The NPCs in your game can hold and move USDC. This is the loop the [Playmos Town example](https://github.com/playmos-labs/playmos-sdk/blob/main/docs/examples/playmos-town-sdk.mjs) runs end to end: give each NPC a wallet, fund it, then move value with a per-call fee — P2P (5%), a monster bounty (0%), a shop sale (100%).
115
+
116
+ **Agents need a SECRET test key — `sk_test_`, not `pk_test_`.** Assigning NPC wallets and transferring *from* an NPC are privileged, server-side actions, so they require an `sk_test_` key kept on **your backend** — not the public `pk_test_playmos_sandbox`. `pk_test_` runs the no-wallet `pay()` / `enterRound()` demos above; `sk_test_` unlocks `agents.*` and NPC-funded `transfer`.
117
+
118
+ ```ts
119
+ import { Playmos } from "@playmos/sdk";
120
+
121
+ // Server-side only — an sk_test_ secret key, never shipped to the browser.
122
+ const playmos = new Playmos({ apiKey: process.env.PLAYMOS_SK_TEST });
123
+ const feeSink = process.env.PLAYMOS_FEE_SINK; // 0x treasury / shop wallet
124
+
125
+ // 1) Assign a wallet to an NPC your game already owns (idempotent — safe to re-call).
126
+ const miner = await playmos.agents.createWallet({ agentId: "npc_pico_miner" });
127
+ miner.address; // 0x… — the NPC's USDC wallet on Base Sepolia
128
+
129
+ // 2) Fund it from your treasury (sandbox faucet, per-NPC lifetime cap).
130
+ const funded = await playmos.agents.fund({ agentId: "npc_pico_miner", amount: "0.20" });
131
+ funded.funding.status; // "settled" | "settling"
132
+
133
+ // 3a) P2P trade — 5% fee to the treasury (feeBps: 500). NPC `from` ⇒ gasless (NPC signs, Playmos relays + pays gas).
134
+ const p2p = await playmos.transfer({
135
+ from: "npc_pico_miner",
136
+ to: "npc_bruna_blacksmith",
137
+ amount: "0.10",
138
+ feeBps: 500, // 5% P2P
139
+ feeSink, // required when feeBps > 0
140
+ });
141
+
142
+ // Confirm with the new ergonomics (#54): wait for a terminal state — no hand-rolled poll loop.
143
+ const settled = await playmos.transfers.wait(p2p.id);
144
+ settled.status; // "settled" — txHash on sepolia.basescan.org
145
+
146
+ // 3b) Monster-bounty payout — 0% untaxed reward from the treasury signer (omit `from`, no feeSink).
147
+ await playmos.transfer({ to: "npc_wynn_ranger", amount: "0.05", feeBps: 0 });
148
+
149
+ // 3c) First-party shop sale — 100% to the shop (feeBps: 10000).
150
+ await playmos.transfer({ from: "npc_kane_knight", to: feeSink, amount: "0.05", feeBps: 10_000, feeSink });
151
+ ```
152
+
153
+ **Prefer one call? Confirm inline.** Pass `{ confirm: true }` and `transfer` blocks until the transfer is terminal before it resolves — identical to calling `transfers.wait(id)` yourself (#54):
154
+
155
+ ```ts
156
+ const t = await playmos.transfer(
157
+ { from: "npc_pico_miner", to: "npc_bruna_blacksmith", amount: "0.10", feeBps: 500, feeSink },
158
+ { confirm: true },
159
+ );
160
+ t.status; // "settled" (or "failed") — already terminal, no follow-up call
161
+ ```
162
+
163
+ The sandbox throttles (5 req/min per key), so back-to-back legs can `429`. **Retries are automatic** — the SDK backs off and retries a `429` up to twice by default, and every write carries an idempotency key so a retry never double-broadcasts (tune with `new Playmos({ apiKey, retry: { maxRetries } })`, or `retry: false` to opt out).
164
+
165
+ The full 5-NPC walkthrough — create → fund → P2P / shop / bounty, each confirmed on-chain — is [`docs/examples/playmos-town-sdk.mjs`](https://github.com/playmos-labs/playmos-sdk/blob/main/docs/examples/playmos-town-sdk.mjs):
166
+
167
+ ```bash
168
+ PLAYMOS_SK_TEST=sk_test_… PLAYMOS_FEE_SINK=0x… node docs/examples/playmos-town-sdk.mjs
169
+ ```
170
+
112
171
  ### Verify before you grant
113
172
 
114
173
  Grant items on a verified confirmation — never on the client `pay()` return alone.
@@ -65,6 +65,54 @@ interface PlaymosConfig {
65
65
  * even mock results use the REAL status union (never a synthetic "mocked").
66
66
  */
67
67
  mock?: boolean;
68
+ /**
69
+ * Automatic backoff/retry on HTTP 429 (throttling). Default ON (up to 2 retries,
70
+ * honoring a `Retry-After` header). Retries are safe — reads are idempotent and
71
+ * writes carry an idempotency key, so a retry never double-broadcasts.
72
+ * retry: false → opt out entirely
73
+ * retry: { maxRetries } → tune attempts / backoff (0 also opts out)
74
+ */
75
+ retry?: boolean | RetryOptions;
76
+ }
77
+ /** Tuning for the automatic 429 retry (all optional; sane defaults). */
78
+ interface RetryOptions {
79
+ /** Max automatic retries on HTTP 429. `0` disables retrying entirely. Default 2. */
80
+ maxRetries?: number;
81
+ /** Base backoff in ms; the wait grows exponentially per attempt (2^n). Default 500. */
82
+ baseDelayMs?: number;
83
+ /** Ceiling for any single backoff wait, including a server `Retry-After`. Default 20000. */
84
+ maxDelayMs?: number;
85
+ }
86
+ /**
87
+ * The on-chain-reconciled view of a `transfer`, as `playmos.transfers.get(id)` /
88
+ * `playmos.transfers.wait(id)` resolve it. `status` is the settlement lifecycle:
89
+ * "settled" (confirmed) · "settling" (reserved, confirmation pending) · "failed"
90
+ * (mined revert) · "pending" · "unknown". Terminal states are `settled`/`failed`.
91
+ */
92
+ interface TransferReconcile {
93
+ id: string;
94
+ status: string;
95
+ txHash: string | null;
96
+ to: string;
97
+ amount: string;
98
+ verifiedVia: "chain" | "cache" | "degraded";
99
+ }
100
+ /** Options for `playmos.transfers.wait(id, opts?)` — how long / how often to poll. */
101
+ interface WaitOptions {
102
+ /** Poll interval in ms between `transfers.get` calls. Default 1000. */
103
+ intervalMs?: number;
104
+ /** Max total time to wait before throwing a timeout. Default 30000. */
105
+ timeoutMs?: number;
106
+ /** Belt-and-suspenders cap on poll attempts (alongside `timeoutMs`). Default 40. */
107
+ maxAttempts?: number;
108
+ }
109
+ /** Options for the confirm-inline form of `transfer(input, opts?)`. */
110
+ interface TransferConfirmOptions extends WaitOptions {
111
+ /**
112
+ * When true and the POST comes back `settling`, block until the transfer reaches
113
+ * a terminal state (settled/failed) before resolving — no hand-rolled poll loop.
114
+ */
115
+ confirm?: boolean;
68
116
  }
69
117
  interface PayInput {
70
118
  /** USD decimal string ("4.99"). Rejected: ≤ 0, non-numeric, > 2 dp. */
@@ -150,6 +198,7 @@ interface AgentWallet {
150
198
  interface AgentFundResult {
151
199
  agent: AgentWallet;
152
200
  funding: {
201
+ id: string;
153
202
  amount: string;
154
203
  status: string;
155
204
  txHash: string | null;
@@ -420,9 +469,10 @@ interface RoundState {
420
469
  roundKey: string;
421
470
  status: RoundStatus;
422
471
  entryAmount: string;
423
- /** Payable pool in USD (from chain after lock); may be "0.00" while open. */
424
- pool: string;
425
- entrants: number;
472
+ /** Payable pool in USD from chain after reconcile; **`null` until reconciled** (never a fabricated "0.00"). */
473
+ pool: string | null;
474
+ /** Entrant count from chain after reconcile; **`null` until reconciled**. */
475
+ entrants: number | null;
426
476
  payout: PayoutRule;
427
477
  closeAt?: string;
428
478
  openTxHash?: `0x${string}`;
@@ -499,4 +549,4 @@ declare class ConfigError extends PlaymosError {
499
549
  constructor(message: string, detail?: Record<string, unknown>);
500
550
  }
501
551
 
502
- export { type AgentWallet as A, type PlaymosErrorCode as B, ConfigError as C, type RoundStatus as D, type EscrowHoldInput as E, type WalletConfig as F, type GasConfig as G, WalletConnectionError as H, InsufficientGasError as I, type WalletConnector as J, type WebhookEventType as K, type Listing as L, type MarketplaceListInput as M, type Network as N, type PlaymosConfig as P, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, type VerifyResult as V, type WebhookEvent as W, type RoundState as a, type RoundSettleInput as b, type AgentFundResult as c, type EscrowHoldResult as d, type EscrowResolveResult as e, type MarketplaceSaleResult as f, type MarketplaceGetResult as g, type PayInput as h, type Payment as i, type EnterRoundInput as j, type TransferInput as k, type AgentEconomyConfig as l, ApiError as m, AuthError as n, type ContractConfig as o, type Eip1193Provider as p, type GasMode as q, InvalidAmountError as r, type ListingStatus as s, type MarketplaceItem as t, type MarketplaceSale as u, MissingFieldError as v, PaymentFailedError as w, type PaymentStatus as x, type PayoutRule as y, PlaymosError as z };
552
+ export { type AgentWallet as A, type PaymentStatus as B, ConfigError as C, type PayoutRule as D, type EscrowHoldInput as E, PlaymosError as F, type GasConfig as G, type PlaymosErrorCode as H, InsufficientGasError as I, type RetryOptions as J, type RoundStatus as K, type Listing as L, type MarketplaceListInput as M, type Network as N, type WalletConfig as O, type PlaymosConfig as P, WalletConnectionError as Q, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, type WalletConnector as U, type VerifyResult as V, type WebhookEvent as W, type WebhookEventType as X, type RoundState as a, type RoundSettleInput as b, type AgentFundResult as c, type EscrowHoldResult as d, type EscrowResolveResult as e, type MarketplaceSaleResult as f, type MarketplaceGetResult as g, type PayInput as h, type Payment as i, type EnterRoundInput as j, type TransferReconcile as k, type WaitOptions as l, type TransferInput as m, type TransferConfirmOptions as n, type AgentEconomyConfig as o, ApiError as p, AuthError as q, type ContractConfig as r, type Eip1193Provider as s, type GasMode as t, InvalidAmountError as u, type ListingStatus as v, type MarketplaceItem as w, type MarketplaceSale as x, MissingFieldError as y, PaymentFailedError as z };
@@ -65,6 +65,54 @@ interface PlaymosConfig {
65
65
  * even mock results use the REAL status union (never a synthetic "mocked").
66
66
  */
67
67
  mock?: boolean;
68
+ /**
69
+ * Automatic backoff/retry on HTTP 429 (throttling). Default ON (up to 2 retries,
70
+ * honoring a `Retry-After` header). Retries are safe — reads are idempotent and
71
+ * writes carry an idempotency key, so a retry never double-broadcasts.
72
+ * retry: false → opt out entirely
73
+ * retry: { maxRetries } → tune attempts / backoff (0 also opts out)
74
+ */
75
+ retry?: boolean | RetryOptions;
76
+ }
77
+ /** Tuning for the automatic 429 retry (all optional; sane defaults). */
78
+ interface RetryOptions {
79
+ /** Max automatic retries on HTTP 429. `0` disables retrying entirely. Default 2. */
80
+ maxRetries?: number;
81
+ /** Base backoff in ms; the wait grows exponentially per attempt (2^n). Default 500. */
82
+ baseDelayMs?: number;
83
+ /** Ceiling for any single backoff wait, including a server `Retry-After`. Default 20000. */
84
+ maxDelayMs?: number;
85
+ }
86
+ /**
87
+ * The on-chain-reconciled view of a `transfer`, as `playmos.transfers.get(id)` /
88
+ * `playmos.transfers.wait(id)` resolve it. `status` is the settlement lifecycle:
89
+ * "settled" (confirmed) · "settling" (reserved, confirmation pending) · "failed"
90
+ * (mined revert) · "pending" · "unknown". Terminal states are `settled`/`failed`.
91
+ */
92
+ interface TransferReconcile {
93
+ id: string;
94
+ status: string;
95
+ txHash: string | null;
96
+ to: string;
97
+ amount: string;
98
+ verifiedVia: "chain" | "cache" | "degraded";
99
+ }
100
+ /** Options for `playmos.transfers.wait(id, opts?)` — how long / how often to poll. */
101
+ interface WaitOptions {
102
+ /** Poll interval in ms between `transfers.get` calls. Default 1000. */
103
+ intervalMs?: number;
104
+ /** Max total time to wait before throwing a timeout. Default 30000. */
105
+ timeoutMs?: number;
106
+ /** Belt-and-suspenders cap on poll attempts (alongside `timeoutMs`). Default 40. */
107
+ maxAttempts?: number;
108
+ }
109
+ /** Options for the confirm-inline form of `transfer(input, opts?)`. */
110
+ interface TransferConfirmOptions extends WaitOptions {
111
+ /**
112
+ * When true and the POST comes back `settling`, block until the transfer reaches
113
+ * a terminal state (settled/failed) before resolving — no hand-rolled poll loop.
114
+ */
115
+ confirm?: boolean;
68
116
  }
69
117
  interface PayInput {
70
118
  /** USD decimal string ("4.99"). Rejected: ≤ 0, non-numeric, > 2 dp. */
@@ -150,6 +198,7 @@ interface AgentWallet {
150
198
  interface AgentFundResult {
151
199
  agent: AgentWallet;
152
200
  funding: {
201
+ id: string;
153
202
  amount: string;
154
203
  status: string;
155
204
  txHash: string | null;
@@ -420,9 +469,10 @@ interface RoundState {
420
469
  roundKey: string;
421
470
  status: RoundStatus;
422
471
  entryAmount: string;
423
- /** Payable pool in USD (from chain after lock); may be "0.00" while open. */
424
- pool: string;
425
- entrants: number;
472
+ /** Payable pool in USD from chain after reconcile; **`null` until reconciled** (never a fabricated "0.00"). */
473
+ pool: string | null;
474
+ /** Entrant count from chain after reconcile; **`null` until reconciled**. */
475
+ entrants: number | null;
426
476
  payout: PayoutRule;
427
477
  closeAt?: string;
428
478
  openTxHash?: `0x${string}`;
@@ -499,4 +549,4 @@ declare class ConfigError extends PlaymosError {
499
549
  constructor(message: string, detail?: Record<string, unknown>);
500
550
  }
501
551
 
502
- export { type AgentWallet as A, type PlaymosErrorCode as B, ConfigError as C, type RoundStatus as D, type EscrowHoldInput as E, type WalletConfig as F, type GasConfig as G, WalletConnectionError as H, InsufficientGasError as I, type WalletConnector as J, type WebhookEventType as K, type Listing as L, type MarketplaceListInput as M, type Network as N, type PlaymosConfig as P, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, type VerifyResult as V, type WebhookEvent as W, type RoundState as a, type RoundSettleInput as b, type AgentFundResult as c, type EscrowHoldResult as d, type EscrowResolveResult as e, type MarketplaceSaleResult as f, type MarketplaceGetResult as g, type PayInput as h, type Payment as i, type EnterRoundInput as j, type TransferInput as k, type AgentEconomyConfig as l, ApiError as m, AuthError as n, type ContractConfig as o, type Eip1193Provider as p, type GasMode as q, InvalidAmountError as r, type ListingStatus as s, type MarketplaceItem as t, type MarketplaceSale as u, MissingFieldError as v, PaymentFailedError as w, type PaymentStatus as x, type PayoutRule as y, PlaymosError as z };
552
+ export { type AgentWallet as A, type PaymentStatus as B, ConfigError as C, type PayoutRule as D, type EscrowHoldInput as E, PlaymosError as F, type GasConfig as G, type PlaymosErrorCode as H, InsufficientGasError as I, type RetryOptions as J, type RoundStatus as K, type Listing as L, type MarketplaceListInput as M, type Network as N, type WalletConfig as O, type PlaymosConfig as P, WalletConnectionError as Q, type RoundOpenInput as R, type SettleRoundResult as S, type TransferResult as T, type WalletConnector as U, type VerifyResult as V, type WebhookEvent as W, type WebhookEventType as X, type RoundState as a, type RoundSettleInput as b, type AgentFundResult as c, type EscrowHoldResult as d, type EscrowResolveResult as e, type MarketplaceSaleResult as f, type MarketplaceGetResult as g, type PayInput as h, type Payment as i, type EnterRoundInput as j, type TransferReconcile as k, type WaitOptions as l, type TransferInput as m, type TransferConfirmOptions as n, type AgentEconomyConfig as o, ApiError as p, AuthError as q, type ContractConfig as r, type Eip1193Provider as s, type GasMode as t, InvalidAmountError as u, type ListingStatus as v, type MarketplaceItem as w, type MarketplaceSale as x, MissingFieldError as y, PaymentFailedError as z };
package/dist/index.cjs CHANGED
@@ -222,8 +222,37 @@ function validateMetadata(metadata) {
222
222
  }
223
223
 
224
224
  // src/http.ts
225
- function createHttpClient(baseUrl, apiKey) {
225
+ function resolveRetry(retry) {
226
+ const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
227
+ if (retry === false) return off;
228
+ const r = retry === true || retry === void 0 ? {} : retry;
229
+ return {
230
+ maxRetries: r.maxRetries ?? 2,
231
+ baseDelayMs: r.baseDelayMs ?? 500,
232
+ maxDelayMs: r.maxDelayMs ?? 2e4
233
+ };
234
+ }
235
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
236
+ function backoffMs(res, attempt, cfg) {
237
+ const header = res.headers.get("retry-after");
238
+ if (header) {
239
+ const secs = Number(header);
240
+ let ms;
241
+ if (Number.isFinite(secs)) {
242
+ ms = secs * 1e3;
243
+ } else {
244
+ const at = Date.parse(header);
245
+ ms = Number.isNaN(at) ? NaN : at - Date.now();
246
+ }
247
+ if (Number.isFinite(ms) && ms >= 0) return Math.min(ms, cfg.maxDelayMs);
248
+ }
249
+ const expo = cfg.baseDelayMs * 2 ** attempt;
250
+ const jitter = Math.random() * cfg.baseDelayMs;
251
+ return Math.min(expo + jitter, cfg.maxDelayMs);
252
+ }
253
+ function createHttpClient(baseUrl, apiKey, retry) {
226
254
  const base = baseUrl.replace(/\/+$/, "");
255
+ const cfg = resolveRetry(retry);
227
256
  async function send(url, init) {
228
257
  try {
229
258
  return await fetch(url, init);
@@ -235,6 +264,14 @@ function createHttpClient(baseUrl, apiKey) {
235
264
  );
236
265
  }
237
266
  }
267
+ async function sendWithRetry(url, init) {
268
+ let res = await send(url, init);
269
+ for (let attempt = 0; res.status === 429 && attempt < cfg.maxRetries; attempt++) {
270
+ await sleep(backoffMs(res, attempt, cfg));
271
+ res = await send(url, init);
272
+ }
273
+ return res;
274
+ }
238
275
  async function handle(res) {
239
276
  let text;
240
277
  try {
@@ -267,11 +304,11 @@ function createHttpClient(baseUrl, apiKey) {
267
304
  authorization: `Bearer ${apiKey}`
268
305
  };
269
306
  if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
270
- const res = await send(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
307
+ const res = await sendWithRetry(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
271
308
  return handle(res);
272
309
  },
273
310
  async get(path) {
274
- const res = await send(`${base}${path}`, {
311
+ const res = await sendWithRetry(`${base}${path}`, {
275
312
  method: "GET",
276
313
  headers: { authorization: `Bearer ${apiKey}` }
277
314
  });
@@ -697,7 +734,12 @@ var Playmos = class {
697
734
  fund: (input) => {
698
735
  requireField(input?.agentId, "agentId");
699
736
  validateAmount(input.amount);
700
- return this.http.post(`/agents/wallets/${encodeURIComponent(input.agentId)}/fund`, { amount: input.amount });
737
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
738
+ return this.http.post(
739
+ `/agents/wallets/${encodeURIComponent(input.agentId)}/fund`,
740
+ { amount: input.amount, idempotencyKey },
741
+ { idempotencyKey }
742
+ );
701
743
  },
702
744
  /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
703
745
  pay: (input) => {
@@ -726,7 +768,7 @@ var Playmos = class {
726
768
  { feeBps: input.feeBps }
727
769
  );
728
770
  }
729
- const feeSink = feeBps > 0 ? requireAddressField(input.feeSink, "feeSink") : input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
771
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
730
772
  const resolver = input.resolver === void 0 ? void 0 : requireAddressField(input.resolver, "resolver");
731
773
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
732
774
  const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
@@ -838,19 +880,51 @@ var Playmos = class {
838
880
  }
839
881
  };
840
882
  /**
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).
883
+ * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
884
+ * `get` reconciles once against chain truth; `wait` polls it to a terminal state
885
+ * for you (no hand-rolled loop). Both cover the gasless agent path.
843
886
  */
844
887
  this.transfers = {
888
+ /** One-shot reconcile of a transfer against chain truth. */
845
889
  get: async (transferId) => {
846
890
  requireField(transferId, "transferId");
847
- const res = await this.http.get(`/transfers/${encodeURIComponent(transferId)}`);
891
+ const res = await this.http.get(
892
+ `/transfers/${encodeURIComponent(transferId)}`
893
+ );
848
894
  return res.transfer;
895
+ },
896
+ /**
897
+ * Block until a transfer reaches a terminal state — `settled` or `failed` —
898
+ * instead of hand-rolling a poll loop (#47). Polls `transfers.get(id)` every
899
+ * `intervalMs` (default 1000) until terminal, then RESOLVES with the final
900
+ * reconcile. Throws a typed `ApiError` (`detail.timeout`) if neither
901
+ * `timeoutMs` (default 30000) nor `maxAttempts` (default 40) is reached first.
902
+ *
903
+ * A `failed` transfer is a legitimate outcome, so it RESOLVES (status
904
+ * "failed") — inspect `result.status`; it does not throw.
905
+ */
906
+ wait: async (transferId, opts) => {
907
+ requireField(transferId, "transferId");
908
+ const intervalMs = opts?.intervalMs ?? 1e3;
909
+ const timeoutMs = opts?.timeoutMs ?? 3e4;
910
+ const maxAttempts = opts?.maxAttempts ?? 40;
911
+ const deadline = Date.now() + timeoutMs;
912
+ let last;
913
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
914
+ last = await this.transfers.get(transferId);
915
+ if (last.status === "settled" || last.status === "failed") return last;
916
+ if (Date.now() + intervalMs > deadline) break;
917
+ await new Promise((r) => setTimeout(r, intervalMs));
918
+ }
919
+ throw new ApiError(
920
+ `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}").`,
921
+ { transferId, timeout: true, lastStatus: last?.status ?? "unknown" }
922
+ );
849
923
  }
850
924
  };
851
925
  this.config = config;
852
926
  this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
853
- this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey);
927
+ this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
854
928
  }
855
929
  /** Connect the player's wallet and return their address. */
856
930
  async connect() {
@@ -1000,12 +1074,13 @@ var Playmos = class {
1000
1074
  * Retries are safe: pass the same `idempotencyKey` and a re-call NEVER broadcasts a second tx —
1001
1075
  * it returns the cached result (`idempotentReplay: true`).
1002
1076
  *
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>`.
1077
+ * Confirmation: treat `status === "settled" && txHash` as final. If `settling`, either call
1078
+ * `playmos.transfers.wait(id)`, or pass `{ confirm: true }` here to block until terminal in one
1079
+ * call (#47). BaseScan: `https://sepolia.basescan.org/tx/<txHash>`.
1005
1080
  *
1006
1081
  * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1007
1082
  */
1008
- async transfer(input) {
1083
+ async transfer(input, opts) {
1009
1084
  const amountMicro = validateAmount(input.amount);
1010
1085
  const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1011
1086
  const to = partyRef(input.to, "to");
@@ -1016,12 +1091,7 @@ var Playmos = class {
1016
1091
  { feeBps: input.feeBps }
1017
1092
  );
1018
1093
  }
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
- }
1094
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
1025
1095
  const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1026
1096
  const res = await this.http.post(
1027
1097
  "/transfers",
@@ -1030,7 +1100,7 @@ var Playmos = class {
1030
1100
  );
1031
1101
  const t = res.transfer;
1032
1102
  const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
1033
- return {
1103
+ const out = {
1034
1104
  id: t.id,
1035
1105
  status: t.status,
1036
1106
  txHash: t.txHash,
@@ -1046,6 +1116,12 @@ var Playmos = class {
1046
1116
  memo: t.memo ?? input.memo ?? null,
1047
1117
  idempotentReplay: Boolean(t.idempotentReplay)
1048
1118
  };
1119
+ if (opts?.confirm && out.status === "settling") {
1120
+ const final = await this.transfers.wait(out.id, opts);
1121
+ out.status = final.status;
1122
+ out.txHash = final.txHash ?? out.txHash;
1123
+ }
1124
+ return out;
1049
1125
  }
1050
1126
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
1051
1127
  async verify(paymentId) {