@playmos/sdk 0.1.6 → 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/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { InvalidAmountError, ConfigError, MissingFieldError, AuthError, WalletConnectionError, InsufficientGasError, PaymentFailedError, ApiError } from './chunk-B7SHFZYY.js';
1
+ import { InvalidAmountError, ConfigError, ApiError, MissingFieldError, AuthError, WalletConnectionError, InsufficientGasError, PaymentFailedError } from './chunk-B7SHFZYY.js';
2
2
  export { ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, PaymentFailedError, PlaymosError, WalletConnectionError } from './chunk-B7SHFZYY.js';
3
3
  import { encodeFunctionData, numberToHex, keccak256, toBytes } from 'viem';
4
4
 
@@ -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
  });
@@ -301,6 +338,17 @@ var playmosPayAbi = [
301
338
  }
302
339
  ];
303
340
  var prizePoolAbi = [
341
+ {
342
+ type: "function",
343
+ name: "openRound",
344
+ stateMutability: "nonpayable",
345
+ inputs: [
346
+ { name: "roundId", type: "bytes32" },
347
+ { name: "series", type: "bytes32" },
348
+ { name: "entry", type: "uint256" }
349
+ ],
350
+ outputs: []
351
+ },
304
352
  {
305
353
  type: "function",
306
354
  name: "enter",
@@ -311,6 +359,40 @@ var prizePoolAbi = [
311
359
  ],
312
360
  outputs: []
313
361
  },
362
+ {
363
+ type: "function",
364
+ name: "lockRound",
365
+ stateMutability: "nonpayable",
366
+ inputs: [{ name: "roundId", type: "bytes32" }],
367
+ outputs: []
368
+ },
369
+ {
370
+ type: "function",
371
+ name: "settle",
372
+ stateMutability: "nonpayable",
373
+ inputs: [
374
+ { name: "roundId", type: "bytes32" },
375
+ { name: "winners", type: "address[]" },
376
+ { name: "amounts", type: "uint256[]" }
377
+ ],
378
+ outputs: []
379
+ },
380
+ {
381
+ type: "function",
382
+ name: "getRound",
383
+ stateMutability: "view",
384
+ inputs: [{ name: "roundId", type: "bytes32" }],
385
+ outputs: [
386
+ { name: "state", type: "uint8" },
387
+ { name: "series", type: "bytes32" },
388
+ { name: "entry", type: "uint256" },
389
+ { name: "pot", type: "uint256" },
390
+ { name: "entrantCount", type: "uint256" },
391
+ { name: "payable_", type: "uint256" },
392
+ { name: "inheritedSeed", type: "uint256" },
393
+ { name: "lockedAt", type: "uint256" }
394
+ ]
395
+ },
314
396
  {
315
397
  type: "function",
316
398
  name: "hasEntered",
@@ -320,6 +402,13 @@ var prizePoolAbi = [
320
402
  { name: "identity", type: "bytes32" }
321
403
  ],
322
404
  outputs: [{ type: "bool" }]
405
+ },
406
+ {
407
+ type: "function",
408
+ name: "withdraw",
409
+ stateMutability: "nonpayable",
410
+ inputs: [],
411
+ outputs: [{ name: "amount", type: "uint256" }]
323
412
  }
324
413
  ];
325
414
  async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
@@ -455,17 +544,42 @@ function mockEntryPayment(args) {
455
544
  }
456
545
 
457
546
  // src/client.ts
