@playmos/sdk 0.3.9 → 0.3.11

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.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { N as Network, P as PlaymosConfig, W as WebhookEvent, R as RoundOpenInput, a as RoundState, b as RoundSettleInput, S as SettleRoundResult, c as RoundCancelInput, C as CancelRoundResult, A as ActiveSeriesRound, d as PrizeBalance, e as WithdrawResult, f as AgentWallet, g as AgentFundResult, T as TransferResult, E as EscrowHoldInput, h as EscrowHoldResult, i as EscrowResolveResult, M as MarketplaceListInput, L as Listing, j as MarketplaceSaleResult, k as MarketplaceGetResult, l as PayInput, m as Payment, n as EnterRoundInput, o as TransferReconcile, p as WaitOptions, q as TransferInput, r as TransferConfirmOptions, V as VerifyResult, s as PayoutRule } from './errors-BMlWHsMb.js';
2
- export { t as ActiveForSeriesResult, u as AgentEconomyConfig, v as AlreadyEnteredError, w as ApiError, x as AuthError, y as ConfigError, z as ContractConfig, B as Eip1193Provider, G as GasConfig, D as GasMode, I as InsufficientGasError, F as InvalidAmountError, H as ListingStatus, J as MarketplaceItem, K as MarketplaceSale, O as MissingFieldError, Q as NothingToWithdrawError, U as PaymentFailedError, X as PaymentStatus, Y as PlaymosError, Z as PlaymosErrorCode, _ as RetryOptions, $ as RoundStatus, a0 as WalletConfig, a1 as WalletConnectionError, a2 as WalletConnector, a3 as WalletTimeoutError, a4 as WebhookEventType } from './errors-BMlWHsMb.js';
1
+ import { N as Network, P as PlaymosConfig, W as WebhookEvent, R as RoundOpenInput, a as RoundState, b as RoundSettleInput, S as SettleRoundResult, c as RoundCancelInput, C as CancelRoundResult, d as RoundGetResult, A as ActiveSeriesRound, e as PrizeBalance, f as WithdrawResult, g as AgentWallet, h as AgentFundResult, T as TransferResult, E as EscrowHoldInput, i as EscrowHoldResult, j as EscrowResolveResult, M as MarketplaceListInput, L as Listing, k as MarketplaceSaleResult, l as MarketplaceGetResult, m as PayInput, n as Payment, o as EnterRoundInput, p as TransferReconcile, q as WaitOptions, r as TransferInput, s as TransferConfirmOptions, V as VerifyResult, t as PayoutRule } from './errors-Dpeesyop.js';
2
+ export { u as ActiveForSeriesResult, v as AgentEconomyConfig, w as AlreadyEnteredError, x as ApiError, y as AuthError, z as ConfigError, B as ContractConfig, D as Eip1193Provider, G as GasConfig, F as GasMode, I as InsufficientGasError, H as InvalidAmountError, J as ListingStatus, K as MarketplaceItem, O as MarketplaceSale, Q as MissingFieldError, U as NothingToWithdrawError, X as PaymentFailedError, Y as PaymentStatus, Z as PlaymosError, _ as PlaymosErrorCode, $ as RetryOptions, a0 as RoundGetVia, a1 as RoundStatus, a2 as WalletConfig, a3 as WalletConnectionError, a4 as WalletConnector, a5 as WalletTimeoutError, a6 as WebhookEventType } from './errors-Dpeesyop.js';
3
3
 
4
4
  /**
5
5
  * Environment resolution + the canonical address book.
@@ -518,9 +518,22 @@ declare class Playmos {
518
518
  * if still pending — never invents terminal success).
519
519
  */
520
520
  cancel: (input: RoundCancelInput) => Promise<CancelRoundResult>;
521
+ /**
522
+ * Round state only (stable). Prefer {@link getWithMeta} when you need service
523
+ * read-path honesty (`via` / `chainReadFailed` — #497 / #501).
524
+ */
521
525
  get: (input: {
522
526
  roundId: string;
523
527
  }) => Promise<RoundState>;
