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.
- package/INTEGRATION.md +188 -0
- package/LICENSE +110 -0
- package/README.md +105 -0
- package/dist/batch-manager.d.ts +201 -0
- package/dist/batch-manager.js +291 -0
- package/dist/batch-manager.min.js +1 -0
- package/dist/budget-file.d.ts +106 -0
- package/dist/budget-file.js +270 -0
- package/dist/budget-file.min.js +1 -0
- package/dist/evm-tx.d.ts +55 -0
- package/dist/evm-tx.js +195 -0
- package/dist/evm-tx.min.js +1 -0
- package/dist/proceeds-fee.d.ts +87 -0
- package/dist/proceeds-fee.js +158 -0
- package/dist/proceeds-fee.min.js +1 -0
- package/dist/seller.d.ts +121 -0
- package/dist/seller.js +136 -0
- package/dist/seller.min.js +1 -0
- package/dist/signer.d.ts +64 -0
- package/dist/signer.js +61 -0
- package/dist/signer.min.js +1 -0
- package/dist/storefront.d.ts +143 -0
- package/dist/storefront.js +173 -0
- package/dist/storefront.min.js +1 -0
- package/dist/x402.d.ts +391 -0
- package/dist/x402.js +930 -0
- package/dist/x402.min.js +1 -0
- package/package.json +120 -0
- package/src/batch-manager.ts +351 -0
- package/src/budget-file.ts +314 -0
- package/src/evm-tx.ts +244 -0
- package/src/proceeds-fee.ts +202 -0
- package/src/seller.ts +252 -0
- package/src/signer.ts +129 -0
- package/src/storefront.ts +286 -0
- package/src/x402.ts +1255 -0
|
@@ -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
|
+
}
|