@playmos/sdk 0.3.8 → 0.3.10
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/{errors-BMlWHsMb.d.cts → errors-Chizbb96.d.cts} +14 -1
- package/dist/{errors-BMlWHsMb.d.ts → errors-Chizbb96.d.ts} +14 -1
- package/dist/index.cjs +281 -152
- package/dist/index.d.cts +54 -3
- package/dist/index.d.ts +54 -3
- package/dist/index.js +281 -152
- package/dist/server.d.cts +1 -1
- package/dist/server.d.ts +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -181,6 +181,112 @@ function validateMetadata(metadata) {
|
|
|
181
181
|
return out;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
// src/payout.ts
|
|
185
|
+
var PayoutError = class extends Error {
|
|
186
|
+
constructor(message) {
|
|
187
|
+
super(message);
|
|
188
|
+
this.code = "payout_invalid";
|
|
189
|
+
this.name = "PayoutError";
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
193
|
+
var BPS = 10000n;
|
|
194
|
+
function requireAddress(w, i) {
|
|
195
|
+
if (!ADDRESS_RE.test(w)) {
|
|
196
|
+
throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
|
|
197
|
+
}
|
|
198
|
+
return w.toLowerCase();
|
|
199
|
+
}
|
|
200
|
+
function parseUsdToMicroLoose(amount) {
|
|
201
|
+
if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
|
|
202
|
+
throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
|
|
203
|
+
}
|
|
204
|
+
const [wholeRaw, frac = ""] = amount.trim().split(".");
|
|
205
|
+
const whole = wholeRaw ?? "0";
|
|
206
|
+
const fracPadded = (frac + "000000").slice(0, 6);
|
|
207
|
+
const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
|
|
208
|
+
if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
|
|
209
|
+
return micro;
|
|
210
|
+
}
|
|
211
|
+
function computePayout(pool, ranking, rule) {
|
|
212
|
+
if (typeof pool !== "bigint" || pool <= 0n) {
|
|
213
|
+
throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
|
|
214
|
+
}
|
|
215
|
+
if (!Array.isArray(ranking) || ranking.length === 0) {
|
|
216
|
+
throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
|
|
217
|
+
}
|
|
218
|
+
const wallets = ranking.map((w, i) => requireAddress(w, i));
|
|
219
|
+
const seen = /* @__PURE__ */ new Set();
|
|
220
|
+
for (const w of wallets) {
|
|
221
|
+
if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
|
|
222
|
+
seen.add(w);
|
|
223
|
+
}
|
|
224
|
+
if (rule.kind === "winner-take-all") {
|
|
225
|
+
return [{ wallet: wallets[0], amount: pool }];
|
|
226
|
+
}
|
|
227
|
+
if (rule.kind === "top-n") {
|
|
228
|
+
const splits = rule.splitsBps;
|
|
229
|
+
if (!Array.isArray(splits) || splits.length === 0) {
|
|
230
|
+
throw new PayoutError("top-n splitsBps must be a non-empty array");
|
|
231
|
+
}
|
|
232
|
+
if (splits.length > wallets.length) {
|
|
233
|
+
throw new PayoutError(
|
|
234
|
+
`top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
let sumBps = 0;
|
|
238
|
+
for (const b of splits) {
|
|
239
|
+
if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
|
|
240
|
+
throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
|
|
241
|
+
}
|
|
242
|
+
sumBps += b;
|
|
243
|
+
}
|
|
244
|
+
if (sumBps !== 1e4) {
|
|
245
|
+
throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
|
|
246
|
+
}
|
|
247
|
+
const out = [];
|
|
248
|
+
let allocated = 0n;
|
|
249
|
+
for (let i = 0; i < splits.length; i++) {
|
|
250
|
+
const amt = pool * BigInt(splits[i]) / BPS;
|
|
251
|
+
out.push({ wallet: wallets[i], amount: amt });
|
|
252
|
+
allocated += amt;
|
|
253
|
+
}
|
|
254
|
+
const remainder = pool - allocated;
|
|
255
|
+
if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
|
|
256
|
+
out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
|
|
257
|
+
const filtered = out.filter((x) => x.amount > 0n);
|
|
258
|
+
if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
|
|
259
|
+
const total = filtered.reduce((s, x) => s + x.amount, 0n);
|
|
260
|
+
if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
|
|
261
|
+
return filtered;
|
|
262
|
+
}
|
|
263
|
+
if (rule.kind === "custom") {
|
|
264
|
+
const amounts = rule.amounts;
|
|
265
|
+
if (!Array.isArray(amounts) || amounts.length === 0) {
|
|
266
|
+
throw new PayoutError("custom amounts must be a non-empty array of USD strings");
|
|
267
|
+
}
|
|
268
|
+
if (amounts.length > wallets.length) {
|
|
269
|
+
throw new PayoutError(
|
|
270
|
+
`custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const out = [];
|
|
274
|
+
let total = 0n;
|
|
275
|
+
for (let i = 0; i < amounts.length; i++) {
|
|
276
|
+
const amt = parseUsdToMicroLoose(amounts[i]);
|
|
277
|
+
out.push({ wallet: wallets[i], amount: amt });
|
|
278
|
+
total += amt;
|
|
279
|
+
}
|
|
280
|
+
if (total !== pool) {
|
|
281
|
+
throw new PayoutError(
|
|
282
|
+
`custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
|
|
288
|
+
}
|
|
289
|
+
|
|
184
290
|
// src/http.ts
|
|
185
291
|
function resolveRetry(retry) {
|
|
186
292
|
const off = { maxRetries: 0, baseDelayMs: 500, maxDelayMs: 2e4 };
|
|
@@ -704,12 +810,12 @@ function mockVerifyResult(payment) {
|
|
|
704
810
|
}
|
|
705
811
|
|
|
706
812
|
// src/x402.ts
|
|
707
|
-
var
|
|
708
|
-
function
|
|
813
|
+
var ADDRESS_RE2 = /^0x[0-9a-fA-F]{40}$/;
|
|
814
|
+
function requireAddress2(value, field) {
|
|
709
815
|
if (typeof value !== "string" || value.trim() === "") {
|
|
710
816
|
throw new MissingFieldError(field);
|
|
711
817
|
}
|
|
712
|
-
if (!
|
|
818
|
+
if (!ADDRESS_RE2.test(value)) {
|
|
713
819
|
throw new ConfigError(`${field} must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(value)}`, {
|
|
714
820
|
field,
|
|
715
821
|
value
|
|
@@ -779,7 +885,7 @@ function createX402Challenge(requirement, extras) {
|
|
|
779
885
|
if (typeof requirement.id !== "string" || !requirement.id.startsWith("preq_")) {
|
|
780
886
|
throw new ConfigError('PaymentRequirement.id must be a "preq_\u2026" string', { id: requirement.id });
|
|
781
887
|
}
|
|
782
|
-
|
|
888
|
+
requireAddress2(requirement.payTo, "payTo");
|
|
783
889
|
parseUsdToMicro(requirement.amount);
|
|
784
890
|
const feeBps = extras?.feeBps ?? 0;
|
|
785
891
|
if (!Number.isInteger(feeBps) || feeBps < 0 || feeBps > 1e4) {
|
|
@@ -789,7 +895,7 @@ function createX402Challenge(requirement, extras) {
|
|
|
789
895
|
);
|
|
790
896
|
}
|
|
791
897
|
const feeSink = extras?.feeSink ?? null;
|
|
792
|
-
if (feeBps > 0 && feeSink)
|
|
898
|
+
if (feeBps > 0 && feeSink) requireAddress2(feeSink, "feeSink");
|
|
793
899
|
const paymentRequired = toX402PaymentRequired(requirement, { feeBps, feeSink });
|
|
794
900
|
return {
|
|
795
901
|
status: 402,
|
|
@@ -817,7 +923,7 @@ function validateX402ChallengeInput(input) {
|
|
|
817
923
|
{ field: "url" }
|
|
818
924
|
);
|
|
819
925
|
}
|
|
820
|
-
const payTo =
|
|
926
|
+
const payTo = requireAddress2(input.payTo, "payTo");
|
|
821
927
|
parseUsdToMicro(input.amount);
|
|
822
928
|
const intent = input.intent ?? "transfer";
|
|
823
929
|
if (intent !== "transfer" && intent !== "marketplace.buy") {
|
|
@@ -833,7 +939,7 @@ function validateX402ChallengeInput(input) {
|
|
|
833
939
|
{ feeBps: input.feeBps }
|
|
834
940
|
);
|
|
835
941
|
}
|
|
836
|
-
const feeSink = input.feeSink === void 0 ? void 0 :
|
|
942
|
+
const feeSink = input.feeSink === void 0 ? void 0 : requireAddress2(input.feeSink, "feeSink");
|
|
837
943
|
return {
|
|
838
944
|
payTo,
|
|
839
945
|
amount: input.amount,
|
|
@@ -847,7 +953,7 @@ function validateX402ChallengeInput(input) {
|
|
|
847
953
|
}
|
|
848
954
|
|
|
849
955
|
// src/client.ts
|
|
850
|
-
var
|
|
956
|
+
var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
|
|
851
957
|
function mapAlreadyEntered(e) {
|
|
852
958
|
const msg = e instanceof Error ? e.message : typeof e === "object" && e && "message" in e ? String(e.message) : String(e);
|
|
853
959
|
const detail = e instanceof PaymentFailedError || e instanceof ApiError ? e.detail : void 0;
|
|
@@ -895,7 +1001,7 @@ function requireAddressField(value, field) {
|
|
|
895
1001
|
if (typeof value !== "string" || value.trim() === "") {
|
|
896
1002
|
throw new MissingFieldError(field);
|
|
897
1003
|
}
|
|
898
|
-
if (!
|
|
1004
|
+
if (!ADDRESS_RE3.test(value)) {
|
|
899
1005
|
throw new ConfigError(
|
|
900
1006
|
`${field} must be a 0x-prefixed 20-byte wallet address (Phase 1a transfers settle server-held wallets), got: ${JSON.stringify(value)}`,
|
|
901
1007
|
{ field, value }
|
|
@@ -1083,11 +1189,22 @@ var Playmos = class {
|
|
|
1083
1189
|
* rounds.settle({ ranking }) → contract pays winners.
|
|
1084
1190
|
*
|
|
1085
1191
|
* Operator txs are signed by the Playmos service (OPERATOR_ROLE key).
|
|
1192
|
+
*
|
|
1193
|
+
* Offline `mock: true`: settle applies the stored payout rule and the 60/30/10
|
|
1194
|
+
* pool leg across entries (#490). No series seed bank — mock pool is a floor,
|
|
1195
|
+
* not a forecast of a live series with inherited seed.
|
|
1086
1196
|
*/
|
|
1087
1197
|
/** Offline mock store for rounds.open/lock/settle/get when `mock: true` (3-game dogfood). */
|
|
1088
1198
|
this.mockRounds = /* @__PURE__ */ new Map();
|
|
1089
1199
|
/** Mock withdrawable credits after settle — key `${roundId}:${walletLower}` (#240 offline prize). */
|
|
1090
1200
|
this.mockWithdrawable = /* @__PURE__ */ new Map();
|
|
1201
|
+
/**
|
|
1202
|
+
* Mock pot after per-entry rake (PrizePool escrowed sum) — keyed by roundId (#490).
|
|
1203
|
+
* Payable pool at lock = pot × POOL_BPS / (POOL_BPS + SEED_BPS); inherited seed = 0 offline.
|
|
1204
|
+
*/
|
|
1205
|
+
this.mockPotMicro = /* @__PURE__ */ new Map();
|
|
1206
|
+
/** Persisted settle winners for re-settle replay (#490 C2) — never re-invent pool-to-everyone. */
|
|
1207
|
+
this.mockSettleWinners = /* @__PURE__ */ new Map();
|
|
1091
1208
|
this.rounds = {
|
|
1092
1209
|
open: async (input) => {
|
|
1093
1210
|
if (!this.config.mock) this.assertSecretKey("playmos.rounds.open");
|
|
@@ -1126,6 +1243,8 @@ var Playmos = class {
|
|
|
1126
1243
|
prizePoolAddress: this.config.contracts?.prizePool ?? "0x0000000000000000000000000000000000000002"
|
|
1127
1244
|
};
|
|
1128
1245
|
this.mockRounds.set(input.roundId, round);
|
|
1246
|
+
this.mockPotMicro.set(input.roundId, 0n);
|
|
1247
|
+
this.mockSettleWinners.delete(input.roundId);
|
|
1129
1248
|
return round;
|
|
1130
1249
|
}
|
|
1131
1250
|
const body = await this.http.post(
|
|
@@ -1158,11 +1277,12 @@ var Playmos = class {
|
|
|
1158
1277
|
code: "conflict"
|
|
1159
1278
|
});
|
|
1160
1279
|
}
|
|
1280
|
+
const payableMicro = this.mockPayablePoolMicro(existing);
|
|
1281
|
+
const poolUsd = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
|
|
1161
1282
|
const locked = {
|
|
1162
1283
|
...existing,
|
|
1163
1284
|
status: "locked",
|
|
1164
|
-
|
|
1165
|
-
pool: existing.pool ?? existing.entryAmount,
|
|
1285
|
+
pool: poolUsd,
|
|
1166
1286
|
entrants: existing.entrants ?? 0,
|
|
1167
1287
|
lockTxHash: MOCK_TX
|
|
1168
1288
|
};
|
|
@@ -1185,14 +1305,15 @@ var Playmos = class {
|
|
|
1185
1305
|
throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
|
|
1186
1306
|
}
|
|
1187
1307
|
if (existing.status === "settled" && existing.settleTxHash) {
|
|
1308
|
+
const stored = this.mockSettleWinners.get(input.roundId);
|
|
1188
1309
|
return {
|
|
1189
1310
|
roundId: input.roundId,
|
|
1190
1311
|
txHash: existing.settleTxHash,
|
|
1191
1312
|
poolPaid: existing.pool ?? "0",
|
|
1192
|
-
winners: "winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
|
|
1313
|
+
winners: stored ?? ("winners" in input.results ? input.results.winners : input.results.ranking.map((wallet) => ({
|
|
1193
1314
|
wallet,
|
|
1194
|
-
amount:
|
|
1195
|
-
})),
|
|
1315
|
+
amount: "0"
|
|
1316
|
+
}))),
|
|
1196
1317
|
status: "settled"
|
|
1197
1318
|
};
|
|
1198
1319
|
}
|
|
@@ -1202,13 +1323,45 @@ var Playmos = class {
|
|
|
1202
1323
|
code: "conflict"
|
|
1203
1324
|
});
|
|
1204
1325
|
}
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1326
|
+
const payableMicro = existing.pool != null ? this.parseUsdToMicroLoose(existing.pool) : this.mockPayablePoolMicro(existing);
|
|
1327
|
+
const poolPaid = payableMicro > 0n ? formatMicroToUsd(payableMicro) : existing.pool ?? existing.entryAmount;
|
|
1328
|
+
let rows;
|
|
1329
|
+
if ("winners" in input.results) {
|
|
1330
|
+
rows = input.results.winners.map((w) => {
|
|
1331
|
+
let micro;
|
|
1332
|
+
try {
|
|
1333
|
+
micro = this.parseUsdToMicroLoose(String(w.amount ?? "0"));
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
this.mockPayoutApiError(e);
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
wallet: w.wallet,
|
|
1339
|
+
amount: formatMicroToUsd(micro),
|
|
1340
|
+
micro
|
|
1341
|
+
};
|
|
1342
|
+
});
|
|
1343
|
+
} else {
|
|
1344
|
+
if (payableMicro <= 0n) {
|
|
1345
|
+
rows = [];
|
|
1346
|
+
} else {
|
|
1347
|
+
try {
|
|
1348
|
+
const computed = computePayout(
|
|
1349
|
+
payableMicro,
|
|
1350
|
+
input.results.ranking,
|
|
1351
|
+
existing.payout
|
|
1352
|
+
);
|
|
1353
|
+
rows = computed.map((c) => ({
|
|
1354
|
+
wallet: c.wallet,
|
|
1355
|
+
amount: formatMicroToUsd(c.amount),
|
|
1356
|
+
micro: c.amount
|
|
1357
|
+
}));
|
|
1358
|
+
} catch (e) {
|
|
1359
|
+
this.mockPayoutApiError(e);
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
const winners = rows.map(({ wallet, amount }) => ({ wallet, amount }));
|
|
1210
1364
|
const settleTxHash = MOCK_TX;
|
|
1211
|
-
const poolPaid = existing.pool ?? existing.entryAmount;
|
|
1212
1365
|
const settled = {
|
|
1213
1366
|
...existing,
|
|
1214
1367
|
status: "settled",
|
|
@@ -1216,18 +1369,11 @@ var Playmos = class {
|
|
|
1216
1369
|
pool: poolPaid
|
|
1217
1370
|
};
|
|
1218
1371
|
this.mockRounds.set(input.roundId, settled);
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
} catch {
|
|
1225
|
-
micro = 0n;
|
|
1226
|
-
}
|
|
1227
|
-
if (micro > 0n) {
|
|
1228
|
-
const k = `${input.roundId}:${wAddr}`;
|
|
1229
|
-
this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + micro);
|
|
1230
|
-
}
|
|
1372
|
+
this.mockSettleWinners.set(input.roundId, winners);
|
|
1373
|
+
for (const w of rows) {
|
|
1374
|
+
if (w.micro <= 0n) continue;
|
|
1375
|
+
const k = `${input.roundId}:${w.wallet.toLowerCase()}`;
|
|
1376
|
+
this.mockWithdrawable.set(k, (this.mockWithdrawable.get(k) ?? 0n) + w.micro);
|
|
1231
1377
|
}
|
|
1232
1378
|
return {
|
|
1233
1379
|
roundId: input.roundId,
|
|
@@ -1319,9 +1465,12 @@ var Playmos = class {
|
|
|
1319
1465
|
...existing,
|
|
1320
1466
|
status: "cancelled",
|
|
1321
1467
|
cancelTxHash: MOCK_TX,
|
|
1322
|
-
pool: null
|
|
1468
|
+
pool: null,
|
|
1469
|
+
entrants: 0
|
|
1323
1470
|
};
|
|
1324
1471
|
this.mockRounds.set(input.roundId, cancelled);
|
|
1472
|
+
this.mockPotMicro.set(input.roundId, 0n);
|
|
1473
|
+
this.mockSettleWinners.delete(input.roundId);
|
|
1325
1474
|
return {
|
|
1326
1475
|
roundId: input.roundId,
|
|
1327
1476
|
txHash: MOCK_TX,
|
|
@@ -1381,19 +1530,34 @@ var Playmos = class {
|
|
|
1381
1530
|
}
|
|
1382
1531
|
return { ...result, txHash: result.txHash ?? firstTx ?? null };
|
|
1383
1532
|
},
|
|
1533
|
+
/**
|
|
1534
|
+
* Round state only (stable). Prefer {@link getWithMeta} when you need service
|
|
1535
|
+
* read-path honesty (`via` / `chainReadFailed` — #497 / #501).
|
|
1536
|
+
*/
|
|
1384
1537
|
get: async (input) => {
|
|
1538
|
+
const meta = await this.rounds.getWithMeta(input);
|
|
1539
|
+
return meta.round;
|
|
1540
|
+
},
|
|
1541
|
+
/**
|
|
1542
|
+
* Round state + transport meta from GET /v1/rounds/:id (#501).
|
|
1543
|
+
* Does **not** attach `via` onto RoundState (Claude rejected option B — hub would
|
|
1544
|
+
* silently pick it up and leak into settle/cancel paths that call get()).
|
|
1545
|
+
* Under `mock: true`, meta fields are **omitted** (no eth_call; do not invent "cache").
|
|
1546
|
+
*/
|
|
1547
|
+
getWithMeta: async (input) => {
|
|
1385
1548
|
requireField(input?.roundId, "roundId");
|
|
1386
1549
|
if (this.config.mock) {
|
|
1387
1550
|
const existing = this.mockRounds.get(input.roundId);
|
|
1388
1551
|
if (!existing) {
|
|
1389
1552
|
throw new ApiError(`unknown round: ${input.roundId}`, { status: 404, code: "not_found" });
|
|
1390
1553
|
}
|
|
1391
|
-
return existing;
|
|
1554
|
+
return { round: existing };
|
|
1392
1555
|
}
|
|
1393
|
-
const
|
|
1394
|
-
|
|
1395
|
-
);
|
|
1396
|
-
|
|
1556
|
+
const body = await this.http.get(`/rounds/${encodeURIComponent(input.roundId)}`);
|
|
1557
|
+
const out = { round: body.round };
|
|
1558
|
+
if (body.via != null) out.via = body.via;
|
|
1559
|
+
if (body.chainReadFailed === true) out.chainReadFailed = true;
|
|
1560
|
+
return out;
|
|
1397
1561
|
},
|
|
1398
1562
|
/**
|
|
1399
1563
|
* Authoritative series latch (#351) — `null` means **proven free**.
|
|
@@ -1498,19 +1662,26 @@ var Playmos = class {
|
|
|
1498
1662
|
*/
|
|
1499
1663
|
withdraw: async (input) => {
|
|
1500
1664
|
if (this.config.mock) {
|
|
1501
|
-
let amountMicro2 = 0n;
|
|
1502
1665
|
let prizePool2 = input.prizePoolAddress ?? "0x0000000000000000000000000000000000000001";
|
|
1503
1666
|
if (input.roundId) {
|
|
1504
1667
|
const existing = this.mockRounds.get(input.roundId);
|
|
1505
1668
|
if (existing?.prizePoolAddress) prizePool2 = existing.prizePoolAddress;
|
|
1506
|
-
for (const [k, v] of this.mockWithdrawable) {
|
|
1507
|
-
if (k.startsWith(`${input.roundId}:`) && v > 0n) {
|
|
1508
|
-
amountMicro2 = v;
|
|
1509
|
-
this.mockWithdrawable.set(k, 0n);
|
|
1510
|
-
break;
|
|
1511
|
-
}
|
|
1512
|
-
}
|
|
1513
1669
|
}
|
|
1670
|
+
if (!input.wallet || !ADDRESS_RE3.test(input.wallet)) {
|
|
1671
|
+
throw new ConfigError(
|
|
1672
|
+
"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)"
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
const wallet = input.wallet.toLowerCase();
|
|
1676
|
+
if (!input.roundId) {
|
|
1677
|
+
throw new ConfigError("mock rounds.withdraw requires roundId to locate claimable credits");
|
|
1678
|
+
}
|
|
1679
|
+
const key = `${input.roundId}:${wallet}`;
|
|
1680
|
+
const amountMicro2 = this.mockWithdrawable.get(key) ?? 0n;
|
|
1681
|
+
if (amountMicro2 === 0n) {
|
|
1682
|
+
throw new NothingToWithdrawError({ prizePoolAddress: prizePool2, wallet });
|
|
1683
|
+
}
|
|
1684
|
+
this.mockWithdrawable.set(key, 0n);
|
|
1514
1685
|
return {
|
|
1515
1686
|
prizePoolAddress: prizePool2,
|
|
1516
1687
|
amount: formatMicroToUsd(amountMicro2),
|
|
@@ -1846,6 +2017,69 @@ var Playmos = class {
|
|
|
1846
2017
|
}
|
|
1847
2018
|
}
|
|
1848
2019
|
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Resolve mock round by roundId **or** roundKey (kit `entryProvider` keys by roundKey, #490 C3).
|
|
2022
|
+
*/
|
|
2023
|
+
findMockRound(idOrKey) {
|
|
2024
|
+
const direct = this.mockRounds.get(idOrKey);
|
|
2025
|
+
if (direct) return direct;
|
|
2026
|
+
for (const r of this.mockRounds.values()) {
|
|
2027
|
+
if (r.roundId === idOrKey || r.roundKey === idOrKey) return r;
|
|
2028
|
+
}
|
|
2029
|
+
return void 0;
|
|
2030
|
+
}
|
|
2031
|
+
/**
|
|
2032
|
+
* 6dp USD → micro for mock winner amounts (#490 C1). Must not use 2dp `validateAmount`
|
|
2033
|
+
* (would zero 0.135 from top-n 30% of $0.45).
|
|
2034
|
+
*/
|
|
2035
|
+
parseUsdToMicroLoose(amount) {
|
|
2036
|
+
if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
|
|
2037
|
+
throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
|
|
2038
|
+
}
|
|
2039
|
+
const [wholeRaw, frac = ""] = amount.trim().split(".");
|
|
2040
|
+
const whole = wholeRaw ?? "0";
|
|
2041
|
+
const fracPadded = (frac + "000000").slice(0, 6);
|
|
2042
|
+
const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
|
|
2043
|
+
if (micro < 0n) throw new PayoutError(`amount must be >= 0, got: ${JSON.stringify(amount)}`);
|
|
2044
|
+
return micro;
|
|
2045
|
+
}
|
|
2046
|
+
/** Live-shaped 400 for mock payout validation (C5) — never leak raw PayoutError. */
|
|
2047
|
+
mockPayoutApiError(e) {
|
|
2048
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2049
|
+
throw new ApiError(msg, { status: 400, code: "payout_invalid" });
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Accumulate one mock entry into the pot (chain parity: rake off top, then lock split).
|
|
2053
|
+
* No-op when no matching open/locked round — never 404 (C4).
|
|
2054
|
+
*/
|
|
2055
|
+
mockAccumulateEntry(idOrKey, amountMicro) {
|
|
2056
|
+
const round = this.findMockRound(idOrKey);
|
|
2057
|
+
if (!round) return;
|
|
2058
|
+
if (round.status !== "open" && round.status !== "locked") return;
|
|
2059
|
+
const rake = amountMicro * BigInt(RAKE_BPS) / 10000n;
|
|
2060
|
+
const escrowed = amountMicro - rake;
|
|
2061
|
+
const pot = (this.mockPotMicro.get(round.roundId) ?? 0n) + escrowed;
|
|
2062
|
+
this.mockPotMicro.set(round.roundId, pot);
|
|
2063
|
+
const entrants = (round.entrants ?? 0) + 1;
|
|
2064
|
+
const updated = { ...round, entrants };
|
|
2065
|
+
this.mockRounds.set(round.roundId, updated);
|
|
2066
|
+
}
|
|
2067
|
+
/**
|
|
2068
|
+
* Payable pool micro for mock lock/settle.
|
|
2069
|
+
* With recorded entries: pot × POOL/(POOL+SEED) (seed bank = 0 offline).
|
|
2070
|
+
* Zero entries: fall back to entryAmount micro (C4 — preserves existing suite).
|
|
2071
|
+
*/
|
|
2072
|
+
mockPayablePoolMicro(round) {
|
|
2073
|
+
const pot = this.mockPotMicro.get(round.roundId) ?? 0n;
|
|
2074
|
+
if (pot > 0n) {
|
|
2075
|
+
return pot * BigInt(POOL_BPS) / BigInt(POOL_BPS + SEED_BPS);
|
|
2076
|
+
}
|
|
2077
|
+
try {
|
|
2078
|
+
return this.parseUsdToMicroLoose(round.entryAmount);
|
|
2079
|
+
} catch {
|
|
2080
|
+
return 0n;
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
1849
2083
|
/**
|
|
1850
2084
|
* Wallet timeout policy (#462 / PR #463 residual).
|
|
1851
2085
|
* - connectTimeoutMs → eth_requestAccounts only
|
|
@@ -1994,6 +2228,7 @@ var Playmos = class {
|
|
|
1994
2228
|
const metadata = validateMetadata(input.metadata);
|
|
1995
2229
|
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
1996
2230
|
if (this.config.mock) {
|
|
2231
|
+
this.mockAccumulateEntry(input.roundId, amountMicro);
|
|
1997
2232
|
return this.rememberMock(
|
|
1998
2233
|
mockEntryPayment({
|
|
1999
2234
|
amountMicro,
|
|
@@ -2414,112 +2649,6 @@ function previewPoolSplit(amount) {
|
|
|
2414
2649
|
};
|
|
2415
2650
|
}
|
|
2416
2651
|
|
|
2417
|
-
// src/payout.ts
|
|
2418
|
-
var PayoutError = class extends Error {
|
|
2419
|
-
constructor(message) {
|
|
2420
|
-
super(message);
|
|
2421
|
-
this.code = "payout_invalid";
|
|
2422
|
-
this.name = "PayoutError";
|
|
2423
|
-
}
|
|
2424
|
-
};
|
|
2425
|
-
var ADDRESS_RE3 = /^0x[0-9a-fA-F]{40}$/;
|
|
2426
|
-
var BPS = 10000n;
|
|
2427
|
-
function requireAddress2(w, i) {
|
|
2428
|
-
if (!ADDRESS_RE3.test(w)) {
|
|
2429
|
-
throw new PayoutError(`ranking[${i}] must be a 0x-prefixed 20-byte address, got: ${JSON.stringify(w)}`);
|
|
2430
|
-
}
|
|
2431
|
-
return w.toLowerCase();
|
|
2432
|
-
}
|
|
2433
|
-
function parseUsdToMicroLoose(amount) {
|
|
2434
|
-
if (typeof amount !== "string" || !/^\d+(\.\d{1,6})?$/.test(amount.trim())) {
|
|
2435
|
-
throw new PayoutError(`invalid USD amount: ${JSON.stringify(amount)}`);
|
|
2436
|
-
}
|
|
2437
|
-
const [wholeRaw, frac = ""] = amount.trim().split(".");
|
|
2438
|
-
const whole = wholeRaw ?? "0";
|
|
2439
|
-
const fracPadded = (frac + "000000").slice(0, 6);
|
|
2440
|
-
const micro = BigInt(whole) * 1000000n + BigInt(fracPadded);
|
|
2441
|
-
if (micro <= 0n) throw new PayoutError(`amount must be > 0, got: ${JSON.stringify(amount)}`);
|
|
2442
|
-
return micro;
|
|
2443
|
-
}
|
|
2444
|
-
function computePayout(pool, ranking, rule) {
|
|
2445
|
-
if (typeof pool !== "bigint" || pool <= 0n) {
|
|
2446
|
-
throw new PayoutError(`pool must be a positive bigint (micro-USDC), got: ${String(pool)}`);
|
|
2447
|
-
}
|
|
2448
|
-
if (!Array.isArray(ranking) || ranking.length === 0) {
|
|
2449
|
-
throw new PayoutError("ranking must be a non-empty array of wallets (best-first)");
|
|
2450
|
-
}
|
|
2451
|
-
const wallets = ranking.map((w, i) => requireAddress2(w, i));
|
|
2452
|
-
const seen = /* @__PURE__ */ new Set();
|
|
2453
|
-
for (const w of wallets) {
|
|
2454
|
-
if (seen.has(w)) throw new PayoutError(`duplicate wallet in ranking: ${w}`);
|
|
2455
|
-
seen.add(w);
|
|
2456
|
-
}
|
|
2457
|
-
if (rule.kind === "winner-take-all") {
|
|
2458
|
-
return [{ wallet: wallets[0], amount: pool }];
|
|
2459
|
-
}
|
|
2460
|
-
if (rule.kind === "top-n") {
|
|
2461
|
-
const splits = rule.splitsBps;
|
|
2462
|
-
if (!Array.isArray(splits) || splits.length === 0) {
|
|
2463
|
-
throw new PayoutError("top-n splitsBps must be a non-empty array");
|
|
2464
|
-
}
|
|
2465
|
-
if (splits.length > wallets.length) {
|
|
2466
|
-
throw new PayoutError(
|
|
2467
|
-
`top-n needs ${splits.length} ranked wallets but ranking only has ${wallets.length}`
|
|
2468
|
-
);
|
|
2469
|
-
}
|
|
2470
|
-
let sumBps = 0;
|
|
2471
|
-
for (const b of splits) {
|
|
2472
|
-
if (!Number.isInteger(b) || b <= 0 || b > 1e4) {
|
|
2473
|
-
throw new PayoutError(`each splitsBps entry must be an integer in (0, 10000], got: ${b}`);
|
|
2474
|
-
}
|
|
2475
|
-
sumBps += b;
|
|
2476
|
-
}
|
|
2477
|
-
if (sumBps !== 1e4) {
|
|
2478
|
-
throw new PayoutError(`splitsBps must sum to 10000, got ${sumBps}`);
|
|
2479
|
-
}
|
|
2480
|
-
const out = [];
|
|
2481
|
-
let allocated = 0n;
|
|
2482
|
-
for (let i = 0; i < splits.length; i++) {
|
|
2483
|
-
const amt = pool * BigInt(splits[i]) / BPS;
|
|
2484
|
-
out.push({ wallet: wallets[i], amount: amt });
|
|
2485
|
-
allocated += amt;
|
|
2486
|
-
}
|
|
2487
|
-
const remainder = pool - allocated;
|
|
2488
|
-
if (remainder < 0n) throw new PayoutError("internal: allocated more than pool");
|
|
2489
|
-
out[0] = { wallet: out[0].wallet, amount: out[0].amount + remainder };
|
|
2490
|
-
const filtered = out.filter((x) => x.amount > 0n);
|
|
2491
|
-
if (filtered.length === 0) throw new PayoutError("payout produced no positive amounts");
|
|
2492
|
-
const total = filtered.reduce((s, x) => s + x.amount, 0n);
|
|
2493
|
-
if (total !== pool) throw new PayoutError(`internal: sum ${total} != pool ${pool}`);
|
|
2494
|
-
return filtered;
|
|
2495
|
-
}
|
|
2496
|
-
if (rule.kind === "custom") {
|
|
2497
|
-
const amounts = rule.amounts;
|
|
2498
|
-
if (!Array.isArray(amounts) || amounts.length === 0) {
|
|
2499
|
-
throw new PayoutError("custom amounts must be a non-empty array of USD strings");
|
|
2500
|
-
}
|
|
2501
|
-
if (amounts.length > wallets.length) {
|
|
2502
|
-
throw new PayoutError(
|
|
2503
|
-
`custom amounts has ${amounts.length} entries but ranking only has ${wallets.length}`
|
|
2504
|
-
);
|
|
2505
|
-
}
|
|
2506
|
-
const out = [];
|
|
2507
|
-
let total = 0n;
|
|
2508
|
-
for (let i = 0; i < amounts.length; i++) {
|
|
2509
|
-
const amt = parseUsdToMicroLoose(amounts[i]);
|
|
2510
|
-
out.push({ wallet: wallets[i], amount: amt });
|
|
2511
|
-
total += amt;
|
|
2512
|
-
}
|
|
2513
|
-
if (total !== pool) {
|
|
2514
|
-
throw new PayoutError(
|
|
2515
|
-
`custom amounts sum to ${total} micro-USDC but pool is ${pool} \u2014 must match exactly`
|
|
2516
|
-
);
|
|
2517
|
-
}
|
|
2518
|
-
return out;
|
|
2519
|
-
}
|
|
2520
|
-
throw new PayoutError(`unknown payout rule kind: ${JSON.stringify(rule.kind)}`);
|
|
2521
|
-
}
|
|
2522
|
-
|
|
2523
2652
|
// src/settlement.ts
|
|
2524
2653
|
var ADDRESS_RE4 = /^0x[0-9a-fA-F]{40}$/;
|
|
2525
2654
|
var DEFAULT_TTL_MS = 15 * 60 * 1e3;
|
package/dist/server.d.cts
CHANGED
package/dist/server.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playmos/sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.10",
|
|
4
4
|
"description": "Playmos SDK — stablecoin payments for games on Base. One SDK for IAP (1%), skill-game prize-pool entries (10%, 60/30/10), and agent economies. USD in, USDC on-chain, no crypto UX for players.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|