@playmos/sdk 0.1.6 → 0.2.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.cjs CHANGED
@@ -360,6 +360,17 @@ var playmosPayAbi = [
360
360
  }
361
361
  ];
362
362
  var prizePoolAbi = [
363
+ {
364
+ type: "function",
365
+ name: "openRound",
366
+ stateMutability: "nonpayable",
367
+ inputs: [
368
+ { name: "roundId", type: "bytes32" },
369
+ { name: "series", type: "bytes32" },
370
+ { name: "entry", type: "uint256" }
371
+ ],
372
+ outputs: []
373
+ },
363
374
  {
364
375
  type: "function",
365
376
  name: "enter",
@@ -370,6 +381,40 @@ var prizePoolAbi = [
370
381
  ],
371
382
  outputs: []
372
383
  },
384
+ {
385
+ type: "function",
386
+ name: "lockRound",
387
+ stateMutability: "nonpayable",
388
+ inputs: [{ name: "roundId", type: "bytes32" }],
389
+ outputs: []
390
+ },
391
+ {
392
+ type: "function",
393
+ name: "settle",
394
+ stateMutability: "nonpayable",
395
+ inputs: [
396
+ { name: "roundId", type: "bytes32" },
397
+ { name: "winners", type: "address[]" },
398
+ { name: "amounts", type: "uint256[]" }
399
+ ],
400
+ outputs: []
401
+ },
402
+ {
403
+ type: "function",
404
+ name: "getRound",
405
+ stateMutability: "view",
406
+ inputs: [{ name: "roundId", type: "bytes32" }],
407
+ outputs: [
408
+ { name: "state", type: "uint8" },
409
+ { name: "series", type: "bytes32" },
410
+ { name: "entry", type: "uint256" },
411
+ { name: "pot", type: "uint256" },
412
+ { name: "entrantCount", type: "uint256" },
413
+ { name: "payable_", type: "uint256" },
414
+ { name: "inheritedSeed", type: "uint256" },
415
+ { name: "lockedAt", type: "uint256" }
416
+ ]
417
+ },
373
418
  {
374
419
  type: "function",
375
420
  name: "hasEntered",
@@ -379,6 +424,13 @@ var prizePoolAbi = [
379
424
  { name: "identity", type: "bytes32" }
380
425
  ],
381
426
  outputs: [{ type: "bool" }]
427
+ },
428
+ {
429
+ type: "function",
430
+ name: "withdraw",
431
+ stateMutability: "nonpayable",
432
+ inputs: [],
433
+ outputs: [{ name: "amount", type: "uint256" }]
382
434
  }
383
435
  ];
384
436
  async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
@@ -514,17 +566,42 @@ function mockEntryPayment(args) {
514
566
  }
515
567
 
516
568
  // src/client.ts