547
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
548
+ function requireAddressField(value, field) {
549
+ if (typeof value !== "string" || value.trim() === "") {
550
+ throw new MissingFieldError(field);
551
+ }
552
+ if (!ADDRESS_RE.test(value)) {
553
+ throw new ConfigError(
554
+ `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
555
+ { field, value }
556
+ );
557
+ }
558
+ return value.toLowerCase();
559
+ }
560
+ function partyRef(value, field) {
561
+ if (typeof value !== "string" || value.trim() === "") throw new MissingFieldError(field);
562
+ if (value.startsWith("0x")) return requireAddressField(value, field);
563
+ return value;
564
+ }
458
565
  var IAP_FEE_BPS = 100;
459
566
  var POOL_BPS = 6e3;
460
567
  var SEED_BPS = 3e3;
461
568
  var RAKE_BPS = 1e3;
462
569
  var Playmos = class {
463
570
  constructor(config) {
571
+ /**
572
+ * Webhook helpers. Signature verification is **server-only** (Node `crypto`) and
573
+ * lives on `@playmos/sdk/server` so browser bundlers never see `node:crypto` (#9).
574
+ *
575
+ * import { verifyWebhook } from "@playmos/sdk/server";
576
+ * const event = verifyWebhook(rawBody, req.headers["x-playmos-signature"], secret);
577
+ */
464
578
  this.webhooks = {
465
579
  /**
466
- * Webhook verification is server-only (it uses node:crypto) and no longer
467
- * ships in the browser entry (issue #9). On a backend, import it directly:
468
- * import { verifyWebhook } from "@playmos/sdk/server";
580
+ * @deprecated Use `import { verifyWebhook } from "@playmos/sdk/server"` instead.
581
+ * Throws if called kept as a discoverable pointer so call sites fail loudly
582
+ * with a fix instruction rather than a silent missing method.
469
583
  */
470
584
  verify: (_rawBody, _signatureHeader, _secret) => {
471
585
  throw new ConfigError(
@@ -480,23 +594,278 @@ var Playmos = class {
480
594
  /** Create a Bridge KYC onboarding link (fiat payout). */
481
595
  createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
482
596
  };
597
+ /**
598
+ * Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
599
+ *
600
+ * Scores never leave the studio. Flow:
601
+ * rounds.open → players enterRound → studio scores → rounds.lock →
602
+ * rounds.settle({ ranking }) → contract pays winners.
603
+ *
604
+ * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
605
+ */
606
+ this.rounds = {
607
+ open: async (input) => {
608
+ requireField(input?.gameId, "gameId");
609
+ requireField(input?.roundId, "roundId");
610
+ requireField(input?.entryAmount, "entryAmount");
611
+ if (!input?.payout || typeof input.payout !== "object") {
612
+ throw new ConfigError("payout rule is required (winner-take-all | top-n | custom)");
613
+ }
614
+ validateAmount(input.entryAmount);
615
+ const { round } = await this.http.post("/rounds", {
616
+ gameId: input.gameId,
617
+ roundId: input.roundId,
618
+ entryAmount: input.entryAmount,
619
+ payout: input.payout,
620
+ closeAt: input.closeAt,
621
+ seriesKey: input.seriesKey,
622
+ roundKey: input.roundKey
623
+ });
624
+ return round;
625
+ },
626
+ lock: async (input) => {
627
+ requireField(input?.roundId, "roundId");
628
+ const { round } = await this.http.post(
629
+ `/rounds/${encodeURIComponent(input.roundId)}/lock`,
630
+ { gameId: input.gameId }
631
+ );
632
+ return round;
633
+ },
634
+ settle: async (input) => {
635
+ requireField(input?.roundId, "roundId");
636
+ if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
637
+ const { settle } = await this.http.post(
638
+ `/rounds/${encodeURIComponent(input.roundId)}/settle`,
639
+ { gameId: input.gameId, results: input.results }
640
+ );
641
+ return settle;
642
+ },
643
+ get: async (input) => {
644
+ requireField(input?.roundId, "roundId");
645
+ const { round } = await this.http.get(
646
+ `/rounds/${encodeURIComponent(input.roundId)}`
647
+ );
648
+ return round;
649
+ }
650
+ };
651
+ /**
652
+ * `agents` — assign wallets to the NPCs YOUR game already owns, so they can transact USDC in your economy.
653
+ * The game creates the NPCs; the SDK only creates the WALLET for a game-supplied id. Engine-agnostic
654
+ * (Unity/Unreal/Godot/web all call the same REST). Requires a secret test key (`sk_test_`) on the sandbox.
655
+ */
483
656
  this.agents = {
484
- /** Assign a wallet to any identity (incl. an AI NPC). Idempotent by agentId. */
485
- createWallet: (input) => {
657
+ /** Assign (or return) the wallet for a game NPC id. Idempotent safe to call wherever your NPCs spawn. */
658
+ createWallet: async (input) => {
659
+ requireField(input?.agentId, "agentId");
660
+ const { agent } = await this.http.post("/agents/wallets", { agentId: input.agentId });
661
+ return agent;
662
+ },
663
+ /** Resolve one NPC's wallet by your id. */
664
+ wallet: async (agentId) => {
665
+ requireField(agentId, "agentId");
666
+ const { agent } = await this.http.get(`/agents/wallets/${encodeURIComponent(agentId)}`);
667
+ return agent;
668
+ },
669
+ /** List the NPC wallets you've assigned in this studio. */
670
+ list: async () => {
671
+ const { agents } = await this.http.get("/agents/wallets");
672
+ return agents;
673
+ },
674
+ /** Sandbox faucet: fund an NPC with USDC from your treasury (per-NPC lifetime cap). */
675
+ fund: (input) => {
486
676
  requireField(input?.agentId, "agentId");
487
- return this.http.post("/agents/wallets", { agentId: input.agentId });
677
+ validateAmount(input.amount);
678
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
679
+ return this.http.post(
680
+ `/agents/wallets/${encodeURIComponent(input.agentId)}/fund`,
681
+ { amount: input.amount, idempotencyKey },
682
+ { idempotencyKey }
683
+ );
488
684
  },
489
- /** Agent↔agent USDC transfer; the configured taxBps is skimmed to Playmos. */
685
+ /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
490
686
  pay: (input) => {
491
687
  requireField(input?.from, "from");
492
688
  requireField(input?.to, "to");
493
- validateAmount(input?.amount);
494
- return this.http.post("/agents/pay", input);
689
+ return this.transfer({ from: input.from, to: input.to, amount: input.amount, feeBps: input.feeBps, feeSink: input.feeSink });
690
+ }
691
+ };
692
+ /**
693
+ * `escrow` — the fair-exchange primitive (Phase 2): lock the payer's USDC on-chain the instant a deal
694
+ * opens (`hold`), then move it exactly once — `release` (→ payee, fee skimmed) XOR `refund` (→ payer,
695
+ * 100%). The contract holds the funds trustlessly; YOUR game decides the rule (who resolves, and when).
696
+ * A `deadline` auto-refund guarantees funds never get stuck. Pass the `hold` result's `id` to the rest.
697
+ */
698
+ this.escrow = {
699
+ /** Open a deal: lock `amount` of the payer's USDC into the on-chain escrow. Retries are idempotent.
700
+ * `async` so client-side validation surfaces as a rejected promise, not a synchronous throw. */
701
+ hold: async (input) => {
702
+ const amountMicro = validateAmount(input.amount);
703
+ const payer = input.payer === void 0 ? void 0 : requireAddressField(input.payer, "payer");
704
+ const payee = requireAddressField(input.payee, "payee");
705
+ const feeBps = input.feeBps ?? 0;
706
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
707
+ throw new ConfigError(
708
+ `feeBps must be an integer in [0, 10000] (fee is taken at release only; refunds are 100%), got: ${JSON.stringify(input.feeBps)}`,
709
+ { feeBps: input.feeBps }
710
+ );
711
+ }
712
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
713
+ const resolver = input.resolver === void 0 ? void 0 : requireAddressField(input.resolver, "resolver");
714
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
715
+ const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
716
+ const { escrow } = await this.http.post(
717
+ "/escrows",
718
+ {
719
+ payer,
720
+ payee,
721
+ amount: input.amount,
722
+ feeBps,
723
+ feeSink,
724
+ resolver,
725
+ expiresIn: input.expiresIn,
726
+ deadline: input.deadline,
727
+ memo: input.memo,
728
+ idempotencyKey
729
+ },
730
+ { idempotencyKey }
731
+ );
732
+ return {
733
+ ...escrow,
734
+ payee: escrow.payee ?? payee,
735
+ amount: escrow.amount ?? formatMicroToUsd(amountMicro),
736
+ feeBps: escrow.feeBps ?? feeBps,
737
+ feeSink: escrow.feeSink ?? feeSink ?? null,
738
+ resolver: escrow.resolver ?? resolver ?? null,
739
+ feeAtRelease: escrow.feeAtRelease ?? formatMicroToUsd(feeMicro),
740
+ netAtRelease: escrow.netAtRelease ?? formatMicroToUsd(amountMicro - feeMicro),
741
+ idempotentReplay: Boolean(escrow.idempotentReplay)
742
+ };
743
+ },
744
+ /** Release a held deal to the payee (fee skimmed). `escrowId` is the `hold` result's `id`. */
745
+ release: async (input) => {
746
+ requireField(input?.escrowId, "escrowId");
747
+ const { escrow } = await this.http.post(`/escrows/${encodeURIComponent(input.escrowId)}/release`, {});
748
+ return escrow;
749
+ },
750
+ /** Refund a held deal to the payer (100%, untaxed). `escrowId` is the `hold` result's `id`. */
751
+ refund: async (input) => {
752
+ requireField(input?.escrowId, "escrowId");
753
+ const { escrow } = await this.http.post(`/escrows/${encodeURIComponent(input.escrowId)}/refund`, {});
754
+ return escrow;
755
+ },
756
+ /** Verify a deal's on-chain state (reconciles a `settling` hold wedged by a crash). */
757
+ get: async (escrowId) => {
758
+ requireField(escrowId, "escrowId");
759
+ const { escrow } = await this.http.get(`/escrows/${encodeURIComponent(escrowId)}`);
760
+ return escrow;
761
+ }
762
+ };
763
+ /**
764
+ * `marketplace` — list an item, and the seller is paid ONLY when it's bought (Phase 3a, off-chain items).
765
+ * Built on `escrow`: `buy` locks the buyer's USDC on-chain, `confirm` (after your game server delivers the
766
+ * item) pays the seller, and a no-delivery/timeout `refund`s the buyer. `deliver: true` on `buy` collapses
767
+ * lock+pay into one call when your server delivers synchronously. On-chain items are Phase 3b.
768
+ */
769
+ this.marketplace = {
770
+ /** List an off-chain item for sale. No money moves. Idempotent on `idempotencyKey`. */
771
+ list: async (input) => {
772
+ if (!input?.item || input.item.kind !== "offchain") {
773
+ throw new ConfigError('marketplace.list requires item = { kind: "offchain", sku: "<your-item-id>" } (on-chain items are Phase 3b)', { item: input?.item });
774
+ }
775
+ validateAmount(input.price);
776
+ const seller = requireAddressField(input.seller, "seller");
777
+ const feeBps = input.feeBps;
778
+ if (feeBps !== void 0 && (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4)) {
779
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
780
+ }
781
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
782
+ const { listing } = await this.http.post("/listings", {
783
+ seller,
784
+ item: input.item,
785
+ price: input.price,
786
+ feeBps,
787
+ feeSink,
788
+ deliveryWindow: input.deliveryWindow,
789
+ expiresIn: input.expiresIn,
790
+ gameId: input.gameId,
791
+ idempotencyKey: input.idempotencyKey
792
+ });
793
+ return listing;
794
+ },
795
+ /** Buy a listing: lock the buyer's USDC in escrow. Pass `deliver: true` to also pay the seller in one call. */
796
+ buy: async (input) => {
797
+ requireField(input?.listingId, "listingId");
798
+ const buyer = input.buyer === void 0 ? void 0 : requireAddressField(input.buyer, "buyer");
799
+ return this.http.post(`/listings/${encodeURIComponent(input.listingId)}/buy`, { buyer, deliver: input.deliver === true });
800
+ },
801
+ /** Confirm delivery → the seller is paid (release), fee skimmed. */
802
+ confirm: async (input) => {
803
+ requireField(input?.listingId, "listingId");
804
+ return this.http.post(`/listings/${encodeURIComponent(input.listingId)}/confirm`, {});
805
+ },
806
+ /** Refund the buyer 100% (seller couldn't deliver / dispute / timeout). */
807
+ refund: async (input) => {
808
+ requireField(input?.listingId, "listingId");
809
+ return this.http.post(`/listings/${encodeURIComponent(input.listingId)}/refund`, {});
810
+ },
811
+ /** Delist an unsold listing (pure DB, no tx). */
812
+ cancel: async (input) => {
813
+ requireField(input?.listingId, "listingId");
814
+ const { listing } = await this.http.post(`/listings/${encodeURIComponent(input.listingId)}/cancel`, {});
815
+ return listing;
816
+ },
817
+ /** Fetch a listing + its on-chain-verified sale status. */
818
+ get: async (listingId) => {
819
+ requireField(listingId, "listingId");
820
+ return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
821
+ }
822
+ };
823
+ /**
824
+ * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
825
+ * `get` reconciles once against chain truth; `wait` polls it to a terminal state
826
+ * for you (no hand-rolled loop). Both cover the gasless agent path.
827
+ */
828
+ this.transfers = {
829
+ /** One-shot reconcile of a transfer against chain truth. */
830
+ get: async (transferId) => {
831
+ requireField(transferId, "transferId");
832
+ const res = await this.http.get(
833
+ `/transfers/${encodeURIComponent(transferId)}`
834
+ );
835
+ return res.transfer;
836
+ },
837
+ /**
838
+ * Block until a transfer reaches a terminal state — `settled` or `failed` —
839
+ * instead of hand-rolling a poll loop (#47). Polls `transfers.get(id)` every
840
+ * `intervalMs` (default 1000) until terminal, then RESOLVES with the final
841
+ * reconcile. Throws a typed `ApiError` (`detail.timeout`) if neither
842
+ * `timeoutMs` (default 30000) nor `maxAttempts` (default 40) is reached first.
843
+ *
844
+ * A `failed` transfer is a legitimate outcome, so it RESOLVES (status
845
+ * "failed") — inspect `result.status`; it does not throw.
846
+ */
847
+ wait: async (transferId, opts) => {
848
+ requireField(transferId, "transferId");
849
+ const intervalMs = opts?.intervalMs ?? 1e3;
850
+ const timeoutMs = opts?.timeoutMs ?? 3e4;
851
+ const maxAttempts = opts?.maxAttempts ?? 40;
852
+ const deadline = Date.now() + timeoutMs;
853
+ let last;
854
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
855
+ last = await this.transfers.get(transferId);
856
+ if (last.status === "settled" || last.status === "failed") return last;
857
+ if (Date.now() + intervalMs > deadline) break;
858
+ await new Promise((r) => setTimeout(r, intervalMs));
859
+ }
860
+ throw new ApiError(
861
+ `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}").`,
862
+ { transferId, timeout: true, lastStatus: last?.status ?? "unknown" }
863
+ );
495
864
  }