528
+ /**
529
+ * Round state + transport meta from GET /v1/rounds/:id (#501).
530
+ * Does **not** attach `via` onto RoundState (Claude rejected option B — hub would
531
+ * silently pick it up and leak into settle/cancel paths that call get()).
532
+ * Under `mock: true`, meta fields are **omitted** (no eth_call; do not invent "cache").
533
+ */
534
+ getWithMeta: (input: {
535
+ roundId: string;
536
+ }) => Promise<RoundGetResult>;
524
537
  /**
525
538
  * Authoritative series latch (#351) — `null` means **proven free**.
526
539
  * `GET /v1/series/:seriesKey/active?gameId=…` — not a candidate probe.
@@ -550,6 +563,11 @@ declare class Playmos {
550
563
  withdraw: (input: {
551
564
  prizePoolAddress?: `0x${string}`;
552
565
  roundId?: string;
566
+ /**
567
+ * Winner wallet (0x…). **Required under mock** (#495 Claude) — live path uses the
568
+ * connected provider. Without this, mock paid the first non-zero credit to anyone.
569
+ */
570
+ wallet?: string;
553
571
  /** Pre-check claimable; default true. Set false only if you already called `prize()`. */
554
572
  checkBalance?: boolean;
555
573
  }) => Promise<WithdrawResult>;