569
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
570
+ function requireAddressField(value, field) {
571
+ if (typeof value !== "string" || value.trim() === "") {
572
+ throw new MissingFieldError(field);
573
+ }
574
+ if (!ADDRESS_RE.test(value)) {
575
+ throw new ConfigError(
576
+ `${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
577
+ { field, value }
578
+ );
579
+ }
580
+ return value.toLowerCase();
581
+ }
582
+ function partyRef(value, field) {
583
+ if (typeof value !== "string" || value.trim() === "") throw new MissingFieldError(field);
584
+ if (value.startsWith("0x")) return requireAddressField(value, field);
585
+ return value;
586
+ }
517
587
  var IAP_FEE_BPS = 100;
518
588
  var POOL_BPS = 6e3;
519
589
  var SEED_BPS = 3e3;
520
590
  var RAKE_BPS = 1e3;
521
591
  var Playmos = class {
522
592
  constructor(config) {
593
+ /**
594
+ * Webhook helpers. Signature verification is **server-only** (Node `crypto`) and
595
+ * lives on `@playmos/sdk/server` so browser bundlers never see `node:crypto` (#9).
596
+ *
597
+ * import { verifyWebhook } from "@playmos/sdk/server";
598
+ * const event = verifyWebhook(rawBody, req.headers["x-playmos-signature"], secret);
599
+ */
523
600
  this.webhooks = {
524
601
  /**
525
- * Webhook verification is server-only (it uses node:crypto) and no longer
526
- * ships in the browser entry (issue #9). On a backend, import it directly:
527
- * import { verifyWebhook } from "@playmos/sdk/server";
602
+ * @deprecated Use `import { verifyWebhook } from "@playmos/sdk/server"` instead.
603
+ * Throws if called kept as a discoverable pointer so call sites fail loudly
604
+ * with a fix instruction rather than a silent missing method.
528
605
  */
529
606
  verify: (_rawBody, _signatureHeader, _secret) => {
530
607
  throw new ConfigError(
@@ -539,18 +616,236 @@ var Playmos = class {
539
616
  /** Create a Bridge KYC onboarding link (fiat payout). */
540
617
  createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
541
618
  };
619
+ /**
620
+ * Skill/contest **round lifecycle** (issue #13) — closes the enterRound loop.
621
+ *
622
+ * Scores never leave the studio. Flow:
623
+ * rounds.open → players enterRound → studio scores → rounds.lock →
624
+ * rounds.settle({ ranking }) → contract pays winners.
625
+ *
626
+ * Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
627
+ */
628
+ this.rounds = {
629
+ open: async (input) => {
630
+ requireField(input?.gameId, "gameId");
631
+ requireField(input?.roundId, "roundId");
632
+ requireField(input?.entryAmount, "entryAmount");
633
+ if (!input?.payout || typeof input.payout !== "object") {
634
+ throw new ConfigError("payout rule is required (winner-take-all | top-n | custom)");
635
+ }
636
+ validateAmount(input.entryAmount);
637
+ const { round } = await this.http.post("/rounds", {
638
+ gameId: input.gameId,
639
+ roundId: input.roundId,
640
+ entryAmount: input.entryAmount,
641
+ payout: input.payout,
642
+ closeAt: input.closeAt,
643
+ seriesKey: input.seriesKey,
644
+ roundKey: input.roundKey
645
+ });
646
+ return round;
647
+ },
648
+ lock: async (input) => {
649
+ requireField(input?.roundId, "roundId");
650
+ const { round } = await this.http.post(
651
+ `/rounds/${encodeURIComponent(input.roundId)}/lock`,
652
+ { gameId: input.gameId }
653
+ );
654
+ return round;
655
+ },
656
+ settle: async (input) => {
657
+ requireField(input?.roundId, "roundId");
658
+ if (!input?.results) throw new ConfigError("results are required (ranking or winners)");
659
+ const { settle } = await this.http.post(
660
+ `/rounds/${encodeURIComponent(input.roundId)}/settle`,
661
+ { gameId: input.gameId, results: input.results }
662
+ );
663
+ return settle;
664
+ },
665
+ get: async (input) => {
666
+ requireField(input?.roundId, "roundId");
667
+ const { round } = await this.http.get(
668
+ `/rounds/${encodeURIComponent(input.roundId)}`
669
+ );
670
+ return round;
671
+ }
672
+ };
673
+ /**
674
+ * `agents` — assign wallets to the NPCs YOUR game already owns, so they can transact USDC in your economy.
675
+ * The game creates the NPCs; the SDK only creates the WALLET for a game-supplied id. Engine-agnostic
676
+ * (Unity/Unreal/Godot/web all call the same REST). Requires a secret test key (`sk_test_`) on the sandbox.
677
+ */
542
678
  this.agents = {
543
- /** Assign a wallet to any identity (incl. an AI NPC). Idempotent by agentId. */
544
- createWallet: (input) => {
679
+ /** Assign (or return) the wallet for a game NPC id. Idempotent safe to call wherever your NPCs spawn. */
680
+ createWallet: async (input) => {
545
681
  requireField(input?.agentId, "agentId");
546
- return this.http.post("/agents/wallets", { agentId: input.agentId });
682
+ const { agent } = await this.http.post("/agents/wallets", { agentId: input.agentId });
683
+ return agent;
684
+ },
685
+ /** Resolve one NPC's wallet by your id. */
686
+ wallet: async (agentId) => {
687
+ requireField(agentId, "agentId");
688
+ const { agent } = await this.http.get(`/agents/wallets/${encodeURIComponent(agentId)}`);
689
+ return agent;
547
690
  },
548
- /** Agent↔agent USDC transfer; the configured taxBps is skimmed to Playmos. */
691
+ /** List the NPC wallets you've assigned in this studio. */
692
+ list: async () => {
693
+ const { agents } = await this.http.get("/agents/wallets");
694
+ return agents;
695
+ },
696
+ /** Sandbox faucet: fund an NPC with USDC from your treasury (per-NPC lifetime cap). */
697
+ fund: (input) => {
698
+ requireField(input?.agentId, "agentId");
699
+ validateAmount(input.amount);
700
+ return this.http.post(`/agents/wallets/${encodeURIComponent(input.agentId)}/fund`, { amount: input.amount });
701
+ },
702
+ /** NPC→NPC (or →player) USDC transfer, by id. A thin alias of `playmos.transfer` (which is canonical). */
549
703
  pay: (input) => {
550
704
  requireField(input?.from, "from");
551
705
  requireField(input?.to, "to");
552
- validateAmount(input?.amount);
553
- return this.http.post("/agents/pay", input);
706
+ return this.transfer({ from: input.from, to: input.to, amount: input.amount, feeBps: input.feeBps, feeSink: input.feeSink });
707
+ }
708
+ };
709
+ /**
710
+ * `escrow` — the fair-exchange primitive (Phase 2): lock the payer's USDC on-chain the instant a deal
711
+ * opens (`hold`), then move it exactly once — `release` (→ payee, fee skimmed) XOR `refund` (→ payer,
712
+ * 100%). The contract holds the funds trustlessly; YOUR game decides the rule (who resolves, and when).
713
+ * A `deadline` auto-refund guarantees funds never get stuck. Pass the `hold` result's `id` to the rest.
714
+ */
715
+ this.escrow = {
716
+ /** Open a deal: lock `amount` of the payer's USDC into the on-chain escrow. Retries are idempotent.
717
+ * `async` so client-side validation surfaces as a rejected promise, not a synchronous throw. */
718
+ hold: async (input) => {
719
+ const amountMicro = validateAmount(input.amount);
720
+ const payer = input.payer === void 0 ? void 0 : requireAddressField(input.payer, "payer");
721
+ const payee = requireAddressField(input.payee, "payee");
722
+ const feeBps = input.feeBps ?? 0;
723
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
724
+ throw new ConfigError(
725
+ `feeBps must be an integer in [0, 10000] (fee is taken at release only; refunds are 100%), got: ${JSON.stringify(input.feeBps)}`,
726
+ { feeBps: input.feeBps }
727
+ );
728
+ }
729
+ const feeSink = feeBps > 0 ? requireAddressField(input.feeSink, "feeSink") : input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
730
+ const resolver = input.resolver === void 0 ? void 0 : requireAddressField(input.resolver, "resolver");
731
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
732
+ const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
733
+ const { escrow } = await this.http.post(
734
+ "/escrows",
735
+ {
736
+ payer,
737
+ payee,
738
+ amount: input.amount,
739
+ feeBps,
740
+ feeSink,
741
+ resolver,
742
+ expiresIn: input.expiresIn,
743
+ deadline: input.deadline,
744
+ memo: input.memo,
745
+ idempotencyKey
746
+ },
747
+ { idempotencyKey }
748
+ );
749
+ return {
750
+ ...escrow,
751
+ payee: escrow.payee ?? payee,
752
+ amount: escrow.amount ?? formatMicroToUsd(amountMicro),
753
+ feeBps: escrow.feeBps ?? feeBps,
754
+ feeSink: escrow.feeSink ?? feeSink ?? null,
755
+ resolver: escrow.resolver ?? resolver ?? null,
756
+ feeAtRelease: escrow.feeAtRelease ?? formatMicroToUsd(feeMicro),
757
+ netAtRelease: escrow.netAtRelease ?? formatMicroToUsd(amountMicro - feeMicro),
758
+ idempotentReplay: Boolean(escrow.idempotentReplay)
759
+ };
760
+ },
761
+ /** Release a held deal to the payee (fee skimmed). `escrowId` is the `hold` result's `id`. */
762
+ release: async (input) => {
763
+ requireField(input?.escrowId, "escrowId");
764
+ const { escrow } = await this.http.post(`/escrows/${encodeURIComponent(input.escrowId)}/release`, {});
765
+ return escrow;
766
+ },
767
+ /** Refund a held deal to the payer (100%, untaxed). `escrowId` is the `hold` result's `id`. */
768
+ refund: async (input) => {
769
+ requireField(input?.escrowId, "escrowId");
770
+ const { escrow } = await this.http.post(`/escrows/${encodeURIComponent(input.escrowId)}/refund`, {});
771
+ return escrow;
772
+ },
773
+ /** Verify a deal's on-chain state (reconciles a `settling` hold wedged by a crash). */
774
+ get: async (escrowId) => {
775
+ requireField(escrowId, "escrowId");
776
+ const { escrow } = await this.http.get(`/escrows/${encodeURIComponent(escrowId)}`);
777
+ return escrow;
778
+ }
779
+ };
780
+ /**
781
+ * `marketplace` — list an item, and the seller is paid ONLY when it's bought (Phase 3a, off-chain items).
782
+ * Built on `escrow`: `buy` locks the buyer's USDC on-chain, `confirm` (after your game server delivers the
783
+ * item) pays the seller, and a no-delivery/timeout `refund`s the buyer. `deliver: true` on `buy` collapses
784
+ * lock+pay into one call when your server delivers synchronously. On-chain items are Phase 3b.
785
+ */
786
+ this.marketplace = {
787
+ /** List an off-chain item for sale. No money moves. Idempotent on `idempotencyKey`. */
788
+ list: async (input) => {
789
+ if (!input?.item || input.item.kind !== "offchain") {
790
+ throw new ConfigError('marketplace.list requires item = { kind: "offchain", sku: "<your-item-id>" } (on-chain items are Phase 3b)', { item: input?.item });
791
+ }
792
+ validateAmount(input.price);
793
+ const seller = requireAddressField(input.seller, "seller");
794
+ const feeBps = input.feeBps;
795
+ if (feeBps !== void 0 && (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4)) {
796
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
797
+ }
798
+ const feeSink = input.feeSink === void 0 ? void 0 : requireAddressField(input.feeSink, "feeSink");
799
+ const { listing } = await this.http.post("/listings", {
800
+ seller,
801
+ item: input.item,
802
+ price: input.price,
803
+ feeBps,
804
+ feeSink,
805
+ deliveryWindow: input.deliveryWindow,
806
+ expiresIn: input.expiresIn,
807
+ gameId: input.gameId,
808
+ idempotencyKey: input.idempotencyKey
809
+ });
810
+ return listing;
811
+ },
812
+ /** Buy a listing: lock the buyer's USDC in escrow. Pass `deliver: true` to also pay the seller in one call. */
813
+ buy: async (input) => {
814
+ requireField(input?.listingId, "listingId");
815
+ const buyer = input.buyer === void 0 ? void 0 : requireAddressField(input.buyer, "buyer");
816
+ return this.http.post(`/listings/${encodeURIComponent(input.listingId)}/buy`, { buyer, deliver: input.deliver === true });
817
+ },
818
+ /** Confirm delivery → the seller is paid (release), fee skimmed. */
819
+ confirm: async (input) => {
820
+ requireField(input?.listingId, "listingId");
821
+ return this.http.post(`/listings/${encodeURIComponent(input.listingId)}/confirm`, {});
822
+ },
823
+ /** Refund the buyer 100% (seller couldn't deliver / dispute / timeout). */
824
+ refund: async (input) => {
825
+ requireField(input?.listingId, "listingId");
826
+ return this.http.post(`/listings/${encodeURIComponent(input.listingId)}/refund`, {});
827
+ },
828
+ /** Delist an unsold listing (pure DB, no tx). */
829
+ cancel: async (input) => {
830
+ requireField(input?.listingId, "listingId");
831
+ const { listing } = await this.http.post(`/listings/${encodeURIComponent(input.listingId)}/cancel`, {});
832
+ return listing;
833
+ },
834
+ /** Fetch a listing + its on-chain-verified sale status. */
835
+ get: async (listingId) => {
836
+ requireField(listingId, "listingId");
837
+ return this.http.get(`/listings/${encodeURIComponent(listingId)}`);
838
+ }
839
+ };
840
+ /**
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).
843
+ */
844
+ this.transfers = {
845
+ get: async (transferId) => {
846
+ requireField(transferId, "transferId");
847
+ const res = await this.http.get(`/transfers/${encodeURIComponent(transferId)}`);
848
+ return res.transfer;
554
849
  }
555
850
  };
556
851
  this.config = config;
@@ -627,9 +922,11 @@ var Playmos = class {
627
922
  const playmosPay = intent.clientParams?.contractAddress ?? this.config.contracts?.playmosPay;
628
923
  const studio = intent.clientParams?.studio ?? input.studio;
629
924
  if (!playmosPay) throw new ConfigError("No PlaymosPay contract address (service intent + config.contracts both empty).");
630
- if (!studio) throw new ConfigError("No studio payout address for this payment.");
925
+ if (!studio) throw new ConfigError("No studio payout address for this payment (service must return clientParams.studio).");
926
+ if (!intent.clientParams?.studio && input.studio) ;
631
927
  const amountUnits = this.resolveUnits(intent, amountMicro);
632
- const calls = buildIapCalls({ usdc, playmosPay, paymentId: intent.payment.id, studio, amountUnits });
928
+ const paymentId = intent.clientParams?.paymentId ?? intent.payment.id;
929
+ const calls = buildIapCalls({ usdc, playmosPay, paymentId, studio, amountUnits });
633
930
  const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
634
931
  const { txHash } = await waitForCalls(provider, callsId);
635
932
  return this.settle(intent.payment.id, txHash);
@@ -681,8 +978,9 @@ var Playmos = class {
681
978
  const usdc = intent.clientParams?.usdc ?? this.config.contracts?.usdc ?? USDC_ADDRESS[this.env.network];
682
979
  const prizePool = intent.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
683
980
  if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
684
- const roundKey = input.roundKey ?? intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? `${input.gameId}:${input.roundId}`;
685
- const identity = input.identity ?? intent.clientParams?.identity ?? `${from}#${idempotencyKey}`;
981
+ const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? input.roundKey ?? intent.payment.roundId ?? input.roundId;
982
+ if (!roundKey) throw new ConfigError("No roundKey/roundId for enterRound (service clientParams missing).");
983
+ const identity = intent.clientParams?.identity ?? input.identity ?? intent.payment.identity ?? `${from}#${idempotencyKey}`;
686
984
  const amountUnits = this.resolveUnits(intent, amountMicro);
687
985
  const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
688
986
  const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
@@ -691,6 +989,64 @@ var Playmos = class {
691
989
  payment.identity = identity;
692
990
  return payment;
693
991
  }
992
+ /**
993
+ * `transfer` — the value-movement base primitive (Phase 1a): move USDC from one wallet to another,
994
+ * with a per-call fee. The GAME LOGIC is the authority — you already decided the move is valid — so
995
+ * this is a direct, unconditional push (use `escrow`/`marketplace` when a trust boundary needs fair
996
+ * exchange). The service settles it through the protocol-agnostic settlement core (idempotent,
997
+ * reserve-before-broadcast) and the on-chain PlaymosTransfer / PlaymosTransferAuth contracts.
998
+ *
999
+ * Fee is per-call: `feeBps` 0–10000 (+ `feeSink`). `feeBps: 0` is an untaxed reward/faucet transfer.
1000
+ * Retries are safe: pass the same `idempotencyKey` and a re-call NEVER broadcasts a second tx —
1001
+ * it returns the cached result (`idempotentReplay: true`).
1002
+ *
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>`.
1005
+ *
1006
+ * NPC `from` requires `sk_test_` and settles gaslessly (NPC signs; service relays).
1007
+ */
1008
+ async transfer(input) {
1009
+ const amountMicro = validateAmount(input.amount);
1010
+ const from = input.from === void 0 ? void 0 : partyRef(input.from, "from");
1011
+ const to = partyRef(input.to, "to");
1012
+ const feeBps = input.feeBps ?? 0;
1013
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1014
+ throw new ConfigError(
1015
+ `feeBps must be an integer in [0, 10000] (0 = untaxed reward/faucet; 500 = 5%; 10000 = 100%), got: ${JSON.stringify(input.feeBps)}`,
1016
+ { feeBps: input.feeBps }
1017
+ );
1018
+ }
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
+ }
1025
+ const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
1026
+ const res = await this.http.post(
1027
+ "/transfers",
1028
+ { from, to, amount: input.amount, memo: input.memo, feeBps, feeSink, idempotencyKey },
1029
+ { idempotencyKey }
1030
+ );
1031
+ const t = res.transfer;
1032
+ const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
1033
+ return {
1034
+ id: t.id,
1035
+ status: t.status,
1036
+ txHash: t.txHash,
1037
+ from: t.from,
1038
+ // the service's resolved payer address (omitted when it's the redacted treasury signer)
1039
+ to: t.to,
1040
+ // the service's resolved payee address
1041
+ amount: t.amount ?? formatMicroToUsd(amountMicro),
1042
+ fee: t.fee ?? formatMicroToUsd(feeMicro),
1043
+ net: t.net ?? formatMicroToUsd(amountMicro - feeMicro),
1044
+ feeBps: t.feeBps ?? feeBps,
1045
+ feeSink: t.feeSink ?? feeSink ?? null,
1046
+ memo: t.memo ?? input.memo ?? null,
1047
+ idempotentReplay: Boolean(t.idempotentReplay)
1048
+ };
1049
+ }
694
1050
  /** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
695
1051
  async verify(paymentId) {
696
1052
  requireField(paymentId, "paymentId");
@@ -782,6 +1138,30 @@ function previewIapSplit(amount) {
782
1138
  const { feeMicro, netMicro } = computeIapSplit(micro, IAP_FEE_BPS);
783
1139
  return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(netMicro) };
784
1140
  }
1141
+ function previewTransferSplit(amount, feeBps) {
1142
+ const micro = validateAmount(amount);
1143
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1144
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
1145
+ }
1146
+ const feeMicro = micro * BigInt(feeBps) / 10000n;
1147
+ return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(micro - feeMicro), feeBps };
1148
+ }
1149
+ function previewEscrowFee(amount, feeBps) {
1150
+ const micro = validateAmount(amount);
1151
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1152
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
1153
+ }
1154
+ const feeMicro = micro * BigInt(feeBps) / 10000n;
1155
+ return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(micro - feeMicro), feeBps };
1156
+ }
1157
+ function previewMarketplaceSplit(price, feeBps) {
1158
+ const micro = validateAmount(price);
1159
+ if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
1160
+ throw new ConfigError(`feeBps must be an integer in [0, 10000], got: ${JSON.stringify(feeBps)}`, { feeBps });
1161
+ }
1162
+ const feeMicro = micro * BigInt(feeBps) / 10000n;
1163
+ return { price: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(micro - feeMicro), feeBps };
1164
+ }
785
1165
  function previewPoolSplit(amount) {
786
1166
  const micro = validateAmount(amount);
787
1167
  const { poolMicro, seedMicro, rakeMicro } = computePoolSplit(micro, POOL_BPS, SEED_BPS, RAKE_BPS);
@@ -793,6 +1173,205 @@ function previewPoolSplit(amount) {
793
1173
  };
794
1174
  }
795
1175
 
1176
+ // src/payout.ts
1177
+ var PayoutError = class extends Error {
1178
+ constructor(message) {
1179
+ super(message);
1180
+ this.code = "payout_invalid";
1181
+ this.name = "PayoutError";
1182
+ }
1183
+ };
1184
+ var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
1185
+ var BPS = 10000n;
1186
+ function requireAddress(w, i) {
1187
+ if (!ADDRESS_RE2.test(w)) {
1188
+ throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
1189
+ }
1190
+ return w.toLowerCase();
1191
+ }
1192
+ function parseUsdToMicroLoose(amount) {
1193
+ if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
1194
+ throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
1195
+ }
1196
+ const [wholeRaw, frac = ""] = amount.trim().split(".");
1197
+ const whole = wholeRaw ?? "0";
1198
+ const fracPadded = (frac + "000000").slice(0, 6);
1199
+ const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
1200
+ if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
1201
+ return micro;
1202
+ }
1203
+ function computePayout(pool, ranking, rule) {
1204
+ if (typeof pool !== "bigint" || pool <= 0n) {
1205
+ throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
1206
+ }
1207
+ if (!Array.isArray(ranking) || ranking.length === 0) {
1208
+ throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
1209
+ }
1210
+ const wallets = ranking.map((w, i) => requireAddress(w, i));
1211
+ const seen = /* @__PURE__ */ new Set();
1212
+ for (const w of wallets) {
1213
+ if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
1214
+ seen.add(w);
1215
+ }
1216
+ if (rule.kind === "winner-take-all") {
1217
+ return [{ wallet: wallets[0], amount: pool }];
1218
+ }
1219
+ if (rule.kind === "top-n") {
1220
+ const splits = rule.splitsBps;
1221
+ if (!Array.isArray(splits) || splits.length === 0) {
1222
+ throw new PayoutError("top-n splitsBps must be a non-empty array");
1223
+ }
1224
+ if (splits.length > wallets.length) {
1225
+ throw new PayoutError(
1226
+ `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
1227
+ );
1228
+ }
1229
+ let sumBps = 0;
1230
+ for (const b of splits) {
1231
+ if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
1232
+ throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
1233
+ }
1234
+ sumBps += b;
1235
+ }
1236
+ if (sumBps !== 1e4) {
1237
+ throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
1238
+ }
1239
+ const out = [];
1240
+ let allocated = 0n;
1241
+ for (let i = 0; i < splits.length; i++) {
1242
+ const amt = pool * BigInt(splits[i]) / BPS;
1243
+ out.push({ wallet: wallets[i], amount: amt });
1244
+ allocated += amt;
1245
+ }
1246
+ const remainder = pool - allocated;
1247
+ if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
1248
+ out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
1249
+ const filtered = out.filter((x) => x.amount > 0n);
1250
+ if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
1251
+ const total = filtered.reduce((s, x) => s + x.amount, 0n);
1252
+ if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
1253
+ return filtered;
1254
+ }
1255
+ if (rule.kind === "custom") {
1256
+ const amounts = rule.amounts;
1257
+ if (!Array.isArray(amounts) || amounts.length === 0) {
1258
+ throw new PayoutError("custom amounts must be a non-empty array of USD strings");
1259
+ }
1260
+ if (amounts.length > wallets.length) {
1261
+ throw new PayoutError(
1262
+ `custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
1263
+ );
1264
+ }
1265
+ const out = [];
1266
+ let total = 0n;
1267
+ for (let i = 0; i < amounts.length; i++) {
1268
+ const amt = parseUsdToMicroLoose(amounts[i]);
1269
+ out.push({ wallet: wallets[i], amount: amt });
1270
+ total += amt;
1271
+ }
1272
+ if (total !== pool) {
1273
+ throw new PayoutError(
1274
+ `custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
1275
+ );
1276
+ }
1277
+ return out;
1278
+ }
1279
+ throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
1280
+ }
1281
+
1282
+ // src/settlement.ts
1283
+ var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
1284
+ var DEFAULT_TTL_MS = 15 * 60 * 1e3;
1285
+ function requireAddress2(value, field) {
1286
+ if (typeof value !== "string" || value.trim() === "") {
1287
+ throw new MissingFieldError(field);
1288
+ }
1289
+ if (!ADDRESS_RE3.test(value)) {
1290
+ throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
1291
+ field,
1292
+ value
1293
+ });
1294
+ }
1295
+ return value;
1296
+ }
1297
+ function requireNetwork(value) {
1298
+ if (value === "base" || value === "base-sepolia") return value;
1299
+ throw new ConfigError(`Unknown network: ${JSON.stringify(value)}. Expected one of ${Object.keys(CHAIN_ID).join(", ")}.`, {
1300
+ field: "network",
1301
+ value
1302
+ });
1303
+ }
1304
+ function createPaymentRequirement(input) {
1305
+ const now = (input.now ?? (() => /* @__PURE__ */ new Date()))();
1306
+ const payTo = requireAddress2(input.payTo, "payTo");
1307
+ const network = requireNetwork(input.network);
1308
+ const asset = input.asset ?? "USDC";
1309
+ if (asset !== "USDC") {
1310
+ throw new ConfigError(`Unsupported asset: ${JSON.stringify(asset)}. Only "USDC" is supported.`, { asset });
1311
+ }
1312
+ parseUsdToMicro(input.amount);
1313
+ const expiresAt = input.expiresAt ?? new Date(now.getTime() + (input.expiresInMs ?? DEFAULT_TTL_MS)).toISOString();
1314
+ const req = {
1315
+ id: input.id ?? prefixedId("preq"),
1316
+ payTo,
1317
+ amount: input.amount,
1318
+ asset,
1319
+ network,
1320
+ expiresAt
1321
+ };
1322
+ if (input.terms !== void 0) req.terms = input.terms;
1323
+ return req;
1324
+ }
1325
+ function serializePaymentRequirement(req) {
1326
+ const body = {
1327
+ id: req.id,
1328
+ payTo: req.payTo,
1329
+ amount: req.amount,
1330
+ asset: req.asset,
1331
+ network: req.network,
1332
+ expiresAt: req.expiresAt
1333
+ };
1334
+ if (req.terms !== void 0) body.terms = req.terms;
1335
+ return body;
1336
+ }
1337
+ function parsePaymentRequirement(input) {
1338
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
1339
+ throw new ConfigError("PaymentRequirement must be a JSON object.", { input });
1340
+ }
1341
+ const o = input;
1342
+ const id = o.id;
1343
+ if (typeof id !== "string" || id.trim() === "") throw new MissingFieldError("id");
1344
+ if (o.asset !== "USDC") {
1345
+ throw new ConfigError(`PaymentRequirement.asset must be "USDC", got: ${JSON.stringify(o.asset)}.`, {
1346
+ asset: o.asset
1347
+ });
1348
+ }
1349
+ const payTo = requireAddress2(o.payTo, "payTo");
1350
+ const network = requireNetwork(o.network);
1351
+ if (typeof o.amount !== "string") throw new InvalidAmountError(o.amount);
1352
+ parseUsdToMicro(o.amount);
1353
+ if (typeof o.expiresAt !== "string" || o.expiresAt.trim() === "") throw new MissingFieldError("expiresAt");
1354
+ if (o.terms !== void 0 && typeof o.terms !== "string") {
1355
+ throw new ConfigError("PaymentRequirement.terms must be a string when present.", { terms: o.terms });
1356
+ }
1357
+ const req = {
1358
+ id,
1359
+ payTo,
1360
+ amount: o.amount,
1361
+ asset: "USDC",
1362
+ network,
1363
+ expiresAt: o.expiresAt
1364
+ };
1365
+ if (o.terms !== void 0) req.terms = o.terms;
1366
+ return req;
1367
+ }
1368
+ function isWalletSignatureAuthorization(auth) {
1369
+ return auth.kind === "wallet-signature";
1370
+ }
1371
+ function isX402PayloadAuthorization(auth) {
1372
+ return auth.kind === "x402-payload";
1373
+ }
1374
+
796
1375
  exports.ApiError = ApiError;
797
1376
  exports.AuthError = AuthError;
798
1377
  exports.CHAIN_ID = CHAIN_ID;
@@ -803,18 +1382,28 @@ exports.InvalidAmountError = InvalidAmountError;
803
1382
  exports.MICRO_PER_USDC = MICRO_PER_USDC;
804
1383
  exports.MissingFieldError = MissingFieldError;
805
1384
  exports.PaymentFailedError = PaymentFailedError;
1385
+ exports.PayoutError = PayoutError;
806
1386
  exports.Playmos = Playmos;
807
1387
  exports.PlaymosError = PlaymosError;
808
1388
  exports.USDC_ADDRESS = USDC_ADDRESS;
809
1389
  exports.USDC_DECIMALS = USDC_DECIMALS;
810
1390
  exports.WalletConnectionError = WalletConnectionError;
811
1391
  exports.computeIapSplit = computeIapSplit;
1392
+ exports.computePayout = computePayout;
812
1393
  exports.computePoolSplit = computePoolSplit;
1394
+ exports.createPaymentRequirement = createPaymentRequirement;
813
1395
  exports.formatMicroToUsd = formatMicroToUsd;
1396
+ exports.isWalletSignatureAuthorization = isWalletSignatureAuthorization;
1397
+ exports.isX402PayloadAuthorization = isX402PayloadAuthorization;
1398
+ exports.parsePaymentRequirement = parsePaymentRequirement;
814
1399
  exports.parseUsdToMicro = parseUsdToMicro;
815
1400
  exports.prefixedId = prefixedId;
1401
+ exports.previewEscrowFee = previewEscrowFee;
816
1402
  exports.previewIapSplit = previewIapSplit;
1403
+ exports.previewMarketplaceSplit = previewMarketplaceSplit;
817
1404
  exports.previewPoolSplit = previewPoolSplit;
1405
+ exports.previewTransferSplit = previewTransferSplit;
1406
+ exports.serializePaymentRequirement = serializePaymentRequirement;
818
1407
  exports.ulid = ulid;
819
1408
  //# sourceMappingURL=index.cjs.map
820
1409
  //# sourceMappingURL=index.cjs.map