496
865
  };
497
866
  this.config = config;
498
867
  this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
499
- this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey);
868
+ this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey, config.retry);
500
869
  }
501
870
  /** Connect the player's wallet and return their address. */
502
871
  async connect() {
@@ -568,9 +937,11 @@ var Playmos = class {
568
937
  const playmosPay = intent.clientParams?.contractAddress ?? this.config.contracts?.playmosPay;
569
938
  const studio = intent.clientParams?.studio ?? input.studio;
570
939
  if (!playmosPay) throw new ConfigError("No PlaymosPay contract address (service intent + config.contracts both empty).");
571
- if (!studio) throw new ConfigError("No studio payout address for this payment.");
940
+ if (!studio) throw new ConfigError("No studio payout address for this payment (service must return clientParams.studio).");
941
+ if (!intent.clientParams?.studio && input.studio) ;
572
942
  const amountUnits = this.resolveUnits(intent, amountMicro);
573
- const calls = buildIapCalls({ usdc, playmosPay, paymentId: intent.payment.id, studio, amountUnits });
943
+ const paymentId = intent.clientParams?.paymentId ?? intent.payment.id;
944
+ const calls = buildIapCalls({ usdc, playmosPay, paymentId, studio, amountUnits });
574
945
  const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
575
946
  const { txHash } = await waitForCalls(provider, callsId);
576
947
  return this.settle(intent.payment.id, txHash);
@@ -622,8 +993,9 @@ var Playmos = class {
622
993
  const usdc = intent.clientParams?.usdc ?? this.config.contracts?.usdc ?? USDC_ADDRESS[this.env.network];
623
994
  const prizePool = intent.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
624
995
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
625
- const roundKey = input.roundKey ?? intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? `${input.gameId}:${input.roundId}`;
626
- const identity = input.identity ?? intent.clientParams?.identity ?? `${from}#${idempotencyKey}`;
996
+ const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
997
+ if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
998
+ const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity ?? `${from}#${idempotencyKey}`;
627
999
  const amountUnits = this.resolveUnits(intent, amountMicro);
628
1000
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
629
1001
  const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
@@ -632,6 +1004,66 @@ var Playmos = class {
632
1004
  payment.identity = identity;
633
1005
  return payment;
634
1006
  }
1007
+ /**
1008
+ * `transfer` — the value-movement base primitive (Phase 1a): move USDC from one wallet to another,
1009
+ * with a per-call fee. The GAME LOGIC is the authority — you already decided the move is valid — so
1010
+ * this is a direct, unconditional push (use `escrow`/`marketplace` when a trust boundary needs fair
1011
+ * exchange). The service settles it through the protocol-agnostic settlement core (idempotent,
1012
+ * reserve-before-broadcast) and the on-chain PlaymosTransfer / PlaymosTransferAuth contracts.
1013
+ *
1014
+ * Fee is per-call: `feeBps` 0–10000 (+ `feeSink`). `feeBps: 0` is an untaxed reward/faucet transfer.
1015
+ * Retries are safe: pass the same `idempotencyKey` and a re-call NEVER broadcasts a second tx —
1016
+ * it returns the cached result (`idempotentReplay: true`).
1017
+ *
1018
+ * Confirmation: treat `status === "settled" && txHash` as final. If `settling`, either call
1019
+ * `playmos.transfers.wait(id)`, or pass `{ confirm: true }` here to block until terminal in one
1020
+ * call (#47). BaseScan: `https://sepolia.basescan.org/tx/<txHash>`.
1021
+ *
1022
+ * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1023
+ */
1024
+ async transfer(input, opts) {
1025
+ const amountMicro = validateAmount(input.amount);
1026
+ const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1027
+ const to = partyRef(input.to, "to");
1028
+ const feeBps = input.feeBps ?? 0;
1029
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1030
+ throw new ConfigError(
1031
+ `feeBps must be an integer in [0, 10000] (0 = untaxed reward/faucet; 500 = 5%; 10000 = 100%), got: ${JSON.stringify(input.feeBps)}`,
1032
+ { feeBps: input.feeBps }
1033
+ );
1034
+ }
1035
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
1036
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1037
+ const res = await this.http.post(
1038
+ "/transfers",
1039
+ { from, to, amount: input.amount, memo: input.memo, feeBps, feeSink, idempotencyKey },
1040
+ { idempotencyKey }
1041
+ );
1042
+ const t = res.transfer;
1043
+ const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
1044
+ const out = {
1045
+ id: t.id,
1046
+ status: t.status,
1047
+ txHash: t.txHash,
1048
+ from: t.from,
1049
+ // the service's resolved payer address (omitted when it's the redacted treasury signer)
1050
+ to: t.to,
1051
+ // the service's resolved payee address
1052
+ amount: t.amount ?? formatMicroToUsd(amountMicro),
1053
+ fee: t.fee ?? formatMicroToUsd(feeMicro),
1054
+ net: t.net ?? formatMicroToUsd(amountMicro - feeMicro),
1055
+ feeBps: t.feeBps ?? feeBps,
1056
+ feeSink: t.feeSink ?? feeSink ?? null,
1057
+ memo: t.memo ?? input.memo ?? null,
1058
+ idempotentReplay: Boolean(t.idempotentReplay)
1059
+ };
1060
+ if (opts?.confirm && out.status === "settling") {
1061
+ const final = await this.transfers.wait(out.id, opts);
1062
+ out.status = final.status;
1063
+ out.txHash = final.txHash ?? out.txHash;
1064
+ }
1065
+ return out;
1066
+ }
635
1067
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
636
1068
  async verify(paymentId) {
637
1069
  requireField(paymentId, "paymentId");
@@ -723,6 +1155,30 @@ function previewIapSplit(amount) {
723
1155
  const { feeMicro, netMicro } = computeIapSplit(micro, IAP_FEE_BPS);
724
1156
  return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(netMicro) };
725
1157
  }
1158
+ function previewTransferSplit(amount, feeBps) {
1159
+ const micro = validateAmount(amount);
1160
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1161
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
1162
+ }
1163
+ const feeMicro = micro * BigInt(feeBps) / 10000n;
1164
+ return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(micro - feeMicro), feeBps };
1165
+ }
1166
+ function previewEscrowFee(amount, feeBps) {
1167
+ const micro = validateAmount(amount);
1168
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1169
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
1170
+ }
1171
+ const feeMicro = micro * BigInt(feeBps) / 10000n;
1172
+ return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(micro - feeMicro), feeBps };
1173
+ }
1174
+ function previewMarketplaceSplit(price, feeBps) {
1175
+ const micro = validateAmount(price);
1176
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1177
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
1178
+ }
1179
+ const feeMicro = micro * BigInt(feeBps) / 10000n;
1180
+ return { price: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(micro - feeMicro), feeBps };
1181
+ }
726
1182
  function previewPoolSplit(amount) {
727
1183
  const micro = validateAmount(amount);
728
1184
  const { poolMicro, seedMicro, rakeMicro } = computePoolSplit(micro, POOL_BPS, SEED_BPS, RAKE_BPS);
@@ -734,6 +1190,205 @@ function previewPoolSplit(amount) {
734
1190
  };
735
1191
  }
