@playmos/sdk 0.1.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 +732 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +383 -0
- package/dist/index.d.ts +383 -0
- package/dist/index.js +706 -0
- package/dist/index.js.map +1 -0
- package/package.json +53 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var viem = require('viem');
|
|
4
|
+
var crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
// src/errors.ts
|
|
7
|
+
var PlaymosError = class extends Error {
|
|
8
|
+
constructor(code, message, detail) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = new.target.name;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.detail = detail;
|
|
13
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
var InvalidAmountError = class extends PlaymosError {
|
|
17
|
+
constructor(amount) {
|
|
18
|
+
super(
|
|
19
|
+
"invalid_amount",
|
|
20
|
+
`Invalid amount: ${JSON.stringify(amount)}. Provide a positive USD decimal string with at most 2 decimals, e.g. "4.99".`,
|
|
21
|
+
{ amount }
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var MissingFieldError = class extends PlaymosError {
|
|
26
|
+
constructor(field) {
|
|
27
|
+
super("missing_field", `Missing required field: "${field}".`, { field });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var InsufficientGasError = class extends PlaymosError {
|
|
31
|
+
constructor(detail) {
|
|
32
|
+
super(
|
|
33
|
+
"insufficient_gas",
|
|
34
|
+
`The player's wallet has too little ETH to pay gas. Ask them to add a little ETH, or switch to gas.mode: "sponsored".`,
|
|
35
|
+
detail
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
var WalletConnectionError = class extends PlaymosError {
|
|
40
|
+
constructor(message = "Could not connect the player's wallet.", detail) {
|
|
41
|
+
super("wallet_connection", message, detail);
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var PaymentFailedError = class extends PlaymosError {
|
|
45
|
+
constructor(message = "The on-chain payment did not complete.", detail) {
|
|
46
|
+
super("payment_failed", message, detail);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
var AuthError = class extends PlaymosError {
|
|
50
|
+
constructor(message = "Invalid or missing API key.", detail) {
|
|
51
|
+
super("auth", message, detail);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
var ApiError = class extends PlaymosError {
|
|
55
|
+
constructor(message, detail) {
|
|
56
|
+
super("api_error", message, detail);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
var ConfigError = class extends PlaymosError {
|
|
60
|
+
constructor(message, detail) {
|
|
61
|
+
super("config", message, detail);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// src/config.ts
|
|
66
|
+
var CHAIN_ID = {
|
|
67
|
+
base: 8453,
|
|
68
|
+
"base-sepolia": 84532
|
|
69
|
+
};
|
|
70
|
+
var USDC_ADDRESS = {
|
|
71
|
+
base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
72
|
+
"base-sepolia": "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
|
|
73
|
+
};
|
|
74
|
+
var DEFAULT_API_BASE_URL = {
|
|
75
|
+
base: "https://api.playmos.io/v1",
|
|
76
|
+
"base-sepolia": "https://api.sandbox.playmos.io/v1"
|
|
77
|
+
};
|
|
78
|
+
function resolveEnv(apiKey, networkOverride, apiBaseUrlOverride) {
|
|
79
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
80
|
+
throw new AuthError("Missing apiKey. Pass a pk_test_\u2026 or pk_live_\u2026 key.");
|
|
81
|
+
}
|
|
82
|
+
const isTest = apiKey.startsWith("pk_test_") || apiKey.startsWith("sk_test_");
|
|
83
|
+
const isLive = apiKey.startsWith("pk_live_") || apiKey.startsWith("sk_live_");
|
|
84
|
+
if (!isTest && !isLive) {
|
|
85
|
+
throw new AuthError(
|
|
86
|
+
'Malformed apiKey. Expected a "pk_test_", "pk_live_", "sk_test_", or "sk_live_" prefix.',
|
|
87
|
+
{ keyPreview: apiKey.slice(0, 8) }
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
const network = networkOverride ?? (isTest ? "base-sepolia" : "base");
|
|
91
|
+
return {
|
|
92
|
+
network,
|
|
93
|
+
chainId: CHAIN_ID[network],
|
|
94
|
+
apiBaseUrl: apiBaseUrlOverride ?? DEFAULT_API_BASE_URL[network],
|
|
95
|
+
isTest
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// src/ids.ts
|
|
100
|
+
var ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
101
|
+
var TIME_LEN = 10;
|
|
102
|
+
var RANDOM_LEN = 16;
|
|
103
|
+
function getCrypto() {
|
|
104
|
+
const c = globalThis.crypto;
|
|
105
|
+
if (!c || typeof c.getRandomValues !== "function") {
|
|
106
|
+
throw new Error("Web Crypto (crypto.getRandomValues) is not available in this runtime.");
|
|
107
|
+
}
|
|
108
|
+
return c;
|
|
109
|
+
}
|
|
110
|
+
function encodeTime(now, len) {
|
|
111
|
+
let mod;
|
|
112
|
+
let str = "";
|
|
113
|
+
for (let i = len - 1; i >= 0; i--) {
|
|
114
|
+
mod = now % 32;
|
|
115
|
+
str = ENCODING[mod] + str;
|
|
116
|
+
now = (now - mod) / 32;
|
|
117
|
+
}
|
|
118
|
+
return str;
|
|
119
|
+
}
|
|
120
|
+
var lastTime = -1;
|
|
121
|
+
var lastRandom = [];
|
|
122
|
+
function randomBytes(len) {
|
|
123
|
+
const bytes = new Uint8Array(len);
|
|
124
|
+
getCrypto().getRandomValues(bytes);
|
|
125
|
+
return Array.from(bytes, (b) => b % 32);
|
|
126
|
+
}
|
|
127
|
+
function incrementRandom(arr) {
|
|
128
|
+
const out = arr.slice();
|
|
129
|
+
for (let i = out.length - 1; i >= 0; i--) {
|
|
130
|
+
const v = out[i] ?? 0;
|
|
131
|
+
if (v < 31) {
|
|
132
|
+
out[i] = v + 1;
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
out[i] = 0;
|
|
136
|
+
}
|
|
137
|
+
return randomBytes(out.length);
|
|
138
|
+
}
|
|
139
|
+
function ulid(seedTime) {
|
|
140
|
+
const now = seedTime ?? Date.now();
|
|
141
|
+
let random;
|
|
142
|
+
if (now === lastTime) {
|
|
143
|
+
random = incrementRandom(lastRandom);
|
|
144
|
+
} else {
|
|
145
|
+
random = randomBytes(RANDOM_LEN);
|
|
146
|
+
}
|
|
147
|
+
lastTime = now;
|
|
148
|
+
lastRandom = random;
|
|
149
|
+
return encodeTime(now, TIME_LEN) + random.map((r) => ENCODING[r]).join("");
|
|
150
|
+
}
|
|
151
|
+
function prefixedId(prefix) {
|
|
152
|
+
return `${prefix}_${ulid()}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/money.ts
|
|
156
|
+
var USDC_DECIMALS = 6;
|
|
157
|
+
var MICRO_PER_USDC = 1000000n;
|
|
158
|
+
var AMOUNT_RE = /^\d+(\.\d{1,2})?$/;
|
|
159
|
+
function parseUsdToMicro(amount) {
|
|
160
|
+
if (typeof amount !== "string" || amount.trim() === "" || !AMOUNT_RE.test(amount)) {
|
|
161
|
+
throw new InvalidAmountError(amount);
|
|
162
|
+
}
|
|
163
|
+
const parts = amount.split(".");
|
|
164
|
+
const whole = parts[0] ?? "0";
|
|
165
|
+
const frac = parts[1] ?? "";
|
|
166
|
+
const fracPadded = (frac + "000000").slice(0, USDC_DECIMALS);
|
|
167
|
+
const micro = BigInt(whole) * MICRO_PER_USDC + BigInt(fracPadded);
|
|
168
|
+
if (micro <= 0n) throw new InvalidAmountError(amount);
|
|
169
|
+
return micro;
|
|
170
|
+
}
|
|
171
|
+
function formatMicroToUsd(micro) {
|
|
172
|
+
const neg = micro < 0n;
|
|
173
|
+
const abs = neg ? -micro : micro;
|
|
174
|
+
const whole = abs / MICRO_PER_USDC;
|
|
175
|
+
const frac = abs % MICRO_PER_USDC;
|
|
176
|
+
let fracStr = frac.toString().padStart(USDC_DECIMALS, "0").replace(/0+$/, "");
|
|
177
|
+
if (fracStr.length < 2) fracStr = fracStr.padEnd(2, "0");
|
|
178
|
+
return `${neg ? "-" : ""}${whole.toString()}.${fracStr}`;
|
|
179
|
+
}
|
|
180
|
+
function computeIapSplit(amountMicro, feeBps) {
|
|
181
|
+
const feeMicro = amountMicro * BigInt(feeBps) / 10000n;
|
|
182
|
+
const netMicro = amountMicro - feeMicro;
|
|
183
|
+
return { feeMicro, netMicro };
|
|
184
|
+
}
|
|
185
|
+
function computePoolSplit(amountMicro, poolBps, seedBps, rakeBps) {
|
|
186
|
+
if (poolBps + seedBps + rakeBps !== 1e4) {
|
|
187
|
+
throw new Error(`pool/seed/rake bps must sum to 10000, got ${poolBps + seedBps + rakeBps}`);
|
|
188
|
+
}
|
|
189
|
+
const seedMicro = amountMicro * BigInt(seedBps) / 10000n;
|
|
190
|
+
const rakeMicro = amountMicro * BigInt(rakeBps) / 10000n;
|
|
191
|
+
const poolMicro = amountMicro - seedMicro - rakeMicro;
|
|
192
|
+
return { poolMicro, seedMicro, rakeMicro };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/validation.ts
|
|
196
|
+
function validateAmount(amount) {
|
|
197
|
+
if (typeof amount !== "string") throw new InvalidAmountError(amount);
|
|
198
|
+
return parseUsdToMicro(amount);
|
|
199
|
+
}
|
|
200
|
+
function requireField(value, field) {
|
|
201
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
202
|
+
throw new MissingFieldError(field);
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
function validateMetadata(metadata) {
|
|
207
|
+
if (metadata == null) return void 0;
|
|
208
|
+
if (typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
209
|
+
throw new MissingFieldError("metadata (must be an object of string values)");
|
|
210
|
+
}
|
|
211
|
+
const entries = Object.entries(metadata);
|
|
212
|
+
if (entries.length > 20) {
|
|
213
|
+
throw new InvalidAmountError(`metadata: at most 20 keys allowed, got ${entries.length}`);
|
|
214
|
+
}
|
|
215
|
+
const out = {};
|
|
216
|
+
for (const [k, v] of entries) {
|
|
217
|
+
if (typeof v !== "string") {
|
|
218
|
+
throw new MissingFieldError(`metadata.${k} (values must be strings)`);
|
|
219
|
+
}
|
|
220
|
+
out[k] = v;
|
|
221
|
+
}
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// src/http.ts
|
|
226
|
+
function createHttpClient(baseUrl, apiKey) {
|
|
227
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
228
|
+
async function handle(res) {
|
|
229
|
+
const text = await res.text();
|
|
230
|
+
let json;
|
|
231
|
+
try {
|
|
232
|
+
json = text ? JSON.parse(text) : {};
|
|
233
|
+
} catch {
|
|
234
|
+
throw new ApiError(`Non-JSON response (${res.status}) from ${res.url}`, { status: res.status, body: text });
|
|
235
|
+
}
|
|
236
|
+
if (res.ok) return json;
|
|
237
|
+
const errBody = json;
|
|
238
|
+
const message = errBody?.error?.message ?? `Request failed with ${res.status}`;
|
|
239
|
+
if (res.status === 401 || res.status === 403 || errBody?.error?.code === "auth") {
|
|
240
|
+
throw new AuthError(message, { status: res.status });
|
|
241
|
+
}
|
|
242
|
+
throw new ApiError(message, { status: res.status, code: errBody?.error?.code });
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
async post(path, body, opts) {
|
|
246
|
+
const headers = {
|
|
247
|
+
"content-type": "application/json",
|
|
248
|
+
authorization: `Bearer ${apiKey}`
|
|
249
|
+
};
|
|
250
|
+
if (opts?.idempotencyKey) headers["idempotency-key"] = opts.idempotencyKey;
|
|
251
|
+
const res = await fetch(`${base}${path}`, { method: "POST", headers, body: JSON.stringify(body) });
|
|
252
|
+
return handle(res);
|
|
253
|
+
},
|
|
254
|
+
async get(path) {
|
|
255
|
+
const res = await fetch(`${base}${path}`, {
|
|
256
|
+
method: "GET",
|
|
257
|
+
headers: { authorization: `Bearer ${apiKey}` }
|
|
258
|
+
});
|
|
259
|
+
return handle(res);
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// src/wallet.ts
|
|
265
|
+
function resolveProvider(wallet) {
|
|
266
|
+
if (wallet?.provider) return wallet.provider;
|
|
267
|
+
const injected = globalThis.ethereum;
|
|
268
|
+
const connector = wallet?.connector ?? "base-account";
|
|
269
|
+
if (connector === "injected") {
|
|
270
|
+
if (!injected) {
|
|
271
|
+
throw new WalletConnectionError(
|
|
272
|
+
"No injected wallet found (globalThis.ethereum is undefined). Open in a wallet browser or pass wallet.provider."
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
return injected;
|
|
276
|
+
}
|
|
277
|
+
if (injected) return injected;
|
|
278
|
+
throw new WalletConnectionError(
|
|
279
|
+
"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."
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
async function getAccount(provider) {
|
|
283
|
+
try {
|
|
284
|
+
const accounts = await provider.request({ method: "eth_requestAccounts" });
|
|
285
|
+
const addr = accounts?.[0];
|
|
286
|
+
if (!addr) throw new Error("no account");
|
|
287
|
+
return addr;
|
|
288
|
+
} catch (e) {
|
|
289
|
+
throw new WalletConnectionError("Could not read the player's wallet account.", {
|
|
290
|
+
cause: e?.message
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/chain/abis.ts
|
|
296
|
+
var erc20Abi = [
|
|
297
|
+
{
|
|
298
|
+
type: "function",
|
|
299
|
+
name: "approve",
|
|
300
|
+
stateMutability: "nonpayable",
|
|
301
|
+
inputs: [
|
|
302
|
+
{ name: "spender", type: "address" },
|
|
303
|
+
{ name: "amount", type: "uint256" }
|
|
304
|
+
],
|
|
305
|
+
outputs: [{ type: "bool" }]
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
type: "function",
|
|
309
|
+
name: "balanceOf",
|
|
310
|
+
stateMutability: "view",
|
|
311
|
+
inputs: [{ name: "account", type: "address" }],
|
|
312
|
+
outputs: [{ type: "uint256" }]
|
|
313
|
+
}
|
|
314
|
+
];
|
|
315
|
+
var playmosPayAbi = [
|
|
316
|
+
{
|
|
317
|
+
type: "function",
|
|
318
|
+
name: "pay",
|
|
319
|
+
stateMutability: "nonpayable",
|
|
320
|
+
inputs: [
|
|
321
|
+
{ name: "paymentId", type: "bytes32" },
|
|
322
|
+
{ name: "studio", type: "address" },
|
|
323
|
+
{ name: "amount", type: "uint256" }
|
|
324
|
+
],
|
|
325
|
+
outputs: []
|
|
326
|
+
}
|
|
327
|
+
];
|
|
328
|
+
var prizePoolAbi = [
|
|
329
|
+
{
|
|
330
|
+
type: "function",
|
|
331
|
+
name: "enter",
|
|
332
|
+
stateMutability: "nonpayable",
|
|
333
|
+
inputs: [
|
|
334
|
+
{ name: "roundId", type: "bytes32" },
|
|
335
|
+
{ name: "identity", type: "bytes32" }
|
|
336
|
+
],
|
|
337
|
+
outputs: []
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
type: "function",
|
|
341
|
+
name: "hasEntered",
|
|
342
|
+
stateMutability: "view",
|
|
343
|
+
inputs: [
|
|
344
|
+
{ name: "roundId", type: "bytes32" },
|
|
345
|
+
{ name: "identity", type: "bytes32" }
|
|
346
|
+
],
|
|
347
|
+
outputs: [{ type: "bool" }]
|
|
348
|
+
}
|
|
349
|
+
];
|
|
350
|
+
async function sendCalls(provider, from, chainId, calls, paymasterUrl) {
|
|
351
|
+
const params = {
|
|
352
|
+
version: "2.0.0",
|
|
353
|
+
from,
|
|
354
|
+
chainId: viem.numberToHex(chainId),
|
|
355
|
+
atomicRequired: true,
|
|
356
|
+
calls
|
|
357
|
+
};
|
|
358
|
+
if (paymasterUrl) {
|
|
359
|
+
params.capabilities = { paymasterService: { url: paymasterUrl } };
|
|
360
|
+
}
|
|
361
|
+
let result;
|
|
362
|
+
try {
|
|
363
|
+
result = await provider.request({ method: "wallet_sendCalls", params: [params] });
|
|
364
|
+
} catch (e) {
|
|
365
|
+
const msg = e?.message ?? String(e);
|
|
366
|
+
if (/reject|denied|cancel|closed/i.test(msg)) {
|
|
367
|
+
throw new WalletConnectionError("The player cancelled or closed the payment sheet.", { cause: msg });
|
|
368
|
+
}
|
|
369
|
+
throw new PaymentFailedError("wallet_sendCalls failed.", { cause: msg });
|
|
370
|
+
}
|
|
371
|
+
const id = typeof result === "string" ? result : result?.id ?? "";
|
|
372
|
+
if (!id) throw new PaymentFailedError("Wallet returned no calls id.");
|
|
373
|
+
return { id };
|
|
374
|
+
}
|
|
375
|
+
async function waitForCalls(provider, id, timeoutMs = 6e4) {
|
|
376
|
+
if (!id) return { status: "FAILED" };
|
|
377
|
+
const deadline = Date.now() + timeoutMs;
|
|
378
|
+
while (Date.now() < deadline) {
|
|
379
|
+
const res = await provider.request({
|
|
380
|
+
method: "wallet_getCallsStatus",
|
|
381
|
+
params: [id]
|
|
382
|
+
});
|
|
383
|
+
const s = String(res?.status ?? "").toUpperCase();
|
|
384
|
+
const txHash = res?.receipts?.[0]?.transactionHash;
|
|
385
|
+
if (s === "200" || s === "CONFIRMED" || s === "SUCCESS") return { status: "CONFIRMED", txHash };
|
|
386
|
+
if (s === "400" || s === "500" || s === "FAILED" || s === "REVERTED") return { status: "FAILED", txHash };
|
|
387
|
+
await new Promise((r) => setTimeout(r, 900));
|
|
388
|
+
}
|
|
389
|
+
return { status: "PENDING" };
|
|
390
|
+
}
|
|
391
|
+
function encodeApprove(spender, amountUnits) {
|
|
392
|
+
return viem.encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [spender, amountUnits] });
|
|
393
|
+
}
|
|
394
|
+
async function assertEnoughGas(provider, from, minWei = 200000000000000n) {
|
|
395
|
+
let balance;
|
|
396
|
+
try {
|
|
397
|
+
const hex = await provider.request({ method: "eth_getBalance", params: [from, "latest"] });
|
|
398
|
+
balance = BigInt(hex);
|
|
399
|
+
} catch {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (balance < minWei) {
|
|
403
|
+
throw new InsufficientGasError({ balanceWei: balance.toString(), minWei: minWei.toString() });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
var toBytes32 = (s) => viem.keccak256(viem.toBytes(s));
|
|
407
|
+
function buildIapCalls(args) {
|
|
408
|
+
const payData = viem.encodeFunctionData({
|
|
409
|
+
abi: playmosPayAbi,
|
|
410
|
+
functionName: "pay",
|
|
411
|
+
args: [toBytes32(args.paymentId), args.studio, args.amountUnits]
|
|
412
|
+
});
|
|
413
|
+
return [
|
|
414
|
+
{ to: args.usdc, data: encodeApprove(args.playmosPay, args.amountUnits) },
|
|
415
|
+
{ to: args.playmosPay, data: payData }
|
|
416
|
+
];
|
|
417
|
+
}
|
|
418
|
+
function buildEntryCalls(args) {
|
|
419
|
+
const enterData = viem.encodeFunctionData({
|
|
420
|
+
abi: prizePoolAbi,
|
|
421
|
+
functionName: "enter",
|
|
422
|
+
args: [toBytes32(args.roundKey), toBytes32(args.identity)]
|
|
423
|
+
});
|
|
424
|
+
return [
|
|
425
|
+
{ to: args.usdc, data: encodeApprove(args.prizePool, args.amountUnits) },
|
|
426
|
+
{ to: args.prizePool, data: enterData }
|
|
427
|
+
];
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/mock.ts
|
|
431
|
+
var MOCK_TX = "0x000000000000000000000000000000000000000000000000000000000000mock";
|
|
432
|
+
function mockIapPayment(args) {
|
|
433
|
+
const { feeMicro, netMicro } = computeIapSplit(args.amountMicro, args.feeBps);
|
|
434
|
+
return {
|
|
435
|
+
id: prefixedId("pay"),
|
|
436
|
+
status: "confirmed",
|
|
437
|
+
kind: "iap",
|
|
438
|
+
amount: formatMicroToUsd(args.amountMicro),
|
|
439
|
+
fee: formatMicroToUsd(feeMicro),
|
|
440
|
+
net: formatMicroToUsd(netMicro),
|
|
441
|
+
sku: args.sku,
|
|
442
|
+
playerId: args.playerId,
|
|
443
|
+
txHash: MOCK_TX,
|
|
444
|
+
chain: args.chain,
|
|
445
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
446
|
+
metadata: args.metadata,
|
|
447
|
+
mock: true
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
function mockEntryPayment(args) {
|
|
451
|
+
const { poolMicro, seedMicro, rakeMicro } = computePoolSplit(
|
|
452
|
+
args.amountMicro,
|
|
453
|
+
args.poolBps,
|
|
454
|
+
args.seedBps,
|
|
455
|
+
args.rakeBps
|
|
456
|
+
);
|
|
457
|
+
return {
|
|
458
|
+
id: prefixedId("entry"),
|
|
459
|
+
status: "confirmed",
|
|
460
|
+
kind: "entry",
|
|
461
|
+
amount: formatMicroToUsd(args.amountMicro),
|
|
462
|
+
fee: formatMicroToUsd(rakeMicro),
|
|
463
|
+
// the 10% take
|
|
464
|
+
net: formatMicroToUsd(poolMicro + seedMicro),
|
|
465
|
+
// escrowed to the pool
|
|
466
|
+
split: {
|
|
467
|
+
pool: formatMicroToUsd(poolMicro),
|
|
468
|
+
seed: formatMicroToUsd(seedMicro),
|
|
469
|
+
rake: formatMicroToUsd(rakeMicro)
|
|
470
|
+
},
|
|
471
|
+
gameId: args.gameId,
|
|
472
|
+
roundId: args.roundId,
|
|
473
|
+
playerId: args.playerId,
|
|
474
|
+
txHash: MOCK_TX,
|
|
475
|
+
chain: args.chain,
|
|
476
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
477
|
+
metadata: args.metadata,
|
|
478
|
+
mock: true
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
var WebhookSignatureError = class extends PlaymosError {
|
|
482
|
+
constructor(message) {
|
|
483
|
+
super("api_error", `Webhook signature verification failed: ${message}`);
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
function parseHeader(header) {
|
|
487
|
+
const parts = Object.fromEntries(
|
|
488
|
+
header.split(",").map((kv) => {
|
|
489
|
+
const [k, v] = kv.split("=");
|
|
490
|
+
return [k?.trim(), v?.trim()];
|
|
491
|
+
})
|
|
492
|
+
);
|
|
493
|
+
const t = Number(parts["t"]);
|
|
494
|
+
const v1 = parts["v1"];
|
|
495
|
+
if (!Number.isFinite(t) || !v1) throw new WebhookSignatureError("malformed signature header");
|
|
496
|
+
return { t, v1 };
|
|
497
|
+
}
|
|
498
|
+
function computeSignature(secret, t, rawBody) {
|
|
499
|
+
return crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
|
|
500
|
+
}
|
|
501
|
+
function verifyWebhook(rawBody, signatureHeader, secret, opts = {}) {
|
|
502
|
+
if (!signatureHeader || Array.isArray(signatureHeader)) {
|
|
503
|
+
throw new WebhookSignatureError("missing signature header");
|
|
504
|
+
}
|
|
505
|
+
if (!secret) throw new WebhookSignatureError("missing signing secret");
|
|
506
|
+
const body = typeof rawBody === "string" ? rawBody : rawBody.toString("utf8");
|
|
507
|
+
const { t, v1 } = parseHeader(signatureHeader);
|
|
508
|
+
const tolerance = opts.toleranceSeconds ?? 300;
|
|
509
|
+
const nowSec = Math.floor(Date.now() / 1e3);
|
|
510
|
+
if (Math.abs(nowSec - t) > tolerance) {
|
|
511
|
+
throw new WebhookSignatureError(`timestamp outside tolerance (${tolerance}s)`);
|
|
512
|
+
}
|
|
513
|
+
const expected = computeSignature(secret, t, body);
|
|
514
|
+
const a = Buffer.from(expected, "hex");
|
|
515
|
+
const b = Buffer.from(v1, "hex");
|
|
516
|
+
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
|
517
|
+
throw new WebhookSignatureError("signature mismatch");
|
|
518
|
+
}
|
|
519
|
+
return JSON.parse(body);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// src/client.ts
|
|
523
|
+
var IAP_FEE_BPS = 100;
|
|
524
|
+
var POOL_BPS = 6e3;
|
|
525
|
+
var SEED_BPS = 3e3;
|
|
526
|
+
var RAKE_BPS = 1e3;
|
|
527
|
+
var Playmos = class {
|
|
528
|
+
constructor(config) {
|
|
529
|
+
this.webhooks = {
|
|
530
|
+
/** Verify a webhook signature and return the parsed event (server-side). */
|
|
531
|
+
verify: (rawBody, signatureHeader, secret) => verifyWebhook(rawBody, signatureHeader, secret)
|
|
532
|
+
};
|
|
533
|
+
this.payouts = {
|
|
534
|
+
/** Choose how the studio is paid: "usdc" (default) or "fiat" (Bridge). */
|
|
535
|
+
setMode: (mode) => this.http.post("/payouts/mode", { mode }),
|
|
536
|
+
/** Create a Bridge KYC onboarding link (fiat payout). */
|
|
537
|
+
createOnboardingLink: () => this.http.post("/payouts/onboarding_link", {})
|
|
538
|
+
};
|
|
539
|
+
this.agents = {
|
|
540
|
+
/** Assign a wallet to any identity (incl. an AI NPC). Idempotent by agentId. */
|
|
541
|
+
createWallet: (input) => {
|
|
542
|
+
requireField(input?.agentId, "agentId");
|
|
543
|
+
return this.http.post("/agents/wallets", { agentId: input.agentId });
|
|
544
|
+
},
|
|
545
|
+
/** Agent↔agent USDC transfer; the configured taxBps is skimmed to Playmos. */
|
|
546
|
+
pay: (input) => {
|
|
547
|
+
requireField(input?.from, "from");
|
|
548
|
+
requireField(input?.to, "to");
|
|
549
|
+
validateAmount(input?.amount);
|
|
550
|
+
return this.http.post("/agents/pay", input);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
this.config = config;
|
|
554
|
+
this.env = resolveEnv(config.apiKey, config.network, config.apiBaseUrl);
|
|
555
|
+
this.http = createHttpClient(this.env.apiBaseUrl, config.apiKey);
|
|
556
|
+
}
|
|
557
|
+
/** External-studio IAP (1%). Returns a real confirmed Payment with a txHash. */
|
|
558
|
+
async pay(input) {
|
|
559
|
+
const amountMicro = validateAmount(input.amount);
|
|
560
|
+
requireField(input.sku, "sku");
|
|
561
|
+
requireField(input.playerId, "playerId");
|
|
562
|
+
const metadata = validateMetadata(input.metadata);
|
|
563
|
+
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
564
|
+
if (this.config.mock) {
|
|
565
|
+
return mockIapPayment({
|
|
566
|
+
amountMicro,
|
|
567
|
+
feeBps: IAP_FEE_BPS,
|
|
568
|
+
sku: input.sku,
|
|
569
|
+
playerId: input.playerId,
|
|
570
|
+
chain: this.env.network,
|
|
571
|
+
metadata
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
const intent = await this.http.post(
|
|
575
|
+
"/payments",
|
|
576
|
+
{ kind: "iap", amount: input.amount, sku: input.sku, playerId: input.playerId, gameId: input.gameId, studio: input.studio, metadata },
|
|
577
|
+
{ idempotencyKey }
|
|
578
|
+
);
|
|
579
|
+
const provider = resolveProvider(this.config.wallet);
|
|
580
|
+
if (this.config.gas?.mode === "player") {
|
|
581
|
+
const from0 = await getAccount(provider);
|
|
582
|
+
await assertEnoughGas(provider, from0);
|
|
583
|
+
}
|
|
584
|
+
const from = await getAccount(provider);
|
|
585
|
+
const usdc = intent.clientParams?.usdc ?? this.config.contracts?.usdc ?? USDC_ADDRESS[this.env.network];
|
|
586
|
+
const playmosPay = intent.clientParams?.contractAddress ?? this.config.contracts?.playmosPay;
|
|
587
|
+
const studio = intent.clientParams?.studio ?? input.studio;
|
|
588
|
+
if (!playmosPay) throw new ConfigError("No PlaymosPay contract address (service intent + config.contracts both empty).");
|
|
589
|
+
if (!studio) throw new ConfigError("No studio payout address for this payment.");
|
|
590
|
+
const amountUnits = this.resolveUnits(intent, amountMicro);
|
|
591
|
+
const calls = buildIapCalls({ usdc, playmosPay, paymentId: intent.payment.id, studio, amountUnits });
|
|
592
|
+
const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
|
|
593
|
+
const { txHash } = await waitForCalls(provider, callsId);
|
|
594
|
+
return this.settle(intent.payment.id, txHash);
|
|
595
|
+
}
|
|
596
|
+
/** Playmos-owned skill-game entry (10%, 60/30/10 prize pool). Closes #343. */
|
|
597
|
+
async enterRound(input) {
|
|
598
|
+
const amountMicro = validateAmount(input.amount);
|
|
599
|
+
requireField(input.gameId, "gameId");
|
|
600
|
+
requireField(input.roundId, "roundId");
|
|
601
|
+
requireField(input.playerId, "playerId");
|
|
602
|
+
const metadata = validateMetadata(input.metadata);
|
|
603
|
+
const idempotencyKey = input.idempotencyKey ?? prefixedId("idem");
|
|
604
|
+
if (this.config.mock) {
|
|
605
|
+
return mockEntryPayment({
|
|
606
|
+
amountMicro,
|
|
607
|
+
poolBps: POOL_BPS,
|
|
608
|
+
seedBps: SEED_BPS,
|
|
609
|
+
rakeBps: RAKE_BPS,
|
|
610
|
+
gameId: input.gameId,
|
|
611
|
+
roundId: input.roundId,
|
|
612
|
+
playerId: input.playerId,
|
|
613
|
+
chain: this.env.network,
|
|
614
|
+
metadata
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const intent = await this.http.post(
|
|
618
|
+
"/payments",
|
|
619
|
+
{ kind: "entry", amount: input.amount, gameId: input.gameId, roundId: input.roundId, playerId: input.playerId, metadata },
|
|
620
|
+
{ idempotencyKey }
|
|
621
|
+
);
|
|
622
|
+
const provider = resolveProvider(this.config.wallet);
|
|
623
|
+
if (this.config.gas?.mode === "player") {
|
|
624
|
+
const from0 = await getAccount(provider);
|
|
625
|
+
await assertEnoughGas(provider, from0);
|
|
626
|
+
}
|
|
627
|
+
const from = await getAccount(provider);
|
|
628
|
+
const usdc = intent.clientParams?.usdc ?? this.config.contracts?.usdc ?? USDC_ADDRESS[this.env.network];
|
|
629
|
+
const prizePool = intent.clientParams?.contractAddress ?? this.config.contracts?.prizePool;
|
|
630
|
+
if (!prizePool) throw new ConfigError("No PrizePool contract address for this game (service intent + config.contracts both empty).");
|
|
631
|
+
const roundKey = intent.clientParams?.roundKey ?? intent.clientParams?.roundId ?? `${input.gameId}:${input.roundId}`;
|
|
632
|
+
const identity = intent.clientParams?.identity ?? `${from}#${idempotencyKey}`;
|
|
633
|
+
const amountUnits = this.resolveUnits(intent, amountMicro);
|
|
634
|
+
const calls = buildEntryCalls({ usdc, prizePool, roundKey, identity, amountUnits });
|
|
635
|
+
const { id: callsId } = await sendCalls(provider, from, this.env.chainId, calls, this.sponsorUrl(intent));
|
|
636
|
+
const { txHash } = await waitForCalls(provider, callsId);
|
|
637
|
+
return this.settle(intent.payment.id, txHash);
|
|
638
|
+
}
|
|
639
|
+
/** Verify a payment by the service's on-chain read (spec §6.1). Idempotent. */
|
|
640
|
+
async verify(paymentId) {
|
|
641
|
+
requireField(paymentId, "paymentId");
|
|
642
|
+
return this.http.get(`/payments/${encodeURIComponent(paymentId)}`);
|
|
643
|
+
}
|
|
644
|
+
// ---- internals ---------------------------------------------------------
|
|
645
|
+
/** Prefer the service's authoritative micro-USDC amount; fall back to the
|
|
646
|
+
* locally-parsed value if the service didn't echo one. */
|
|
647
|
+
resolveUnits(intent, local) {
|
|
648
|
+
const fromServer = intent.clientParams?.amountUnits ?? intent.clientParams?.amountMicro;
|
|
649
|
+
return fromServer ? BigInt(fromServer) : local;
|
|
650
|
+
}
|
|
651
|
+
sponsorUrl(intent) {
|
|
652
|
+
if (this.config.gas?.mode === "player") return void 0;
|
|
653
|
+
return this.config.gas?.paymasterUrl ?? intent.clientParams?.paymasterUrl;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* After the batch lands, resolve the authoritative status via the service's
|
|
657
|
+
* on-chain read. Polls briefly; returns the merged Payment with the real txHash.
|
|
658
|
+
*/
|
|
659
|
+
async settle(paymentId, txHash, timeoutMs = 3e4) {
|
|
660
|
+
if (txHash) {
|
|
661
|
+
try {
|
|
662
|
+
await this.http.post(`/payments/${encodeURIComponent(paymentId)}/tx`, { txHash });
|
|
663
|
+
} catch {
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const deadline = Date.now() + timeoutMs;
|
|
667
|
+
let last;
|
|
668
|
+
while (Date.now() < deadline) {
|
|
669
|
+
last = await this.verify(paymentId);
|
|
670
|
+
if (last.status === "confirmed" || last.status === "failed") break;
|
|
671
|
+
if (last.chainReads === "degraded" || last.verifiedVia === "degraded") break;
|
|
672
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
id: paymentId,
|
|
676
|
+
status: last?.status ?? "pending",
|
|
677
|
+
kind: last?.roundId ? "entry" : "iap",
|
|
678
|
+
amount: last?.amount ?? "0",
|
|
679
|
+
fee: last?.fee ?? "0",
|
|
680
|
+
net: last?.net ?? "0",
|
|
681
|
+
sku: last?.sku,
|
|
682
|
+
roundId: last?.roundId,
|
|
683
|
+
playerId: last?.playerId ?? "",
|
|
684
|
+
txHash: last?.txHash ?? txHash,
|
|
685
|
+
chain: this.env.network,
|
|
686
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
function previewIapSplit(amount) {
|
|
691
|
+
const micro = validateAmount(amount);
|
|
692
|
+
const { feeMicro, netMicro } = computeIapSplit(micro, IAP_FEE_BPS);
|
|
693
|
+
return { amount: formatMicroToUsd(micro), fee: formatMicroToUsd(feeMicro), net: formatMicroToUsd(netMicro) };
|
|
694
|
+
}
|
|
695
|
+
function previewPoolSplit(amount) {
|
|
696
|
+
const micro = validateAmount(amount);
|
|
697
|
+
const { poolMicro, seedMicro, rakeMicro } = computePoolSplit(micro, POOL_BPS, SEED_BPS, RAKE_BPS);
|
|
698
|
+
return {
|
|
699
|
+
amount: formatMicroToUsd(micro),
|
|
700
|
+
pool: formatMicroToUsd(poolMicro),
|
|
701
|
+
seed: formatMicroToUsd(seedMicro),
|
|
702
|
+
rake: formatMicroToUsd(rakeMicro)
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
exports.ApiError = ApiError;
|
|
707
|
+
exports.AuthError = AuthError;
|
|
708
|
+
exports.CHAIN_ID = CHAIN_ID;
|
|
709
|
+
exports.ConfigError = ConfigError;
|
|
710
|
+
exports.DEFAULT_API_BASE_URL = DEFAULT_API_BASE_URL;
|
|
711
|
+
exports.InsufficientGasError = InsufficientGasError;
|
|
712
|
+
exports.InvalidAmountError = InvalidAmountError;
|
|
713
|
+
exports.MICRO_PER_USDC = MICRO_PER_USDC;
|
|
714
|
+
exports.MissingFieldError = MissingFieldError;
|
|
715
|
+
exports.PaymentFailedError = PaymentFailedError;
|
|
716
|
+
exports.Playmos = Playmos;
|
|
717
|
+
exports.PlaymosError = PlaymosError;
|
|
718
|
+
exports.USDC_ADDRESS = USDC_ADDRESS;
|
|
719
|
+
exports.USDC_DECIMALS = USDC_DECIMALS;
|
|
720
|
+
exports.WalletConnectionError = WalletConnectionError;
|
|
721
|
+
exports.WebhookSignatureError = WebhookSignatureError;
|
|
722
|
+
exports.computeIapSplit = computeIapSplit;
|
|
723
|
+
exports.computePoolSplit = computePoolSplit;
|
|
724
|
+
exports.formatMicroToUsd = formatMicroToUsd;
|
|
725
|
+
exports.parseUsdToMicro = parseUsdToMicro;
|
|
726
|
+
exports.prefixedId = prefixedId;
|
|
727
|
+
exports.previewIapSplit = previewIapSplit;
|
|
728
|
+
exports.previewPoolSplit = previewPoolSplit;
|
|
729
|
+
exports.ulid = ulid;
|
|
730
|
+
exports.verifyWebhook = verifyWebhook;
|
|
731
|
+
//# sourceMappingURL=index.cjs.map
|
|
732
|
+
//# sourceMappingURL=index.cjs.map
|