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,286 @@
1
+ /**
2
+ * THE HEADLESS STOREFRONT BRIDGE.
3
+ *
4
+ * A studio's own UI calls in; events come back out. Nothing here renders, opens a browser,
5
+ * takes over input, or writes to the console. The player never learns this exists.
6
+ *
7
+ * const store = createStorefront({
8
+ * payTo: '0x...', // where the studio is paid
9
+ * network: 'base',
10
+ * facilitator: 'https://...',
11
+ * catalog: { vanguard_skin_01: '1500000' }, // atomic units: 1.50 USDC
12
+ * nonceStore, // durable - see below
13
+ * });
14
+ *
15
+ * store.on('settled', e => grantItem(e.playerId, e.itemId));
16
+ * store.on('declined', e => showRefusal(e.reason));
17
+ *
18
+ * const quote = store.quote('vanguard_skin_01'); // client signs this
19
+ * await store.purchase({ itemId, playerId, playerAddress, authorization, signature });
20
+ *
21
+ * WHY THE SPLIT. The signature is the only thing that needs the player's key, and it is a
22
+ * pure function - no network, no protocol. So it happens on the client, and everything
23
+ * else happens here, on the studio's server, using the payment path that has been settling
24
+ * real money on mainnet. The studio never holds a key and never holds a balance.
25
+ */
26
+
27
+ import { createX402Seller, type SellerConfig, type GateResult } from './seller.ts';
28
+ import { createProceedsFee, type ProceedsFeeConfig } from './proceeds-fee.ts';
29
+ import type { Authorization } from './x402.ts';
30
+
31
+ /* ------------------------------------------------------------------ *
32
+ * Events
33
+ * ------------------------------------------------------------------ */
34
+
35
+ export interface PurchaseAccepted {
36
+ itemId: string;
37
+ playerId: string;
38
+ playerAddress: string;
39
+ /** Atomic units of the asset - NOT a float. 1.50 USDC is '1500000'. */
40
+ amount: string;
41
+ }
42
+
43
+ export interface PurchaseSettled extends PurchaseAccepted {
44
+ /** On-chain transaction hash. The money has moved. */
45
+ transaction: string;
46
+ network: string;
47
+ }
48
+
49
+ export interface PurchaseDeclined {
50
+ itemId: string;
51
+ playerId: string;
52
+ /**
53
+ * Machine-readable. Switch on this, do not parse `message`.
54
+ *
55
+ * unknown_item - not in the catalog
56
+ * already_used - this authorization was already redeemed (replay)
57
+ * rejected - the facilitator refused the signature or the amount
58
+ * settlement_failed - VALID payment, our side could not complete it
59
+ * malformed - the client sent something we could not read
60
+ */
61
+ code: 'unknown_item' | 'already_used' | 'rejected' | 'settlement_failed' | 'malformed';
62
+ /** Human-readable detail for the studio's logs. Never shown to a player by us. */
63
+ message: string;
64
+ /**
65
+ * True when the player's money is NOT at risk and the same authorization may be retried
66
+ * unchanged. False means mint a fresh one - re-sending would risk paying twice.
67
+ */
68
+ retryable: boolean;
69
+ }
70
+
71
+ interface Events {
72
+ accepted: PurchaseAccepted;
73
+ settled: PurchaseSettled;
74
+ declined: PurchaseDeclined;
75
+ }
76
+
77
+ /* ------------------------------------------------------------------ *
78
+ * Config
79
+ * ------------------------------------------------------------------ */
80
+
81
+ export interface StorefrontConfig extends Omit<SellerConfig, 'price' | 'description'> {
82
+ /**
83
+ * itemId -> price in ATOMIC units of the asset, as a decimal string. USDC has 6 decimals,
84
+ * so 1.50 is '1500000'. Strings, not numbers: a float cannot represent money exactly and
85
+ * this value ends up inside a signature.
86
+ */
87
+ catalog: Record<string, string>;
88
+ /**
89
+ * Diagnostics for the studio's own logger. Nothing is ever printed - if this is absent,
90
+ * problems are silent and purchases simply fail closed.
91
+ */
92
+ onDiagnostic?: (d: { code: string; message: string }) => void;
93
+ /**
94
+ * The network fee, taken out of PROCEEDS - the player is debited exactly the sticker
95
+ * price and nothing is added on top. Requires `proceedsKey`, the key for the wallet named
96
+ * in `payTo`, because moving money out of that wallet needs its own authorization.
97
+ * Omit this and no fee is charged.
98
+ */
99
+ surcharge?: Omit<ProceedsFeeConfig, 'network' | 'onDiagnostic'>;
100
+ }
101
+
102
+ export interface PurchaseRequest {
103
+ itemId: string;
104
+ /** The studio's own player identifier. Passed through untouched, echoed on every event. */
105
+ playerId: string;
106
+ /** The wallet the player funds. Must match the authorization's `from`. */
107
+ playerAddress: string;
108
+ /** The EIP-3009 authorization the client signed. */
109
+ authorization: Authorization;
110
+ /** Its 65-byte signature, 0x-prefixed. */
111
+ signature: string;
112
+ }
113
+
114
+ /* ------------------------------------------------------------------ *
115
+ * The bridge
116
+ * ------------------------------------------------------------------ */
117
+
118
+ export function createStorefront(cfg: StorefrontConfig) {
119
+ const items = Object.keys(cfg.catalog);
120
+ if (items.length === 0) throw new Error('storefront: catalog is empty');
121
+ for (const [id, price] of Object.entries(cfg.catalog)) {
122
+ if (!/^[0-9]+$/.test(price) || BigInt(price) <= 0n) {
123
+ throw new Error(`storefront: price for '${id}' must be a positive integer in atomic units, got '${price}'`);
124
+ }
125
+ }
126
+
127
+ // One seller per price point. The proven guard validates and settles; we only ever hand it
128
+ // a request it already knows how to read, so none of that logic is reimplemented here.
129
+ const sellers = new Map<string, ReturnType<typeof createX402Seller>>();
130
+ const sellerFor = (itemId: string) => {
131
+ let s = sellers.get(itemId);
132
+ if (!s) {
133
+ s = createX402Seller({ ...cfg, price: cfg.catalog[itemId], description: itemId });
134
+ sellers.set(itemId, s);
135
+ }
136
+ return s;
137
+ };
138
+
139
+ const fee = createProceedsFee({
140
+ ...cfg.surcharge,
141
+ network: cfg.network,
142
+ onDiagnostic: cfg.onDiagnostic,
143
+ });
144
+ // The fee comes out of the studio's own wallet, so it must BE the studio's own wallet.
145
+ // Paying from somewhere else would silently drain a wallet that never agreed to it.
146
+ if (fee.enabled && fee.from.toLowerCase() !== cfg.payTo.toLowerCase()) {
147
+ throw new Error(
148
+ `storefront: surcharge.proceedsKey belongs to ${fee.from}, but payTo is ${cfg.payTo}. ` +
149
+ `The fee is taken from proceeds, so the key must be for the wallet that receives them.`);
150
+ }
151
+
152
+ const handlers: { [K in keyof Events]: Array<(e: Events[K]) => void> } =
153
+ { accepted: [], settled: [], declined: [] };
154
+
155
+ const emit = <K extends keyof Events>(name: K, e: Events[K]): void => {
156
+ for (const h of handlers[name]) {
157
+ // A studio handler that throws must not take down the payment path - the money has
158
+ // already moved. Report it and carry on.
159
+ try { h(e); }
160
+ catch (err) {
161
+ cfg.onDiagnostic?.({
162
+ code: 'handler_threw',
163
+ message: `${name} handler threw: ${err instanceof Error ? err.message : String(err)}`,
164
+ });
165
+ }
166
+ }
167
+ };
168
+
169
+ const decline = (e: PurchaseDeclined): PurchaseDeclined => { emit('declined', e); return e; };
170
+
171
+ return {
172
+ /** Item ids this storefront will sell. */
173
+ get items(): string[] { return [...items]; },
174
+
175
+ /** The network fee taken from proceeds: whether it is on, and what it has collected. */
176
+ fee: { get enabled(): boolean { return fee.enabled; }, stats: () => fee.stats() },
177
+
178
+ /**
179
+ * What the client must sign to buy `itemId`: price, recipient, chain and the EIP-712
180
+ * domain. Costs nothing and moves nothing.
181
+ */
182
+ quote(itemId: string) {
183
+ if (!(itemId in cfg.catalog)) return null;
184
+ return { itemId, ...sellerFor(itemId).requirements };
185
+ },
186
+
187
+ on<K extends keyof Events>(name: K, handler: (e: Events[K]) => void): () => void {
188
+ handlers[name].push(handler);
189
+ return () => {
190
+ const i = handlers[name].indexOf(handler);
191
+ if (i >= 0) handlers[name].splice(i, 1);
192
+ };
193
+ },
194
+
195
+ /**
196
+ * Redeem a signed authorization for an item.
197
+ *
198
+ * Emits `accepted` as soon as the request is well-formed and the item is real, then
199
+ * `settled` or `declined`. The studio decides which one grants the item: `accepted` is
200
+ * optimistic and fast, `settled` means the money has actually moved on-chain.
201
+ */
202
+ async purchase(req: PurchaseRequest): Promise<PurchaseSettled | PurchaseDeclined> {
203
+ const { itemId, playerId, playerAddress, authorization, signature } = req;
204
+
205
+ const amount = cfg.catalog[itemId];
206
+ if (!amount) {
207
+ return decline({ itemId, playerId, code: 'unknown_item',
208
+ message: `no such item '${itemId}'`, retryable: false });
209
+ }
210
+
211
+ if (typeof signature !== 'string' || !/^0x[0-9a-fA-F]{130}$/.test(signature)) {
212
+ return decline({ itemId, playerId, code: 'malformed',
213
+ message: 'signature must be a 0x-prefixed 65-byte hex string', retryable: false });
214
+ }
215
+ if (authorization?.from?.toLowerCase() !== playerAddress.toLowerCase()) {
216
+ return decline({ itemId, playerId, code: 'malformed',
217
+ message: 'authorization.from does not match playerAddress', retryable: false });
218
+ }
219
+ // Checked here so a mismatch reads as 'malformed' rather than surfacing later as an
220
+ // opaque facilitator rejection. The facilitator enforces it too - this is for clarity.
221
+ if (authorization?.value !== amount) {
222
+ return decline({ itemId, playerId, code: 'malformed',
223
+ message: `authorization is for ${authorization?.value}, item costs ${amount}`, retryable: false });
224
+ }
225
+
226
+ emit('accepted', { itemId, playerId, playerAddress, amount });
227
+
228
+ // Hand the proven guard exactly the shape it already parses: an x402 v2 payload in a
229
+ // `payment-signature` header. The URL is synthetic - nothing fetches it - but it must
230
+ // be a valid absolute URL because guard() builds its challenge from it.
231
+ const payload = {
232
+ x402Version: 2,
233
+ scheme: 'exact',
234
+ network: sellerFor(itemId).requirements.network,
235
+ payload: { authorization, signature },
236
+ };
237
+ const request = new Request(`https://storefront.invalid/${encodeURIComponent(itemId)}`, {
238
+ headers: { 'payment-signature': btoa(JSON.stringify(payload)) },
239
+ });
240
+
241
+ let gate: GateResult;
242
+ try {
243
+ gate = await sellerFor(itemId).guard(request);
244
+ } catch (err) {
245
+ const message = err instanceof Error ? err.message : String(err);
246
+ cfg.onDiagnostic?.({ code: 'guard_threw', message });
247
+ // An exception here is our infrastructure, not the player's signature. The
248
+ // authorization was never redeemed, so the SAME one is safe to present again.
249
+ return decline({ itemId, playerId, code: 'settlement_failed', message, retryable: true });
250
+ }
251
+
252
+ if (gate.settlement) {
253
+ const e: PurchaseSettled = {
254
+ itemId, playerId, playerAddress, amount,
255
+ transaction: gate.settlement.transaction,
256
+ network: gate.settlement.network,
257
+ };
258
+ emit('settled', e);
259
+ // Accrued AFTER the sale is final, and awaited so a batch that lands is reflected in
260
+ // stats() before the caller sees the result. record() never throws - a fee problem
261
+ // must not undo a sale that has already settled on-chain.
262
+ await fee.record(BigInt(amount));
263
+ return e;
264
+ }
265
+
266
+ // Settlement failed on OUR side with a valid payment. The nonce was never redeemed
267
+ // on-chain, so re-presenting the same authorization is safe - and minting a fresh one
268
+ // would risk paying twice if the first settlement later lands.
269
+ if (gate.settlementFailed) {
270
+ return decline({ itemId, playerId, code: 'settlement_failed',
271
+ message: gate.reason ?? 'settlement failed', retryable: true });
272
+ }
273
+
274
+ const reason = gate.reason ?? 'refused';
275
+ const alreadyUsed = reason.includes('nonce already used');
276
+ return decline({
277
+ itemId, playerId,
278
+ code: alreadyUsed ? 'already_used' : 'rejected',
279
+ message: reason,
280
+ // A spent nonce will never become unspent, and a rejected signature will not become
281
+ // valid. Both need a fresh authorization, not a retry.
282
+ retryable: false,
283
+ });
284
+ },
285
+ };
286
+ }