736
1192
 
737
- export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePoolSplit, formatMicroToUsd, parseUsdToMicro, prefixedId, previewIapSplit, previewPoolSplit, ulid };
1193
+ // src/payout.ts
1194
+ var PayoutError = class extends Error {
1195
+ constructor(message) {
1196
+ super(message);
1197
+ this.code = "payout_invalid";
1198
+ this.name = "PayoutError";
1199
+ }
1200
+ };
1201
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1202
+ var BPS = 10000n;
1203
+ function requireAddress(w, i) {
1204
+ if (!ADDRESS_RE2.test(w)) {
1205
+ throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1206
+ }
1207
+ return w.toLowerCase();
1208
+ }
1209
+ function parseUsdToMicroLoose(amount) {
1210
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
1211
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
1212
+ }
1213
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
1214
+ const whole = wholeRaw ?? "0";
1215
+ const fracPadded = (frac + "000000").slice(0, 6);
1216
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
1217
+ if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
1218
+ return micro;
1219
+ }
1220
+ function computePayout(pool, ranking, rule) {
1221
+ if (typeof pool !== "bigint" || pool <= 0n) {
1222
+ throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
1223
+ }
1224
+ if (!Array.isArray(ranking) || ranking.length === 0) {
1225
+ throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1226
+ }
1227
+ const wallets = ranking.map((w, i) => requireAddress(w, i));
1228
+ const seen = /* @__PURE__ */ new Set();
1229
+ for (const w of wallets) {
1230
+ if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
1231
+ seen.add(w);
1232
+ }
1233
+ if (rule.kind === "winner-take-all") {
1234
+ return [{ wallet: wallets[0], amount: pool }];
1235
+ }
1236
+ if (rule.kind === "top-n") {
1237
+ const splits = rule.splitsBps;
1238
+ if (!Array.isArray(splits) || splits.length === 0) {
1239
+ throw new PayoutError("top-n splitsBps must be a non-empty array");
1240
+ }
1241
+ if (splits.length > wallets.length) {
1242
+ throw new PayoutError(
1243
+ `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
1244
+ );
1245
+ }
1246
+ let sumBps = 0;
1247
+ for (const b of splits) {
1248
+ if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
1249
+ throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
1250
+ }
1251
+ sumBps += b;
1252
+ }
1253
+ if (sumBps !== 1e4) {
1254
+ throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
1255
+ }
1256
+ const out = [];
1257
+ let allocated = 0n;
1258
+ for (let i = 0; i < splits.length; i++) {
1259
+ const amt = pool * BigInt(splits[i]) / BPS;
1260
+ out.push({ wallet: wallets[i], amount: amt });
1261
+ allocated += amt;
1262
+ }
1263
+ const remainder = pool - allocated;
1264
+ if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
1265
+ out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
1266
+ const filtered = out.filter((x) => x.amount > 0n);
1267
+ if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
1268
+ const total = filtered.reduce((s, x) => s + x.amount, 0n);
1269
+ if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
1270
+ return filtered;
1271
+ }
1272
+ if (rule.kind === "custom") {
1273
+ const amounts = rule.amounts;
1274
+ if (!Array.isArray(amounts) || amounts.length === 0) {
1275
+ throw new PayoutError("custom amounts must be a non-empty array of USD strings");
1276
+ }
1277
+ if (amounts.length > wallets.length) {
1278
+ throw new PayoutError(
1279
+ `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
1280
+ );
1281
+ }
1282
+ const out = [];
1283
+ let total = 0n;
1284
+ for (let i = 0; i < amounts.length; i++) {
1285
+ const amt = parseUsdToMicroLoose(amounts[i]);
1286
+ out.push({ wallet: wallets[i], amount: amt });
1287
+ total += amt;
1288
+ }
1289
+ if (total !== pool) {
1290
+ throw new PayoutError(
1291
+ `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
1292
+ );
1293
+ }
1294
+ return out;
1295
+ }
1296
+ throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
1297
+ }
1298
+
1299
+ // src/settlement.ts
1300
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1301
+ var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1302
+ function requireAddress2(value, field) {
1303
+ if (typeof value !== "string" || value.trim() === "") {
1304
+ throw new MissingFieldError(field);
1305
+ }
1306
+ if (!ADDRESS_RE3.test(value)) {
1307
+ throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1308
+ field,
1309
+ value
1310
+ });
1311
+ }
1312
+ return value;
1313
+ }
1314
+ function requireNetwork(value) {
1315
+ if (value === "base" || value === "base-sepolia") return value;
1316
+ throw new ConfigError(`Unknown network: ${JSON.stringify(value)}. Expected one of ${Object.keys(CHAIN_ID).join(", ")}.`, {
1317
+ field: "network",
1318
+ value
1319
+ });
1320
+ }
1321
+ function createPaymentRequirement(input) {
1322
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1323
+ const payTo = requireAddress2(input.payTo, "payTo");
1324
+ const network = requireNetwork(input.network);
1325
+ const asset = input.asset ?? "USDC";
1326
+ if (asset !== "USDC") {
1327
+ throw new ConfigError(`Unsupported asset: ${JSON.stringify(asset)}. Only "USDC" is supported.`, { asset });
1328
+ }
1329
+ parseUsdToMicro(input.amount);
1330
+ const expiresAt = input.expiresAt ?? new Date(now.getTime() + (input.expiresInMs ?? DEFAULT_TTL_MS)).toISOString();
1331
+ const req = {
1332
+ id: input.id ?? prefixedId("preq"),
1333
+ payTo,
1334
+ amount: input.amount,
1335
+ asset,
1336
+ network,
1337
+ expiresAt
1338
+ };
1339
+ if (input.terms !== void 0) req.terms = input.terms;
1340
+ return req;
1341
+ }
1342
+ function serializePaymentRequirement(req) {
1343
+ const body = {
1344
+ id: req.id,
1345
+ payTo: req.payTo,
1346
+ amount: req.amount,
1347
+ asset: req.asset,
1348
+ network: req.network,
1349
+ expiresAt: req.expiresAt
1350
+ };
1351
+ if (req.terms !== void 0) body.terms = req.terms;
1352
+ return body;
1353
+ }
1354
+ function parsePaymentRequirement(input) {
1355
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
1356
+ throw new ConfigError("PaymentRequirement must be a JSON object.", { input });
1357
+ }
1358
+ const o = input;
1359
+ const id = o.id;
1360
+ if (typeof id !== "string" || id.trim() === "") throw new MissingFieldError("id");
1361
+ if (o.asset !== "USDC") {
1362
+ throw new ConfigError(`PaymentRequirement.asset must be "USDC", got: ${JSON.stringify(o.asset)}.`, {
1363
+ asset: o.asset
1364
+ });
1365
+ }
1366
+ const payTo = requireAddress2(o.payTo, "payTo");
1367
+ const network = requireNetwork(o.network);
1368
+ if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1369
+ parseUsdToMicro(o.amount);
1370
+ if (typeof o.expiresAt !== "string" || o.expiresAt.trim() === "") throw new MissingFieldError("expiresAt");
1371
+ if (o.terms !== void 0 && typeof o.terms !== "string") {
1372
+ throw new ConfigError("PaymentRequirement.terms must be a string when present.", { terms: o.terms });
1373
+ }
1374
+ const req = {
1375
+ id,
1376
+ payTo,
1377
+ amount: o.amount,
1378
+ asset: "USDC",
1379
+ network,
1380
+ expiresAt: o.expiresAt
1381
+ };
1382
+ if (o.terms !== void 0) req.terms = o.terms;
1383
+ return req;
1384
+ }
1385
+ function isWalletSignatureAuthorization(auth) {
1386
+ return auth.kind === "wallet-signature";
1387
+ }
1388
+ function isX402PayloadAuthorization(auth) {
1389
+ return auth.kind === "x402-payload";
1390
+ }
1391
+
1392
+ export { CHAIN_ID, DEFAULT_API_BASE_URL, MICRO_PER_USDC, PayoutError, Playmos, USDC_ADDRESS, USDC_DECIMALS, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, formatMicroToUsd, isWalletSignatureAuthorization, isX402PayloadAuthorization, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, serializePaymentRequirement, ulid };
738
1393
  //# sourceMappingURL=index.js.map
739
1394
  //# sourceMappingURL=index.js.map