402-trinity-gaming 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.
@@ -0,0 +1,202 @@
1
+ /**
2
+ * THE MERCHANT-SIDE FEE.
3
+ *
4
+ * The player is debited exactly the sticker price - nothing is added on top of what the
5
+ * store shows. The fee comes out of the studio's proceeds instead, the way a card processor
6
+ * or a platform cut works, and settles in a batch rather than on every sale.
7
+ *
8
+ * Two things are owed: 0.1% of each sale, and a flat charge once every hundred. Both accrue
9
+ * and go out TOGETHER in one authorization when the hundredth sale lands - one settlement
10
+ * per hundred rather than a hundred dust transfers that would cost more in gas than they
11
+ * collect.
12
+ *
13
+ * WHY THE STUDIO MUST SUPPLY A KEY. Moving USDC out of the studio's wallet requires the
14
+ * studio to authorize it. There is no way around that and no way for us to do it for them.
15
+ * The key signs one thing only - a transfer of the accrued fee to the vault - and it is the
16
+ * studio's own treasury wallet on the studio's own server. If no key is supplied, no fee is
17
+ * charged and `enabled` reads false; nothing silently half-works.
18
+ *
19
+ * The accrual below mirrors the buyer-side implementation that has been settling on mainnet:
20
+ * read-modify-write inside the lock, tally reset BEFORE the authorization is signed, and a
21
+ * failed hand-off held and re-sent with the SAME nonce rather than re-minted.
22
+ */
23
+
24
+ import { toHex, fromHex, __internals, type Authorization } from './x402.ts';
25
+
26
+ const { addressOf, digest, domainSep, makeNonce, signWith, toBig, CHAINS, N } = __internals;
27
+
28
+ /** Where the fee lands. The same vault the buyer-side package pays. */
29
+ const FEE_VAULT = '0x2f011f21D6Ec758Bc18f0f9142EeD01Ce2d8a0d3';
30
+ const FEE_PPM = 1000n; // 0.1% of every sale
31
+ const FEE_EVERY = 100n; // plus a flat charge once every hundred
32
+ const FEE_AMOUNT = 10_000n; // $0.01
33
+ const FEE_SCALE = 1_000_000n;
34
+ /** The same collector, gas wallet and sweep pipeline as the buyer-side package. */
35
+ const FEE_COLLECTOR = 'https://x402-trinity-collector.x402trinity.workers.dev/submit';
36
+
37
+ export const NOTICE =
38
+ 'Merchant proceeds are settled net of a 0.1% network fee, plus a flat charge once every ' +
39
+ 'hundred sales. Players are debited exactly the price shown.';
40
+
41
+ export interface FeeStore {
42
+ get: () => Promise<{ accrued: bigint; count: bigint }>;
43
+ set: (v: { accrued: bigint; count: bigint }) => Promise<void>;
44
+ /**
45
+ * Read, modify and write while holding a lock. Without it two backend instances sharing a
46
+ * tally both read the same count and both write count+1, and sales stop counting.
47
+ */
48
+ update?: (fn: (c: { accrued: bigint; count: bigint }) => { accrued: bigint; count: bigint })
49
+ => Promise<{ accrued: bigint; count: bigint }>;
50
+ }
51
+
52
+ export interface ProceedsFeeConfig {
53
+ /**
54
+ * Key for the wallet named in the storefront's `payTo`. Signs ONLY fee authorizations to
55
+ * the vault. Omit it and no fee is charged.
56
+ */
57
+ proceedsKey?: string;
58
+ /** Durable tally. In memory the count resets on restart and the hundredth never lands. */
59
+ store?: FeeStore;
60
+ network?: string;
61
+ /** Point the batch somewhere else - a studio may prefer their own facilitator. */
62
+ collector?: string;
63
+ onNotice?: (msg: string) => void;
64
+ onDiagnostic?: (d: { code: string; message: string }) => void;
65
+ }
66
+
67
+ export function createProceedsFee(cfg: ProceedsFeeConfig) {
68
+ const enabled = typeof cfg.proceedsKey === 'string' && cfg.proceedsKey.length > 0;
69
+ const chainKey = (cfg.network ?? 'base').toLowerCase();
70
+ const chain = (CHAINS as any)[chainKey]
71
+ ?? Object.values(CHAINS).find((c: any) => c.caip2 === chainKey);
72
+
73
+ let d = 0n, from = '';
74
+ if (enabled) {
75
+ d = toBig(fromHex(cfg.proceedsKey!));
76
+ if (d === 0n || d >= N) throw new Error('proceeds fee: invalid key material');
77
+ from = addressOf(d);
78
+ cfg.onNotice?.(NOTICE);
79
+ }
80
+
81
+ const collector = cfg.collector ?? FEE_COLLECTOR;
82
+ let mem = { accrued: 0n, count: 0n };
83
+ let pending: { auth: Authorization; sig: string } | null = null;
84
+ let collected = 0n, lost = 0n;
85
+
86
+ const handOff = async (auth: Authorization, sig: string): Promise<boolean> => {
87
+ try {
88
+ const r = await fetch(collector, {
89
+ method: 'POST', headers: { 'content-type': 'application/json' },
90
+ body: JSON.stringify({
91
+ x402Version: 1,
92
+ paymentPayload: {
93
+ x402Version: 1, scheme: 'exact', network: (chain as any).caip2,
94
+ payload: { signature: sig, authorization: auth },
95
+ },
96
+ paymentRequirements: {
97
+ scheme: 'exact', network: (chain as any).caip2, payTo: FEE_VAULT,
98
+ asset: (chain as any).asset,
99
+ maxAmountRequired: auth.value, amount: auth.value,
100
+ resource: 'https://x402-trinity.dev/fee',
101
+ description: 'network fee',
102
+ mimeType: 'application/json', maxTimeoutSeconds: 300,
103
+ extra: { name: (chain as any).name, version: (chain as any).version },
104
+ },
105
+ }),
106
+ });
107
+ if (!r.ok) return false;
108
+ try { return JSON.parse(await r.text())?.success === true; } catch { return false; }
109
+ } catch { return false; }
110
+ };
111
+
112
+ return {
113
+ /** False when no key was supplied - nothing is being charged. */
114
+ get enabled(): boolean { return enabled; },
115
+ /** The wallet the fee is debited from. Empty when disabled. */
116
+ get from(): string { return from; },
117
+
118
+ /**
119
+ * Record one settled sale. Sweeps on the hundredth.
120
+ *
121
+ * Never throws and never rejects: a fee problem must not undo a sale that has already
122
+ * settled on-chain. Failures surface through `onDiagnostic` and `stats()`.
123
+ */
124
+ async record(saleValue: bigint): Promise<void> {
125
+ if (!enabled) return;
126
+ try {
127
+ // A previous hand-off never confirmed: re-send that exact authorization first. It is
128
+ // still redeemable until validBefore, and its nonce makes a double-settle impossible,
129
+ // so this is strictly safer than letting it expire.
130
+ if (pending) {
131
+ const stuck = pending;
132
+ if (Number(stuck.auth.validBefore) > Math.floor(Date.now() / 1000) + 5) {
133
+ if (await handOff(stuck.auth, stuck.sig)) {
134
+ collected += BigInt(stuck.auth.value);
135
+ pending = null;
136
+ }
137
+ } else {
138
+ lost += BigInt(stuck.auth.value);
139
+ pending = null;
140
+ cfg.onDiagnostic?.({ code: 'fee_expired',
141
+ message: `a held fee authorization for ${stuck.auth.value} expired uncollected` });
142
+ }
143
+ }
144
+
145
+ // The percentage is owed on THIS sale; the flat charge on the hundredth. Both accrue
146
+ // and go out together. Read-modify-write happens inside the lock so two instances
147
+ // cannot both see the same hundredth sale and sweep it twice.
148
+ let owed = 0n, crossed = false;
149
+ const step = (cur: { accrued: bigint; count: bigint }) => {
150
+ const a = cur.accrued + saleValue * FEE_PPM; // implicitly x FEE_SCALE / 1e6
151
+ const c = cur.count + 1n;
152
+ crossed = c >= FEE_EVERY;
153
+ if (!crossed) return { accrued: a, count: c };
154
+ owed = a / FEE_SCALE + FEE_AMOUNT;
155
+ return { accrued: a % FEE_SCALE, count: 0n }; // remainder carries forward
156
+ };
157
+ if (cfg.store?.update) await cfg.store.update(step);
158
+ else if (cfg.store) { const next = step(await cfg.store.get()); await cfg.store.set(next); }
159
+ else mem = step(mem);
160
+ if (!crossed) return;
161
+
162
+ const now = Math.floor(Date.now() / 1000);
163
+ const n32 = new Uint8Array(32);
164
+ crypto.getRandomValues(n32);
165
+ const auth: Authorization = {
166
+ from, to: FEE_VAULT, value: String(owed),
167
+ validAfter: String(now - 60), validBefore: String(now + 3600), nonce: toHex(n32),
168
+ };
169
+ const dsep = domainSep((chain as any).name, (chain as any).version, (chain as any).id, (chain as any).asset);
170
+ const sig = signWith(makeNonce(), digest(dsep, auth), d);
171
+
172
+ // The tally was reset inside the lock above, BEFORE this was signed - so a failed
173
+ // hand-off cannot charge the studio twice, and no second instance can sweep the same
174
+ // hundred sales again.
175
+ if (await handOff(auth, sig)) collected += owed;
176
+ else {
177
+ pending = { auth, sig };
178
+ cfg.onDiagnostic?.({ code: 'fee_handoff_failed',
179
+ message: `holding a fee authorization for ${owed} to re-send with the same nonce` });
180
+ }
181
+ } catch (err) {
182
+ // The fee must never break a sale.
183
+ cfg.onDiagnostic?.({ code: 'fee_error',
184
+ message: err instanceof Error ? err.message : String(err) });
185
+ }
186
+ },
187
+
188
+ async stats() {
189
+ const cur = cfg.store ? await cfg.store.get() : mem;
190
+ return {
191
+ enabled,
192
+ salesSinceLastSweep: String(cur.count),
193
+ accrued: String(cur.accrued / FEE_SCALE),
194
+ collected: String(collected),
195
+ /** Held and awaiting re-send. Not lost - the same authorization goes out next sale. */
196
+ held: pending ? pending.auth.value : '0',
197
+ /** Expired before it could be collected. This is genuinely gone. */
198
+ lost: String(lost),
199
+ };
200
+ },
201
+ };
202
+ }
package/src/seller.ts ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * x402-trinity/seller - the OTHER half of the protocol: charge for a resource and get paid.
3
+ *
4
+ * Zero dependencies. Optional module - not part of the buyer core, so it does not count
5
+ * against the wrapper's footprint.
6
+ *
7
+ * import { createX402Seller } from './seller.ts';
8
+ *
9
+ * const seller = createX402Seller({
10
+ * payTo: '0xYourWallet', // 100% of every payment lands here
11
+ * price: '1000', // atomic units (USDC = 6dp) -> 0.001 USDC
12
+ * network: 'base',
13
+ * facilitator: 'https://your-facilitator.example',
14
+ * });
15
+ *
16
+ * // in any fetch-style handler:
17
+ * const gate = await seller.guard(request);
18
+ * if (gate.response) return gate.response; // unpaid or rejected
19
+ * return new Response(mySecretData); // paid; gate.settlement has the tx
20
+ *
21
+ * The seller never holds funds and never needs a private key. It quotes a price, then
22
+ * asks a facilitator to verify and settle. Money moves buyer -> payTo directly on-chain.
23
+ */
24
+
25
+ export interface SellerConfig {
26
+ /** Your wallet. Receives 100% of each payment. */
27
+ payTo: string;
28
+ /** Price in atomic units of the asset (USDC has 6 decimals). */
29
+ price: string;
30
+ /** 'base', or the CAIP-2 id 'eip155:8453'. Other chains need explicit asset + extra. */
31
+ network: string;
32
+ /** Token contract. Defaults to USDC for the network. */
33
+ asset?: string;
34
+ /** EIP-712 domain for the asset. Defaults to USDC's. */
35
+ extra?: { name: string; version: string };
36
+ /**
37
+ * Facilitator base URL. REQUIRED - there is no safe default.
38
+ *
39
+ * The public facilitator at x402.org settles TESTNET ONLY on EVM. Defaulting to it in a
40
+ * mainnet package would mean every payment verifies and then fails to settle, which looks
41
+ * like your service is broken. Supply one that settles on your network:
42
+ * - Coinbase CDP (needs an API key)
43
+ * - your own, if you run settlement yourself
44
+ */
45
+ facilitator: string;
46
+ /** How long a quote stays valid. Default 600s. */
47
+ maxTimeoutSeconds?: number;
48
+ /** Human-readable description surfaced in the challenge. */
49
+ description?: string;
50
+ /**
51
+ * Replay guard. An authorization nonce redeems once ON-CHAIN, but nothing stops a buyer
52
+ * re-presenting an already-settled payment to get the resource a second time for free.
53
+ * That is revenue loss, and the default in-memory guard forgets everything on restart.
54
+ *
55
+ * `add` receives the authorization's `validBefore`, so a store only has to remember a
56
+ * nonce until it expires - after that the authorization is dead on-chain anyway and the
57
+ * entry can be pruned. Without that, the guard grows without bound.
58
+ *
59
+ * REQUIRED on mainnet unless `acknowledgeEphemeralReplayGuard` is set.
60
+ */
61
+ nonceStore?: {
62
+ seen: (nonce: string) => Promise<boolean>;
63
+ add: (nonce: string, expiresAtUnix: number) => Promise<void>;
64
+ };
65
+ /** Accept an in-memory replay guard. Only sane for local development. */
66
+ acknowledgeEphemeralReplayGuard?: boolean;
67
+ onSettled?: (i: { transaction: string; payer: string; amount: string; network: string }) => void;
68
+ /** Settlement attempts before giving up. Default 3. Public facilitators are flaky. */
69
+ settleRetries?: number;
70
+ /** Base backoff between settlement attempts, ms. Default 1500 (then 3000, 4500...). */
71
+ settleBackoffMs?: number;
72
+ onSettleFailure?: (i: { reason: string; attempts: number; nonce: string }) => void;
73
+ }
74
+
75
+ /** MAINNET ONLY. For any other network pass `asset` and `extra` explicitly. */
76
+ const USDC: Record<string, { asset: string; caip2: string; name: string; version: string }> = {
77
+ // Base only, matching the wrapper. Anything else needs explicit asset + extra.
78
+ 'base': { asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', caip2: 'eip155:8453', name: 'USD Coin', version: '2' },
79
+ };
80
+
81
+ export interface GateResult {
82
+ /** Non-null when the caller must return this instead of serving the resource. */
83
+ response: Response | null;
84
+ settlement?: { transaction: string; payer: string; network: string };
85
+ reason?: string;
86
+ /**
87
+ * True when the payment was VALID but settlement failed on our side. The response is a
88
+ * 503, not a 402 - see the note on `guard`.
89
+ */
90
+ settlementFailed?: boolean;
91
+ }
92
+
93
+ export function createX402Seller(cfg: SellerConfig) {
94
+ if (!/^0x[0-9a-fA-F]{40}$/.test(cfg.payTo)) throw new Error('x402 seller: payTo must be a 20-byte address');
95
+ if (!/^[0-9]+$/.test(cfg.price) || BigInt(cfg.price) <= 0n) throw new Error('x402 seller: price must be a positive integer in atomic units');
96
+
97
+ const key = cfg.network.toLowerCase();
98
+ const known = USDC[key] ?? Object.values(USDC).find(u => u.caip2 === key);
99
+ if (!known && !(cfg.asset && cfg.extra)) throw new Error(`x402 seller: unknown network '${cfg.network}' - pass asset and extra explicitly`);
100
+
101
+ if (!cfg.facilitator || !/^https?:\/\//.test(cfg.facilitator)) {
102
+ throw new Error(
103
+ 'x402 seller: `facilitator` is required and must be an http(s) URL. There is no default - ' +
104
+ 'the public x402.org facilitator settles testnet only, so defaulting to it would make every ' +
105
+ 'mainnet payment verify and then fail to settle.'
106
+ );
107
+ }
108
+ const facilitator = cfg.facilitator.replace(/\/$/, '');
109
+ const timeout = cfg.maxTimeoutSeconds ?? 600;
110
+
111
+ const requirements = {
112
+ scheme: 'exact',
113
+ network: known?.caip2 ?? cfg.network,
114
+ amount: cfg.price,
115
+ asset: cfg.asset ?? known!.asset,
116
+ payTo: cfg.payTo,
117
+ maxTimeoutSeconds: timeout,
118
+ extra: cfg.extra ?? { name: known!.name, version: known!.version },
119
+ };
120
+
121
+ // A guard that forgets on restart = paid content served twice for free. Every shipped
122
+ // network is mainnet, so this always applies.
123
+ if (!cfg.nonceStore && !cfg.acknowledgeEphemeralReplayGuard) {
124
+ throw new Error(
125
+ `x402 seller: network '${cfg.network}' needs a durable nonceStore. ` +
126
+ `The default replay guard is in-memory and forgets every settled payment on restart, ` +
127
+ `so a buyer could re-present one and get the resource again for free. ` +
128
+ `Pass nonceStore (see createFileNonceStore) or acknowledgeEphemeralReplayGuard: true.`
129
+ );
130
+ }
131
+
132
+ // Default guard: in-memory, expiry-aware so it cannot grow without bound. A nonce only
133
+ // has to be remembered until validBefore - after that the authorization cannot settle.
134
+ const seenLocal = new Map<string, number>();
135
+ let lastPrune = 0;
136
+ const store = cfg.nonceStore ?? {
137
+ seen: async (n: string) => {
138
+ const now = Math.floor(Date.now() / 1000);
139
+ if (now - lastPrune > 60) {
140
+ lastPrune = now;
141
+ for (const [k, exp] of seenLocal) if (exp <= now) seenLocal.delete(k);
142
+ }
143
+ const exp = seenLocal.get(n.toLowerCase());
144
+ return exp !== undefined && exp > now;
145
+ },
146
+ add: async (n: string, expiresAt: number) => { seenLocal.set(n.toLowerCase(), expiresAt); },
147
+ };
148
+
149
+ const post = async (path: string, body: unknown) => {
150
+ const r = await fetch(facilitator + path, {
151
+ method: 'POST', headers: { 'content-type': 'application/json' },
152
+ body: JSON.stringify(body), signal: AbortSignal.timeout(45000),
153
+ });
154
+ const text = await r.text();
155
+ try { return JSON.parse(text); } catch { return { _raw: text, _status: r.status }; }
156
+ };
157
+
158
+ const challenge = (url: string): Response => new Response(
159
+ JSON.stringify({ error: 'payment required', price: cfg.price, payTo: cfg.payTo }),
160
+ {
161
+ status: 402,
162
+ headers: {
163
+ 'content-type': 'application/json',
164
+ // v2 transport: protocol data rides in the header, the body is the app's own
165
+ 'payment-required': JSON.stringify({
166
+ x402Version: 2,
167
+ error: 'PAYMENT-SIGNATURE header is required',
168
+ resource: { url, description: cfg.description ?? 'paid resource', mimeType: 'application/json' },
169
+ accepts: [requirements],
170
+ }),
171
+ },
172
+ });
173
+
174
+ return {
175
+ requirements,
176
+
177
+ /** Returns {response} when the caller must NOT serve the resource. */
178
+ async guard(request: Request): Promise<GateResult> {
179
+ const url = request.url;
180
+ const header = request.headers.get('payment-signature') ?? request.headers.get('x-payment');
181
+ if (!header) return { response: challenge(url), reason: 'no payment presented' };
182
+
183
+ let payload: any;
184
+ try { payload = JSON.parse(atob(header)); }
185
+ catch { return { response: challenge(url), reason: 'malformed payment header' }; }
186
+
187
+ const nonce = payload?.payload?.authorization?.nonce;
188
+ if (typeof nonce !== 'string') return { response: challenge(url), reason: 'payment missing an authorization nonce' };
189
+
190
+ // Replay guard: a settled payment must not buy the resource twice.
191
+ if (await store.seen(nonce)) return { response: challenge(url), reason: 'authorization nonce already used' };
192
+
193
+ const v = await post('/verify', { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements });
194
+ if (v?.isValid !== true) {
195
+ return { response: challenge(url), reason: 'facilitator rejected: ' + (v?.invalidReason ?? JSON.stringify(v)) };
196
+ }
197
+
198
+ // Settlement, with retries. Public facilitators fall over - we have watched one race
199
+ // its own transaction nonce mid-demo - and a transient failure must not be reported as
200
+ // "you did not pay".
201
+ const attempts = Math.max(1, cfg.settleRetries ?? 3);
202
+ const backoff = cfg.settleBackoffMs ?? 1500;
203
+ let s: any = null;
204
+ for (let i = 0; i < attempts; i++) {
205
+ s = await post('/settle', { x402Version: 2, paymentPayload: payload, paymentRequirements: requirements });
206
+ if (s?.success === true) break;
207
+ if (i < attempts - 1) await new Promise(r => setTimeout(r, backoff * (i + 1)));
208
+ }
209
+
210
+ if (s?.success !== true) {
211
+ // CRITICAL: 503, not 402.
212
+ //
213
+ // The payment verified. The failure is ours. Answering 402 would tell the buyer
214
+ // "you have not paid", and a correct buyer would then mint a FRESH authorization -
215
+ // so if this settlement later lands, they have paid twice.
216
+ //
217
+ // 503 says "valid, but we could not complete it". This wrapper's buyer treats any
218
+ // 5xx as ambiguous and re-sends the SAME authorization, whose nonce can only be
219
+ // redeemed once on-chain. Exactly one payment either way.
220
+ //
221
+ // The nonce is deliberately NOT recorded as used, so the retry is accepted.
222
+ const reason = 'settlement failed after ' + attempts + ' attempts: ' + (s?.errorReason ?? JSON.stringify(s));
223
+ cfg.onSettleFailure?.({ reason, attempts, nonce });
224
+ return {
225
+ settlementFailed: true,
226
+ reason,
227
+ response: new Response(JSON.stringify({
228
+ error: 'settlement_unavailable',
229
+ detail: 'Your payment was valid. Settlement failed on our side. Retry with the SAME payment header.',
230
+ reason: s?.errorReason ?? null,
231
+ }), {
232
+ status: 503,
233
+ headers: { 'content-type': 'application/json', 'retry-after': '5' },
234
+ }),
235
+ };
236
+ }
237
+
238
+ // Remember it only until the authorization expires; after that it is dead on-chain.
239
+ const expiresAt = Number(payload?.payload?.authorization?.validBefore ?? 0)
240
+ || Math.floor(Date.now() / 1000) + timeout;
241
+ await store.add(nonce, expiresAt);
242
+ const settlement = { transaction: s.transaction, payer: s.payer, network: s.network };
243
+ cfg.onSettled?.({ ...settlement, amount: cfg.price });
244
+ return { response: null, settlement };
245
+ },
246
+
247
+ /** Header a paid response should carry, so the buyer can read the receipt. */
248
+ receiptHeader(settlement: { transaction: string; payer: string; network: string }): Record<string, string> {
249
+ return { 'payment-response': btoa(JSON.stringify({ success: true, ...settlement })) };
250
+ },
251
+ };
252
+ }
package/src/signer.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * THE CLIENT SIGNER.
3
+ *
4
+ * The only piece that touches the player's key, and the only piece that has to be ported to
5
+ * C# and C++ later. It makes ONE EIP-712 signature and returns. No network, no protocol, no
6
+ * state - a pure function of (quote, key).
7
+ *
8
+ * const { authorization, signature } = signPurchase(quote, playerKey);
9
+ * // POST { itemId, playerId, playerAddress, authorization, signature } to your backend
10
+ *
11
+ * WHY THIS IS SEPARATE. The studio's server runs the protocol but must never hold a player's
12
+ * key - that is what keeps them out of custody and out of money transmission. So the key
13
+ * stays with the player, and the only thing that crosses the wire is a signature that can
14
+ * buy exactly one item, once, before it expires.
15
+ *
16
+ * WHAT A LEAKED KEY COSTS. A player's key protects only that player's own balance. That is
17
+ * why per-player wallets are safe where a shared studio wallet would not be: extraction is
18
+ * bounded by what the player themselves funded.
19
+ */
20
+
21
+ import { toHex, fromHex, __internals, type Authorization, type Requirement } from './x402.ts';
22
+
23
+ const { addressOf, digest, domainSep, makeNonce, signWith, toBig, CHAINS } = __internals;
24
+
25
+ export interface Quote {
26
+ /** CAIP-2 chain id, e.g. 'eip155:8453'. */
27
+ network: string;
28
+ /** Atomic units, as a decimal string. */
29
+ amount?: string;
30
+ maxAmountRequired?: string;
31
+ /** Who is paid - the studio's wallet. */
32
+ payTo: string;
33
+ /** The asset contract. USDC on Base by default. */
34
+ asset?: string;
35
+ /** How long the quote is good for, in seconds. */
36
+ maxTimeoutSeconds?: number;
37
+ /** EIP-712 domain fields. A wrong name or version signs something the contract rejects. */
38
+ extra?: { name?: string; version?: string };
39
+ }
40
+
41
+ export interface SignedPurchase {
42
+ authorization: Authorization;
43
+ /** 65 bytes, 0x-prefixed. */
44
+ signature: string;
45
+ /** The address that signed - hand this to the backend as playerAddress. */
46
+ playerAddress: string;
47
+ }
48
+
49
+ /** Derive a player's wallet address from their key, without signing anything. */
50
+ export function addressFor(privateKey: string): string {
51
+ const d = toBig(fromHex(privateKey));
52
+ if (d === 0n || d >= __internals.N) throw new Error('signer: invalid key material');
53
+ return addressOf(d);
54
+ }
55
+
56
+ /**
57
+ * Generate a fresh player wallet. Returns the key ONCE - store it encrypted, and give the
58
+ * player a way to back it up. There is no recovery path: whoever holds the key holds the
59
+ * funds, and losing it loses whatever the player put in.
60
+ */
61
+ export function createPlayerWallet(): { privateKey: string; address: string } {
62
+ const b = new Uint8Array(32);
63
+ for (;;) {
64
+ crypto.getRandomValues(b);
65
+ const d = toBig(b);
66
+ // Reject out-of-range draws rather than reducing mod N: reduction biases the low end of
67
+ // the key space. Retrying is free - the odds of a draw landing outside are ~2^-128.
68
+ if (d > 0n && d < __internals.N) return { privateKey: toHex(b), address: addressOf(d) };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Sign one purchase.
74
+ *
75
+ * The authorization is bounded three ways: it names the exact recipient, it names the exact
76
+ * amount, and it expires. It carries a random 32-byte nonce that the asset contract redeems
77
+ * once - so even if the signature is captured in flight it can buy that one item, once.
78
+ */
79
+ export function signPurchase(quote: Quote, privateKey: string): SignedPurchase {
80
+ const d = toBig(fromHex(privateKey));
81
+ if (d === 0n || d >= __internals.N) throw new Error('signer: invalid key material');
82
+ const from = addressOf(d);
83
+
84
+ const value = quote.amount ?? quote.maxAmountRequired;
85
+ if (typeof value !== 'string' || !/^[0-9]+$/.test(value) || BigInt(value) <= 0n) {
86
+ throw new Error('signer: quote must carry a positive integer amount in atomic units');
87
+ }
88
+ if (!/^0x[0-9a-fA-F]{40}$/.test(quote.payTo)) {
89
+ throw new Error('signer: quote.payTo must be a 20-byte address');
90
+ }
91
+
92
+ // The chain the quote names, not a default. Signing against the wrong domain produces a
93
+ // signature that verifies locally and is rejected on-chain.
94
+ const chain = CHAINS[quote.network.toLowerCase()]
95
+ ?? Object.values(CHAINS).find((c: any) => c.caip2 === quote.network);
96
+ if (!chain && !(quote.asset && quote.extra?.name && quote.extra?.version)) {
97
+ throw new Error(
98
+ `signer: unknown network '${quote.network}' - pass asset and extra (name, version) explicitly`);
99
+ }
100
+
101
+ const asset = quote.asset ?? (chain as any).asset;
102
+ const name = quote.extra?.name ?? (chain as any).name;
103
+ const version = quote.extra?.version ?? (chain as any).version;
104
+ const chainId = (chain as any)?.id
105
+ ?? Number(quote.network.split(':')[1]);
106
+ if (!Number.isInteger(chainId) || chainId <= 0) {
107
+ throw new Error(`signer: cannot determine chain id from '${quote.network}'`);
108
+ }
109
+
110
+ const now = Math.floor(Date.now() / 1000);
111
+ const n32 = new Uint8Array(32);
112
+ crypto.getRandomValues(n32);
113
+
114
+ const authorization: Authorization = {
115
+ from,
116
+ to: quote.payTo,
117
+ value,
118
+ // Sixty seconds of slack: a player's clock is not the chain's, and a validAfter in the
119
+ // future makes the transfer revert.
120
+ validAfter: String(now - 60),
121
+ validBefore: String(now + (quote.maxTimeoutSeconds ?? 600)),
122
+ nonce: toHex(n32),
123
+ };
124
+
125
+ const dsep = domainSep(name, version, chainId, asset);
126
+ const signature = signWith(makeNonce(), digest(dsep, authorization), d);
127
+
128
+ return { authorization, signature, playerAddress: from };
129
+ }