@@ -637,8 +655,16 @@ declare class Playmos {
637
655
  /**
638
656
  * Offline mock payments for this client instance — so `verify(id)` after
639
657
  * `mock: true` pay/enterRound does not hit the live API (#204).
658
+ * Keyed by payment id for verify.
640
659
  */
641
660
  private readonly mockPayments;
661
+ /**
662
+ * sdk#513 / residual B1–B2 — held idempotencyKey offline (live contract rehearsal).
663
+ * Keyed like service: `${gameId}:${key}` (IAP without gameId uses empty gameId).
664
+ * Stores terms so same key + different money throws (live IdempotencyConflict), not silent replay.
665
+ * Auto-minted keys still unique per call (not stored for cross-call replay).
666
+ */
667
+ private readonly mockByIdempotencyKey;
642
668
  constructor(config: PlaymosConfig);
643
669
  /**
644
670
  * Wallet timeout policy (#462 / PR #463 residual).
@@ -677,7 +703,10 @@ declare class Playmos {
677
703
  identity?: string;
678
704
  }>;
679
705
  };
706
+ private mockIdemScopeKey;
680
707
  private rememberMock;
708
+ /** Live-shaped conflict when held mock key is reused with different money terms (sdk#513 residual B1). */
709
+ private mockIdemReplayOrThrow;
681
710
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
682
711
  pay(input: PayInput): Promise<Payment>;
683
712
  /** Playmos-owned skill-game entry (10%, 60/30/10 prize pool). Closes #343. */
@@ -741,12 +770,22 @@ declare class Playmos {
741
770
  * the documented "confirmed" sandbox promise (TTFSC-T0 / cold-run).
742
771
  */
743
772
  private waitForServerSettle;
773
+ /**
774
+ * sdk#509 — recover a payment after a lost response by the idempotency key you held.
775
+ * Requires secret sk_test_ (same as list). Returns null if not found.
776
+ */
777
+ findPaymentByIdempotencyKey(idempotencyKey: string, opts?: {
778
+ gameId?: string;
779
+ }): Promise<Payment | null>;
744
780
  /**
745
781
  * Map a server-settled payment (from the `settle: "server"` response) into the
746
782
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
747
- * pass its authoritative `status`/`txHash`/amounts straight through and, for an
748
- * entry, derive the 60/30/10 USD `split` locally (exactly as the mock + the
749
- * contract compute it) from the parsed amount.
783
+ * pass its authoritative `status`/`txHash`/amounts straight through.
784
+ *
785
+ * For entry, `Payment.split` is a **first-party 60/30/10 projection** from the
786
+ * request amount using SDK BPS constants (same integer math as mock) — not a
787
+ * chain read of pool constructor bps or post-lock payable pot (sdk#514 residual N1).
788
+ * Prefer `chainAmount` / on-chain `getRound` for money truth.
750
789
  */
751
790
  private mapServerPayment;
752
791
  /** Prefer the service's authoritative micro-USDC amount; fall back to the
@@ -820,8 +859,8 @@ declare function previewPoolSplit(amount: string): {
820
859
  */
821
860
 
822
861
  declare class PayoutError extends Error {
823
- code: "payout_invalid";
824
- constructor(message: string);
862
+ code: string;
863
+ constructor(message: string, code?: string);
825
864
  }
826
865
  /**
827
866
  * Apply the studio's payout rule to a payable pool and ranking (best-first).
@@ -890,4 +929,4 @@ declare function ulid(seedTime?: number): string;
890
929
  /** `${prefix}_${ulid()}` — e.g. `pay_01J…`, `entry_01J…`, `idem_01J…`. */
891
930
  declare function prefixedId(prefix: string): string;
892
931
 
893
- export { ActiveSeriesRound, AgentFundResult, AgentWallet, type Authorization, CHAIN_ID, CancelRoundResult, type CreatePaymentRequirementInput, DEFAULT_API_BASE_URL, EnterRoundInput, EscrowHoldInput, EscrowHoldResult, EscrowResolveResult, Listing, MICRO_PER_USDC, MarketplaceGetResult, MarketplaceListInput, MarketplaceSaleResult, Network, PayInput, Payment, type PaymentRequirement, PayoutError, PayoutRule, PayoutRule as PayoutRuleCompute, Playmos, PlaymosConfig, PrizeBalance, RoundCancelInput, RoundOpenInput, RoundSettleInput, RoundState, SettleRoundResult, type SettlementAsset, TransferConfirmOptions, TransferInput, TransferReconcile, TransferResult, USDC_ADDRESS, USDC_DECIMALS, VerifyResult, WaitOptions, type WalletSignatureAuthorization, WebhookEvent, WithdrawResult, type X402ChallengeInput, type X402ChallengeResult, type X402FulfillAuthorization, type X402PayInput, type X402PayloadAuthorization, type X402PayloadMode, type X402PaymentRequired, type X402SettleResult, clientRuntimeSignals, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isClientRuntime, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, resolveEnv, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
932
+ export { ActiveSeriesRound, AgentFundResult, AgentWallet, type Authorization, CHAIN_ID, CancelRoundResult, type CreatePaymentRequirementInput, DEFAULT_API_BASE_URL, EnterRoundInput, EscrowHoldInput, EscrowHoldResult, EscrowResolveResult, Listing, MICRO_PER_USDC, MarketplaceGetResult, MarketplaceListInput, MarketplaceSaleResult, Network, PayInput, Payment, type PaymentRequirement, PayoutError, PayoutRule, PayoutRule as PayoutRuleCompute, Playmos, PlaymosConfig, PrizeBalance, RoundCancelInput, RoundGetResult, RoundOpenInput, RoundSettleInput, RoundState, SettleRoundResult, type SettlementAsset, TransferConfirmOptions, TransferInput, TransferReconcile, TransferResult, USDC_ADDRESS, USDC_DECIMALS, VerifyResult, WaitOptions, type WalletSignatureAuthorization, WebhookEvent, WithdrawResult, type X402ChallengeInput, type X402ChallengeResult, type X402FulfillAuthorization, type X402PayInput, type X402PayloadAuthorization, type X402PayloadMode, type X402PaymentRequired, type X402SettleResult, clientRuntimeSignals, computeIapSplit, computePayout, computePoolSplit, createPaymentRequirement, createX402Challenge, decodePaymentHeader, encodePaymentHeader, formatMicroToUsd, isClientRuntime, isWalletSignatureAuthorization, isX402PayloadAuthorization, networkToCaip2, parsePaymentRequirement, parseUsdToMicro, prefixedId, previewEscrowFee, previewIapSplit, previewMarketplaceSplit, previewPoolSplit, previewTransferSplit, resolveEnv, serializePaymentRequirement, toX402PaymentRequired, ulid, validateX402ChallengeInput };
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, WalletTimeoutError, PaymentFailedError, ApiError, WalletConnectionError, InsufficientGasError, AlreadyEnteredError } from './chunk-UZECDT6F.js';
2
- export { AlreadyEnteredError, ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError, WalletTimeoutError } from './chunk-UZECDT6F.js';
3
- import { encodeFunctionData, decodeFunctionResult, numberToHex, keccak256, toBytes } from 'viem';
1
+ import { AuthError, InvalidAmountError, ConfigError, MissingFieldError, NothingToWithdrawError, assertEnoughGas, buildWithdrawCall, sendCalls, waitForCalls, WalletTimeoutError, PaymentFailedError, ApiError, buildIapCalls, buildEntryCalls, prizePoolAbi, WalletConnectionError, AlreadyEnteredError } from './chunk-35WJANW4.js';
2
+ export { AlreadyEnteredError, ApiError, AuthError, ConfigError, InsufficientGasError, InvalidAmountError, MissingFieldError, NothingToWithdrawError, PaymentFailedError, PlaymosError, WalletConnectionError, WalletTimeoutError } from './chunk-35WJANW4.js';
3
+ import { encodeFunctionData, decodeFunctionResult } from 'viem';
4
4
 
5
5
  // src/config.ts
6
6
  var CHAIN_ID = {
@@ -183,10 +183,10 @@ function validateMetadata(metadata) {
183
183
 
184
184
  // src/payout.ts
185
185
  var PayoutError = class extends Error {
186
- constructor(message) {
186
+ constructor(message, code = "payout_invalid") {
187
187
  super(message);
188
- this.code = "payout_invalid";
189
188
  this.name = "PayoutError";
189
+ this.code = code;
190
190
  }
191
191
  };
192
192
  var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
@@ -231,7 +231,8 @@ function computePayout(pool, ranking, rule) {
231
231
  }
232
232
  if (splits.length > wallets.length) {
233
233
  throw new PayoutError(
234
- `top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
234
+ `ranking_too_short: a top-n rule cannot be settled from a ranking shorter than splitsBps.length (need ${splits.length}, got ${wallets.length}) \u2014 open with winner-take-all, or settle with explicit winners[{wallet,amount}]`,
235
+ "ranking_too_short"
235
236
  );
236
237
  }
237
238
  let sumBps = 0;
@@ -544,7 +545,7 @@ function resolveRawProvider(wallet) {
544
545
  }
545
546
  if (injected) return injected;
546
547
  throw new WalletConnectionError(
547
- "base-account connector needs a provider. In the Base App it is injected automatically; elsewhere, create one with @base-org/account and pass it as wallet.provider."
548
+ 'base-account connector needs a provider \u2014 this is not a built-in Base Account/passkey flow in @playmos/sdk. In the Base App a provider is often injected; elsewhere install @base-org/account, construct a provider, and pass it as wallet.provider (or use connector: "injected" in a wallet browser).'
548
549
  );
549
550
  }
550
551
  function resolveProvider(wallet, opts) {
@@ -581,174 +582,6 @@ async function getAccount(provider) {
581
582
  }
582
583
  }
583
584
 
584
- // src/chain/abis.ts
585
- var erc20Abi = [
586
- {
587
- type: "function",
588
- name: "approve",
589
- stateMutability: "nonpayable",
590
- inputs: [
591
- { name: "spender", type: "address" },
592
- { name: "amount", type: "uint256" }
593
- ],
594
- outputs: [{ type: "bool" }]
595
- }
596
- ];
597
- var playmosPayAbi = [
598
- {
599
- type: "function",
600
- name: "pay",
601
- stateMutability: "nonpayable",
602
- inputs: [
603
- { name: "paymentId", type: "bytes32" },
604
- { name: "studio", type: "address" },
605
- { name: "amount", type: "uint256" }
606
- ],
607
- outputs: []
608
- }
609
- ];
610
- var prizePoolAbi = [
611
- {
612
- type: "function",
613
- name: "enter",
614
- stateMutability: "nonpayable",
615
- inputs: [
616
- { name: "roundId", type: "bytes32" },
617
- { name: "identity", type: "bytes32" }
618
- ],
619
- outputs: []
620
- },
621
- {
622
- type: "function",
623
- name: "withdrawable",
624
- stateMutability: "view",
625
- inputs: [{ name: "account", type: "address" }],
626
- outputs: [{ type: "uint256" }]
627
- },
628
- {
629
- type: "function",
630
- name: "withdraw",
631
- stateMutability: "nonpayable",
632
- inputs: [],
633
- outputs: [{ name: "amount", type: "uint256" }]
634
- }
635
- ];
636
- async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
637
- const params = {
638
- version: "2.0.0",
639
- from,
640
- chainId: numberToHex(chainId),
641
- atomicRequired: true,
642
- calls
643
- };
644
- if (paymasterUrl) {
645
- params.capabilities = { paymasterService: { url: paymasterUrl } };
646
- }
647
- let result;
648
- try {
649
- result = await provider.request({ method: "wallet_sendCalls", params: [params] });
650
- } catch (e) {
651
- if (e instanceof WalletTimeoutError) throw e;
652
- const msg = e?.message ?? String(e);
653
- if (/reject|denied|cancel|closed/i.test(msg)) {
654
- throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
655
- }
656
- throw new PaymentFailedError("wallet_sendCalls failed.", { cause: msg });
657
- }
658
- const id = typeof result === "string" ? result : result?.id ?? "";
659
- if (!id) throw new PaymentFailedError("Wallet returned no calls id.");
660
- return { id };
661
- }
662
- async function waitForCalls(provider, id, timeoutMs = 6e4) {
663
- if (!id) return { status: "FAILED" };
664
- const deadline = Date.now() + timeoutMs;
665
- while (Date.now() < deadline) {
666
- try {
667
- const res = await provider.request({
668
- method: "wallet_getCallsStatus",
669
- params: [id]
670
- });
671
- const s = String(res?.status ?? "").toUpperCase();
672
- const txHash = res?.receipts?.[0]?.transactionHash;
673
- if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
674
- if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
675
- } catch (e) {
676
- if (e instanceof WalletTimeoutError) {
677
- await new Promise((r) => setTimeout(r, 900));
678
- continue;
679
- }
680
- throw e;
681
- }
682
- await new Promise((r) => setTimeout(r, 900));
683
- }
684
- return { status: "PENDING" };
685
- }
686
- function encodeApprove(spender, amountUnits) {
687
- return encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
688
- }
689
- var GAS_FLOOR_WEI_DEFAULT = 200000000000000n;
690
- var GAS_FLOOR_WEI_BASE_SEPOLIA = 50000000000000n;
691
- var CHAIN_ID_BASE_SEPOLIA = 84532;
692
- function gasFloorWei(chainId) {
693
- return chainId === CHAIN_ID_BASE_SEPOLIA ? GAS_FLOOR_WEI_BASE_SEPOLIA : GAS_FLOOR_WEI_DEFAULT;
694
- }
695
- async function assertEnoughGas(provider, from, minWei) {
696
- let floor = minWei;
697
- if (floor === void 0) {
698
- let chainId = 0;
699
- try {
700
- const hex = await provider.request({ method: "eth_chainId", params: [] });
701
- chainId = Number(BigInt(hex));
702
- } catch {
703
- }
704
- floor = gasFloorWei(chainId);
705
- }
706
- let balance;
707
- try {
708
- const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
709
- balance = BigInt(hex);
710
- } catch {
711
- return;
712
- }
713
- if (balance < floor) {
714
- throw new InsufficientGasError({
715
- balanceWei: balance.toString(),
716
- minWei: floor.toString()
717
- });
718
- }
719
- }
720
- var toBytes32 = (s) => keccak256(toBytes(s));
721
- function buildIapCalls(args) {
722
- const payData = encodeFunctionData({
723
- abi: playmosPayAbi,
724
- functionName: "pay",
725
- args: [toBytes32(args.paymentId), args.studio, args.amountUnits]
726
- });
727
- return [
728
- { to: args.usdc, data: encodeApprove(args.playmosPay, args.amountUnits) },
729
- { to: args.playmosPay, data: payData }
730
- ];
731
- }
732
- function buildEntryCalls(args) {
733
- const enterData = encodeFunctionData({
734
- abi: prizePoolAbi,
735
- functionName: "enter",
736
- args: [toBytes32(args.roundKey), toBytes32(args.identity)]
737
- });
738
- return [
739
- { to: args.usdc, data: encodeApprove(args.prizePool, args.amountUnits) },
740
- { to: args.prizePool, data: enterData }
741
- ];
742
- }
743
- function buildWithdrawCall(prizePool) {
744
- const data = encodeFunctionData({
745
- abi: prizePoolAbi,
746
- functionName: "withdraw",
747
- args: []
748
- });
749
- return { to: prizePool, data };
750
- }
751
-
752
585
  // src/mock.ts
753
586
  var MOCK_TX = `0x${"0".repeat(56)}deadbeef`;
754
587
  function mockIapPayment(args) {
@@ -1530,19 +1363,34 @@ var Playmos = class {
1530
1363
  }
1531
1364
  return { ...result, txHash: result.txHash ?? firstTx ?? null };
1532
1365
  },
1366
+ /**
1367
+ * Round state only (stable). Prefer {@link getWithMeta} when you need service
1368
+ * read-path honesty (`via` / `chainReadFailed` — #497 / #501).
1369
+ */
1533
1370
  get: async (input) => {
1371
+ const meta = await this.rounds.getWithMeta(input);
1372
+ return meta.round;
1373
+ },
1374
+ /**
1375
+ * Round state + transport meta from GET /v1/rounds/:id (#501).
1376
+ * Does **not** attach `via` onto RoundState (Claude rejected option B — hub would
1377
+ * silently pick it up and leak into settle/cancel paths that call get()).
1378
+ * Under `mock: true`, meta fields are **omitted** (no eth_call; do not invent "cache").
1379
+ */
1380
+ getWithMeta: async (input) => {
1534
1381
  requireField(input?.roundId, "roundId");
1535
1382
  if (this.config.mock) {
1536
1383
  const existing = this.mockRounds.get(input.roundId);
1537
1384
  if (!existing) {
1538
1385
  throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
1539
1386
  }
1540
- return existing;
1387
+ return { round: existing };
1541
1388
  }
1542
- const { round } = await this.http.get(
1543
- `/rounds/${encodeURIComponent(input.roundId)}`
1544
- );
1545
- return round;
1389
+ const body = await this.http.get(`/rounds/${encodeURIComponent(input.roundId)}`);
1390
+ const out = { round: body.round };
1391
+ if (body.via != null) out.via = body.via;
1392
+ if (body.chainReadFailed === true) out.chainReadFailed = true;
1393
+ return out;
1546
1394
  },
1547
1395
  /**
1548
1396
  * Authoritative series latch (#351) — `null` means **proven free**.
@@ -1647,19 +1495,26 @@ var Playmos = class {
1647
1495
  */
1648
1496
  withdraw: async (input) => {
1649
1497
  if (this.config.mock) {
1650
- let amountMicro2 = 0n;
1651
1498
  let prizePool2 = input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001";
1652
1499
  if (input.roundId) {
1653
1500
  const existing = this.mockRounds.get(input.roundId);
1654
1501
  if (existing?.prizePoolAddress) prizePool2 = existing.prizePoolAddress;
1655
- for (const [k, v] of this.mockWithdrawable) {
1656
- if (k.startsWith(`${input.roundId}:`) && v > 0n) {
1657
- amountMicro2 = v;
1658
- this.mockWithdrawable.set(k, 0n);
1659
- break;
1660
- }
1661
- }
1662
1502
  }
1503
+ if (!input.wallet || !ADDRESS_RE3.test(input.wallet)) {
1504
+ throw new ConfigError(
1505
+ "mock rounds.withdraw requires wallet (0x\u2026) \u2014 live uses the connected provider; without wallet mock would pay the first non-zero credit to the wrong player (#495)"
1506
+ );
1507
+ }
1508
+ const wallet = input.wallet.toLowerCase();
1509
+ if (!input.roundId) {
1510
+ throw new ConfigError("mock rounds.withdraw requires roundId to locate claimable credits");
1511
+ }
1512
+ const key = `${input.roundId}:${wallet}`;
1513
+ const amountMicro2 = this.mockWithdrawable.get(key) ?? 0n;
1514
+ if (amountMicro2 === 0n) {
1515
+ throw new NothingToWithdrawError({ prizePoolAddress: prizePool2, wallet });
1516
+ }
1517
+ this.mockWithdrawable.set(key, 0n);
1663
1518
  return {
1664
1519
  prizePoolAddress: prizePool2,
1665
1520
  amount: formatMicroToUsd(amountMicro2),
@@ -1931,8 +1786,16 @@ var Playmos = class {
1931
1786
  /**
1932
1787
  * Offline mock payments for this client instance — so `verify(id)` after
1933
1788
  * `mock: true` pay/enterRound does not hit the live API (#204).
1789
+ * Keyed by payment id for verify.
1934
1790
  */
1935
1791
  this.mockPayments = /* @__PURE__ */ new Map();
1792
+ /**
1793
+ * sdk#513 / residual B1–B2 — held idempotencyKey offline (live contract rehearsal).
1794
+ * Keyed like service: `${gameId}:${key}` (IAP without gameId uses empty gameId).
1795
+ * Stores terms so same key + different money throws (live IdempotencyConflict), not silent replay.
1796
+ * Auto-minted keys still unique per call (not stored for cross-call replay).
1797
+ */
1798
+ this.mockByIdempotencyKey = /* @__PURE__ */ new Map();
1936
1799
  /**
1937
1800
  * `transfers` — read-back / confirmation for a prior `transfer()` (issues #27, #47).
1938
1801
  * `get` reconciles once against chain truth; `wait` polls it to a terminal state
@@ -2117,18 +1980,62 @@ var Playmos = class {
2117
1980
  }
2118
1981
  };
2119
1982
  }
2120
- rememberMock(payment) {
2121
- if (payment.mock) this.mockPayments.set(payment.id, payment);
1983
+ mockIdemScopeKey(gameId, heldKey) {
1984
+ return `${gameId ?? ""}:${heldKey}`;
1985
+ }
1986
+ rememberMock(payment, held) {
1987
+ if (payment.mock) {
1988
+ this.mockPayments.set(payment.id, payment);
1989
+ if (held) {
1990
+ this.mockByIdempotencyKey.set(this.mockIdemScopeKey(held.terms.gameId, held.key), {
1991
+ payment,
1992
+ terms: held.terms
1993
+ });
1994
+ }
1995
+ }
2122
1996
  return payment;
2123
1997
  }
1998
+ /** Live-shaped conflict when held mock key is reused with different money terms (sdk#513 residual B1). */
1999
+ mockIdemReplayOrThrow(heldKey, terms) {
2000
+ const prior = this.mockByIdempotencyKey.get(this.mockIdemScopeKey(terms.gameId, heldKey));
2001
+ if (!prior) return null;
2002
+ const t = prior.terms;
2003
+ const same = t.kind === terms.kind && t.amount === terms.amount && t.gameId === terms.gameId && t.playerId === terms.playerId && t.roundId === terms.roundId && t.identity === terms.identity && t.sku === terms.sku;
2004
+ if (!same) {
2005
+ throw new ApiError(
2006
+ `idempotencyKey was already used for a payment with DIFFERENT terms (kind/amount/gameId/playerId/roundId/identity/sku); use a fresh key (mock:true mirrors live conflict \u2014 sdk#513).`,
2007
+ {
2008
+ code: "idempotency_conflict",
2009
+ idempotencyKey: heldKey,
2010
+ priorKind: t.kind,
2011
+ priorAmount: t.amount
2012
+ }
2013
+ );
2014
+ }
2015
+ return prior.payment;
2016
+ }
2124
2017
  /** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
2125
2018
  async pay(input) {
2126
2019
  const amountMicro = validateAmount(input.amount);
2127
2020
  requireField(input.sku, "sku");
2128
2021
  requireField(input.playerId, "playerId");
2129
2022
  const metadata = validateMetadata(input.metadata);
2130
- const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
2023
+ const heldIdem = typeof input.idempotencyKey === "string" && input.idempotencyKey.trim() ? input.idempotencyKey.trim() : void 0;
2024
+ const idempotencyKey = heldIdem ?? prefixedId("idem");
2131
2025
  if (this.config.mock) {
2026
+ const terms = {
2027
+ kind: "iap",
2028
+ amount: formatMicroToUsd(amountMicro),
2029
+ gameId: input.gameId ?? "",
2030
+ playerId: input.playerId,
2031
+ roundId: "",
2032
+ identity: "",
2033
+ sku: input.sku.trim()
2034
+ };
2035
+ if (heldIdem) {
2036
+ const replay = this.mockIdemReplayOrThrow(heldIdem, terms);
2037
+ if (replay) return replay;
2038
+ }
2132
2039
  return this.rememberMock(
2133
2040
  mockIapPayment({
2134
2041
  amountMicro,
@@ -2137,7 +2044,8 @@ var Playmos = class {
2137
2044
  playerId: input.playerId,
2138
2045
  chain: this.env.network,
2139
2046
  metadata
2140
- })
2047
+ }),
2048
+ heldIdem ? { key: heldIdem, terms } : void 0
2141
2049
  );
2142
2050
  }
2143
2051
  if (this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy())) {
@@ -2159,7 +2067,13 @@ var Playmos = class {
2159
2067
  );
2160
2068
  let payment = this.mapServerPayment(res.payment, "iap", amountMicro);
2161
2069
  if (payment.status === "pending" || payment.status === "created") {
2162
- payment = await this.waitForServerSettle(payment.id, "iap", amountMicro);
2070
+ payment = await this.waitForServerSettle(
2071
+ payment.id,
2072
+ "iap",
2073
+ amountMicro,
2074
+ input.settleTimeoutMs ?? 6e4,
2075
+ input.signal
2076
+ );
2163
2077
  }
2164
2078
  return payment;
2165
2079
  }
@@ -2204,8 +2118,22 @@ var Playmos = class {
2204
2118
  requireField(input.roundId, "roundId");
2205
2119
  requireField(input.playerId, "playerId");
2206
2120
  const metadata = validateMetadata(input.metadata);
2207
- const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
2121
+ const heldIdem = typeof input.idempotencyKey === "string" && input.idempotencyKey.trim() ? input.idempotencyKey.trim() : void 0;
2122
+ const idempotencyKey = heldIdem ?? prefixedId("idem");
2208
2123
  if (this.config.mock) {
2124
+ const terms = {
2125
+ kind: "entry",
2126
+ amount: formatMicroToUsd(amountMicro),
2127
+ gameId: input.gameId,
2128
+ playerId: input.playerId,
2129
+ roundId: input.roundId,
2130
+ identity: typeof input.identity === "string" ? input.identity.trim() : "",
2131
+ sku: ""
2132
+ };
2133
+ if (heldIdem) {
2134
+ const replay = this.mockIdemReplayOrThrow(heldIdem, terms);
2135
+ if (replay) return replay;
2136
+ }
2209
2137
  this.mockAccumulateEntry(input.roundId, amountMicro);
2210
2138
  return this.rememberMock(
2211
2139
  mockEntryPayment({
@@ -2221,7 +2149,8 @@ var Playmos = class {
2221
2149
  // B2 pins — must survive mock path for hub hasEntered parity (#204).
2222
2150
  roundKey: input.roundKey,
2223
2151
  identity: input.identity
2224
- })
2152
+ }),
2153
+ heldIdem ? { key: heldIdem, terms } : void 0
2225
2154
  );
2226
2155
  }
2227
2156
  const serverSettleNoWallet = this.env.isTest && !await walletAvailable(this.config.wallet, this.walletTimeoutPolicy());
@@ -2254,7 +2183,13 @@ var Playmos = class {
2254
2183
  );
2255
2184
  let payment2 = this.mapServerPayment(res.payment, "entry", amountMicro);
2256
2185
  if (payment2.status === "pending" || payment2.status === "created") {
2257
- payment2 = await this.waitForServerSettle(payment2.id, "entry", amountMicro);
2186
+ payment2 = await this.waitForServerSettle(
2187
+ payment2.id,
2188
+ "entry",
2189
+ amountMicro,
2190
+ input.settleTimeoutMs ?? 6e4,
2191
+ input.signal
2192
+ );
2258
2193
  }
2259
2194
  if (input.identity) payment2.identity = input.identity;
2260
2195
  const pool = res.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
@@ -2437,10 +2372,16 @@ var Playmos = class {
2437
2372
  * until chain verify catches PaymentSettled (RPC lag) — poll so pay() matches
2438
2373
  * the documented "confirmed" sandbox promise (TTFSC-T0 / cold-run).
2439
2374
  */
2440
- async waitForServerSettle(paymentId, kind, amountMicro, timeoutMs = 6e4) {
2375
+ async waitForServerSettle(paymentId, kind, amountMicro, timeoutMs = 6e4, signal) {
2441
2376
  const start = Date.now();
2442
2377
  let delayMs = 400;
2443
2378
  while (Date.now() - start < timeoutMs) {
2379
+ if (signal?.aborted) {
2380
+ throw new ApiError(
2381
+ `Aborted waiting for server-settle of ${paymentId}. Re-check with playmos.verify("${paymentId}").`,
2382
+ { paymentId, aborted: true, asyncSettle: true }
2383
+ );
2384
+ }
2444
2385
  const raw = await this.http.get(
2445
2386
  `/payments/${encodeURIComponent(paymentId)}`
2446
2387
  );
@@ -2455,12 +2396,46 @@ var Playmos = class {
2455
2396
  { paymentId, timeout: true, asyncSettle: true }
2456
2397
  );
2457
2398
  }
2399
+ /**
2400
+ * sdk#509 — recover a payment after a lost response by the idempotency key you held.
2401
+ * Requires secret sk_test_ (same as list). Returns null if not found.
2402
+ */
2403
+ async findPaymentByIdempotencyKey(idempotencyKey, opts) {
2404
+ requireField(idempotencyKey, "idempotencyKey");
2405
+ if (this.config.mock) {
2406
+ return null;
2407
+ }
2408
+ const q = new URLSearchParams({ idempotencyKey });
2409
+ if (opts?.gameId) q.set("gameId", opts.gameId);
2410
+ try {
2411
+ const res = await this.http.get(
2412
+ `/payments?${q.toString()}`
2413
+ );
2414
+ const row = res.data?.[0];
2415
+ if (!row || typeof row.id !== "string") return null;
2416
+ const kind = row.kind === "entry" ? "entry" : "iap";
2417
+ const amountMicro = validateAmount(String(row.amount ?? "0.01"));
2418
+ return this.mapServerPayment(
2419
+ row,
2420
+ kind,
2421
+ amountMicro
2422
+ );
2423
+ } catch (e) {
2424
+ if (e instanceof ApiError && (e.detail?.status === 404 || e.detail?.code === "not_found" || /not found/i.test(e.message))) {
2425
+ return null;
2426
+ }
2427
+ throw e;
2428
+ }
2429
+ }
2458
2430
  /**
2459
2431
  * Map a server-settled payment (from the `settle: "server"` response) into the
2460
2432
  * SDK's `Payment` shape. The service already signed + confirmed on-chain, so we
2461
- * pass its authoritative `status`/`txHash`/amounts straight through and, for an
2462
- * entry, derive the 60/30/10 USD `split` locally (exactly as the mock + the
2463
- * contract compute it) from the parsed amount.
2433
+ * pass its authoritative `status`/`txHash`/amounts straight through.
2434
+ *
2435
+ * For entry, `Payment.split` is a **first-party 60/30/10 projection** from the
2436
+ * request amount using SDK BPS constants (same integer math as mock) — not a
2437
+ * chain read of pool constructor bps or post-lock payable pot (sdk#514 residual N1).
2438
+ * Prefer `chainAmount` / on-chain `getRound` for money truth.
2464
2439
  */
2465
2440
  mapServerPayment(p, kind, amountMicro) {
2466
2441
  const payment = {